我想分享一个酷酷的程序化NPC系统,我也想解释一下它是如何工作的(注意,这不是一个逐步指南,你需要有一定的Unity、Blender和Shader Graph经验才能复制这个系统)。

这个系统中,每个NPC都使用两个模型,一个男的模型和一个女的模型。通过使用Blend Shape来混合瘦和胖的版本的网格,来实现不同的身体类型。

颜色是通过一个自定义的着色器来实现的,这个着色器的工作原理如下:

  • 在两个模型上都绘制了顶点颜色,皮肤区域是黑色,衬衫区域是红色,裤子区域是绿色,鞋子区域是蓝色。
  • 模型的UV是通过Blender的Project from View unwrapping方法来实现的,腿部区域是通过这个方法来实现的,手臂区域是通过在手腕上标记一个缝合线,然后在手臂中间标记一个缝合线,直到手臂尽头,然后沿着整个身体中线标记一个缝合线,直到腰部。
  • 着色器首先使用顶点颜色来确定皮肤区域,然后将衬衫颜色、裤子颜色和鞋子颜色叠加在上面。
  • 然后为了允许像短裤和背心这样的物品,我们使用模型的UV作为参考点来确定顶点在模型中的位置(我们不使用模型的位置,因为这会在模型变形时打破)。然后我们使用一些基本的数学来说,如果任何像素低于这个特定阈值,我们就应用皮肤颜色。

为了生成具有多样化物品设置的NPC,同时使用同一个材质,我们使用了一个自定义脚本来随机化大量的材质设置。这个脚本将在下面的帖子中提供。

着色器在行动

整个NPC着色器(注意:这个着色器有一些bug,但它工作得很好。如果你有任何改进的建议,我会非常感激。)

图像说明了UV映射应该是什么样的,以及如何标记缝合线

你的顶点绘制应该是什么样的

任何话都得到了,谢谢,因为这是一个非常酷的东西,我想记录一下。有人可能会问为什么需要这样做?不能简单地创建多个不同的模型来实现不同的服装?不能在运行时简单地交换纹理?不能简单地使用不同的材质?

答案是所有的都来到了性能。如果你需要创建大量不同版本的NPC(潜在上百个),那么所有这些模型都会占用大量的内存,这可能会影响其他东西,如更多的建筑或房屋。这个方法只需要两个模型就可以实现NPC的所有版本,这大大减少了内存的占用。交换纹理在运行时也是不理想的,因为你需要大量不同的纹理来实现相同的视觉多样性,这会导致游戏文件大小的膨胀。使用不同的材质也是不理想的,因为这会导致批处理问题,因为你无法将场景中具有不同材质的对象批处理在一起。这个方法解决了所有这些问题。由于颜色是通过程序生成的,你不需要担心纹理会导致文件大小膨胀;由于只需要两个模型就可以实现NPC的所有版本,你不需要担心模型会占用内存;由于NPC只使用一个材质,你不需要担心绘制调用。

这个方法的缺点是这个方法只适用于低多边形风格的游戏,而且实现起来也有一点困难。它不是很难,但需要一些耐心和耐心。另外,由于顶点颜色绘制,只有4个通道可用,所以你不能很灵活地处理服装。

TL-DR:

我创建了一个系统来程序化NPC。这个系统使用顶点颜色来确定皮肤区域,衬衫区域,裤子区域和鞋子区域。然后它使用模型的UV来确定顶点在模型中的位置,然后进行一个基本的检查,如果像素低于某个阈值,就应用皮肤颜色。这是为了实现像短裤和背心这样的物品。这个系统是为了节省内存,绘制调用和存储空间而设计的,因为NPC只使用两个模型和一个材质。

脚本用于生成NPC(脚本附在场景中的一个游戏对象上。脚本中属性的名称反映了我的着色器中属性的名称。如果你在你的着色器中使用相同的属性名称,你就可以直接复制这个脚本)。

using UnityEngine;

public class NPCSpawner : MonoBehaviour
{
    [Header("References")]
    public GameObject malePrefab;
    public GameObject femalePrefab;

    [Header("Spawn Settings")]
    public float spawnRadius;
    public int spawnAmount;

    [Header("Variation Settings")]
    public Vector2 bodyTypeRange;
    public Vector2 shirtCutoffRange;
    public Vector2 pantsCutoffRange;
    public Vector2 sizeRange;
    public Color[] raceColors;
    public ColorProfile npcClothingColorProfile;

    private static readonly int RaceColorPropID = Shader.PropertyToID("_Race_Color");
    private static readonly int ShirtColorPropID = Shader.PropertyToID("_Shirt_Color");
    private static readonly int PantsColorPropID = Shader.PropertyToID("_Pants_Color");
    private static readonly int ShoesColorPropID = Shader.PropertyToID("_Shoes_Color");

    private static readonly int ShirtCutoffPropID = Shader.PropertyToID("_Shirt_Cutoff");
    private static readonly int PantsCutoffPropID = Shader.PropertyToID("_Pants_Cutoff");

    public enum ColorProfile
    {
        Unsaturated,
        HighlySaturated,
        Dark,
        Navy
    }

    void Start()
    {
        SpawnNPCS();
    }

    void SpawnNPCS()
    {
        for (int i = 0; i < spawnAmount / 2; i++)
        {
            SpawnNPC(malePrefab);
        }

        for (int i = 0; i < spawnAmount / 2; i++)
        {
            SpawnNPC(femalePrefab);
        }
    }

    void OnDrawGizmosSelected()
    {
        Gizmos.DrawWireSphere(transform.position, spawnRadius);
        Gizmos.color = Color.blue;
    }

    void SpawnNPC(GameObject npcToSpawn)
    {
        GameObject npcPrefabDupe = RandomSpawnPoint(npcToSpawn);

        SkinnedMeshRenderer npcSkinnedMesh = npcPrefabDupe.GetComponentInChildren<SkinnedMeshRenderer>();

        SetRandomBodyType(npcSkinnedMesh, bodyTypeRange);

        RandomColorWithArray(npcSkinnedMesh, raceColors, RaceColorPropID);
        RandomColorWithProfile(npcSkinnedMesh, npcClothingColorProfile, ShirtColorPropID);
        RandomColorWithProfile(npcSkinnedMesh, npcClothingColorProfile, PantsColorPropID);
        RandomColorWithProfile(npcSkinnedMesh, npcClothingColorProfile, ShoesColorPropID);

        SetRandomCutoff(npcSkinnedMesh, shirtCutoffRange, ShirtCutoffPropID);
        SetRandomCutoff(npcSkinnedMesh, pantsCutoffRange, PantsCutoffPropID);

        SetRandomSize(npcPrefabDupe, sizeRange);
    }

    GameObject RandomSpawnPoint(GameObject prefab)
    {
        Vector2 randomPoint = Random.insideUnitCircle * spawnRadius;
        Vector3 spawnPos = transform.position + new Vector3(randomPoint.x, 0, randomPoint.y);

        GameObject prefabDupe = Instantiate(prefab, spawnPos, Quaternion.identity);

        return prefabDupe;
    }

    void SetRandomBodyType(SkinnedMeshRenderer skinnedMesh, Vector2 range)
    {
        skinnedMesh.SetBlendShapeWeight(0, Random.Range(range.x, range.y));
    }

    void SetRandomSize(GameObject prefabDupe, Vector2 range)
    {
        Vector3 targetScale = new Vector3(1f, Random.Range(range.x, range.y), 1f);

        prefabDupe.transform.localScale = targetScale;
    }

    void SetRandomCutoff(SkinnedMeshRenderer skinnedMesh, Vector2 range, int cutoffID)
    {
        float targetCutoff = Random.Range(range.x, range.y);

        skinnedMesh.material.SetFloat(cutoffID, targetCutoff);
    }

    void RandomColorWithArray(SkinnedMeshRenderer skinnedMesh, Color[] colors, int propID)
    {
        int randomIndex = Random.Range(0, colors.Length);
        Color chosenColor = colors[randomIndex];

        skinnedMesh.material.SetColor(propID, chosenColor);
    }

    void RandomColorWithProfile(SkinnedMeshRenderer skinnedMesh, ColorProfile colorProfile, int propID)
    {
        float minH = 0f, maxH = 1f;
        float minS = 0f, maxS = 1f;
        float minV = 0f, maxV = 1f;

        switch (colorProfile)
        {
            case ColorProfile.Unsaturated:
                minS = 0.1f; maxS = 0.35f;
                minV = 0.75f; maxV = 0.95f;
                break;

            case ColorProfile.HighlySaturated:
                minS = 0.85f; maxS = 1.0f;
                minV = 0.80f; maxV = 1.0f;
                break;

            case ColorProfile.Dark:
                minS = 0.3f; maxS = 0.9f;
                minV = 0.15f; maxV = 0.35f;
                break;

            case ColorProfile.Navy:
                // Restrict hue mapping strictly to the blue spectrum
                minH = 0.58f; maxH = 0.66f;
                minS = 0.65f; maxS = 0.95f;
                minV = 0.15f; maxV = 0.45f;
                break;
        }

        float randomH = Random.Range(minH, maxH);
        float randomS = Random.Range(minS, maxS);
        float randomV = Random.Range(minV, maxV);

        Color chosenColor = Color.HSVToRGB(randomH, randomS, randomV);

        skinnedMesh.material.SetColor(propID, chosenColor);
    }
}