NSDT工具推荐Three.js AI纹理开发包 - YOLO合成数据生成器 - GLTF/GLB在线编辑 - 3D模型格式在线转换 - 可编程3D场景编辑器 - REVIT导出3D模型插件 - 3D模型语义搜索引擎 - Three.js虚拟轴心开发包 - 3D模型在线减面 - STL模型在线切割

最近,有人联系我,想要为他们要制作的游戏提供一些随机角色。

我从来没有研究过程序化角色生成,关于如何这样做的信息很少,但我决定接受挑战并让它发挥作用。

在这篇文章中,我将探讨我用来生成随机字符的几种方法。在这里我们考虑随机的头部,但这些技术也可以应用于其他身体部位。

可以在此处的 GitHub 上找到演示代码

1、问题背景

我想到的解决这个问题的第一种方法是让一个零件有几个变体,然后每次都随机选择一个来切换它们。

这是一个简单而合乎逻辑的第一步,但我错了。

我联系了他们的艺术家,创造了一些头部的变化并试了一下。

我会将每个部分及其变体导出到一个文件中。 因此头部及其变体位于 head.gltf 中。 head 的变体按此约定命名 - head_1、head_2…head_n。

以下是我将如何使用它们:

  • 载入文件
  • 生成一个介于 1 和 n 之间的随机数 (r)
  • 遍历文件的场景图
  • 将 head_r 添加到场景中

1.1 存在的问题

现在这很好,而且有效,但我遇到了对齐问题。 将所有其他部分排成一行非常困难,因此它们看起来不错。 鼻子、耳朵、眼睛等部位都错位了,一点都不好看。

另一个问题是 - 要生成另一个随机配置,必须再次加载文件或将所有模型保存在内存中并有选择地渲染。

这很……笨拙。

1.2 更好的方法

一周过去了,客户告诉我他们雇用了另一位艺术家。 这家伙用不同的艺术风格制作了模型,他们给我发了一张 GIF。

它演示了艺术家在 Blender 中使用形状键(Shape Keys)!

如果我们将变体变成形状键,并且我可以在 ThreeJS 中控制形状键,那么我们可能会有无限的组合,一切都会完美对齐! 你知道吗,WebGL 正是我所需要的——Morph Targets。 事实上,在 ThreeJS 中,Morph Targets 非常容易控制。

1.3 使用变形目标

要使用 Morph Targets,首先,你需要在其数据中导出带有它们的模型。 在 Blender 中,这就像创建一些形状键并在启用它们的情况下导出模型一样简单。

接下来,在 ThreeJS 中,导入模型后,Mesh 将包含几个属性:

  • morphTargetDictionary:这是一个对象,其中键是形状键的名称,值是索引。 指数成什么? 出色地…
  • morphTargetInfluences:索引到这个数组中。 该数组保存每个形状键的权重,就像 Blender 中的“值”滑块一样。

网格在两个形状(原始形状和目标形状)之间平滑变换,权重控制变换的“百分比”。 所以权重 0 是原始网格,1 是目标网格。 介于两者之间的任何东西都是两者的结合。

1.4 为什么这样更好?

这更好,因为一个简单的事实。 变形目标可以由一个数字控制。

这意味着很多事情,其中之一就是我们可以将该值连接到一个滑块。 这样我们就可以制作一个基本的角色创建工具。

此外,由于每个权重的范围可以从 0 到 1,并且中间有无限多个值,因此我们可以获得无限多种组合!

我们还可以用一个权重同时驱动多个形状键。 我让艺术家用相同的名字命名他想要一起驱动的键。 这样,当不同的部分变形时,没有元素会错位或剪裁另一个元素。

1.5 局限性

没有任何限制。 WebGL 将单个网格上的变形目标数量限制为八个。 这是一个进一步讨论这个问题的问题。

有很多方法可以解决这个问题,但它们对于我正在做的事情来说太复杂了。 因此,为了解决这个问题,艺术家只需将网格分成更小的网格,每个网格最多有八个形状键。

1.6 模型

新模型是由一位非常有才华的艺术家创建的。 他仔细地将八个形状键建模到每个网格中。 这里只是头部:

我隔离了头部并使用 Blender 的 glTF 2.0 插件将其导出为 GLTF 文件格式。 我们终于可以开始编码了!

2、代码

了解所有这些上下文后,让我们深入研究代码。 本节将快速移动,因为这并不是一个“初学者指南”。

2.1 加载模型

设置一些样板代码后,使用 ThreeJS 的 GLTFLoader 类加载模型。

const loader = new GLTFLoader();
loader.load(`./Assets/head.gltf`, (gltf) => {
  const object = gltf.scene;

  object.traverse((child) => {
    if (child.isMesh) {
      child.material.metalness = 0;
      child.material.vertexColors = false;
    }
  });

  scene.add(object);
});

这位艺术家也很友善地绘制了一些纹理。 使用 ThreeJS 的 TextureLoader 类从 .png 图像加载纹理。

const loader = new GLTFLoader();

// 👋 Loading the texture
const texture = new THREE.TextureLoader().load("./Diffuse.png");
texture.flipY = false;
texture.encoding = THREE.sRGBEncoding;

loader.load(`./Assets/head.gltf`, (gltf) => {
  const object = gltf.scene;

  object.traverse((child) => {
    if (child.isMesh) {
      child.material.metalness = 0;
      child.material.vertexColors = false;
      child.material.map = texture; // 👈 Using the texture
    }
  });

  scene.add(object);
});

2.2 变形目标

我们可以通过记录网格的 morphTargetDictionary 属性来检查模型的变形目标。

loader.load(`./Assets/head.gltf`, (gltf) => {
  const object = gltf.scene;

  object.traverse((child) => {
    if (child.isMesh) {
      child.material.metalness = 0;
      child.material.vertexColors = false;
      child.material.map = texture;
    }
  });

  // 👋 Here
  console.log(object.morphTargetDictionary);

  scene.add(object);
});

运行结果如下:

未定义?...什么给了? 我是否错误地导出了模型?

没有! GLTF 场景对象不是我们的网格,它只包含我们所有的网格。 我们需要遍历GLTF场景的场景图,找到我们的对象。

当然,我们可以使用 Object3D.getObjectByName 按名称找到我们的对象,但我在导出网格时没有命名我的网格,所以我将使用老式的方法。

loader.load(`./Assets/head.gltf`, (gltf) => {
  const object = gltf.scene;

  object.traverse((child) => {
    if (child.isMesh) {
      child.material.metalness = 0;
      child.material.vertexColors = false;
      child.material.map = texture;

      // 👋 Here
      if (child.morphTargetDictionary) console.log(child.morphTargetDictionary);
    }
  });

  scene.add(object);
});

答对了! 我们在 ThreeJS 中找到了形状键。

2.3 异步模型加载

回调让我很困惑。 我很快就会像这样Promisify处理 GLTFLoader.load 函数:

const head = await new Promise((res, rej) => {
  loader.load(
    `./Assets/head.gltf`,
    (gltf) => {
      const object = gltf.scene;

      object.traverse((child) => {
        if (child.isMesh) {
          child.material.metalness = 0;
          child.material.vertexColors = false;
          child.material.map = texture;
        }
      });

      res(object);
    },
    (e) => rej(e)
  );
});
scene.add(head);

有点难看,但我现在可以确定在模型加载后头部将可用。 我可能很快就会做出一个three-promises库,因为我不喜欢回调。

2.4 储存变形目标

现在我们知道变形目标在哪里,我们可以简单地将它们随机化到 console.log 所在的位置。 但这意味着我们只能在再次加载模型时才能获得新的变体。

我想在按下按钮时生成一个新角色。

为此,我们可以将目标与其他一些数据一起存储,并在以后使用它们。 这样我们也可以有额外的逻辑来一起控制同名目标。 这是我将如何存储它们:

// A little TypeScript pseudo-code just for demonstration purposes.
type morphTarget = {
  index: typeof child.morphTargetDictionary[morphTargetName];
  child: typeof child;
};

type morphTargetMap = {
  morphTargetName: morphTarget[];
};

// 👇 I will use this object to store the data I need.
const morphTargets: morphTargetMap = {};

这样,所有同名的目标连同对其相应网格的引用和网格 morphTargetInfluences 中的索引一起存储。 这是它在代码中的样子:

const morphTargets = {};
const head = await new Promise((res, rej) => {
  loader.load(
    `./Assets/head.gltf`,
    (gltf) => {
      const object = gltf.scene;

      object.traverse((child) => {
        if (child.isMesh) {
          child.material.metalness = 0;
          child.material.vertexColors = false;
          child.material.map = texture;

          // 👋 Here is where I add stuff to the object
          if (child.morphTargetDictionary) {
            for (const key in child.morphTargetDictionary) {
              const index = child.morphTargetDictionary[key];
              if (Array.isArray(morphTargets[key])) {
                morphTargets[key].push({ index, child });
              } else {
                morphTargets[key] = [];
                morphTargets[key].push({ index, child });
              }
            }
          }
        }
      });

      res(object);
    },
    (e) => rej(e)
  );
});
scene.add(head);

// 👋 Lets log this object
console.log(morphTargets);

我们现在有了一个漂亮的对象,其中包含我们随意使用变形目标所需的所有信息。

2.5 图形用户界面

让我们创建一些滑块来帮助我们改变模型的外观。 我将使用由 ThreeJS = MrDoob 的创建者创建的库 dat.GUI。

我将遍历我们所有独特的变形目标并为每个目标创建一个滑块。 由于权重应介于 0 和 1 之间,因此我会将滑块的最小值和最大值设置为这些值。

const gui = new dat.GUI();

// Temporary object holds our influences. It's a
// weird little quirk of dat.GUI
const influences = {};

// Loop through all targets
for (const key in morphTargets) {
  // 👇 Get the individual targets associated with that key
  const targets = morphTargets[key];

  // Set an initial weight by using the first
  // target.
  const { child, index } = targets[0];
  influences[key] = child.morphTargetInfluences[index];

  // Add stuff to the GUI
  gui.add(influences, key, 0, 1, 0.01).onChange((v) => {
    targets.forEach(({ child, index }) => {
      child.morphTargetInfluences[index] = v;
    });
  });
}

完美! 我们现在可以使用滑块控制变形目标。

2.6 随机化

这个过程的最后一步也是我们最初打算做的是随机化权重,这样每次点击按钮都会创建一个随机角色。

为此,我们只需遍历我们的 morphTargets 对象并为每个 morphTargetInfluence 分配一个随机权重。

const funcs = {
  Randomize: () => {
    // Loop over all morph targets by name
    for (const key in morphTargets) {
      // Set each of them individual weights assciated with that name
      influences[key] = Math.random();
      morphTargets[key].forEach(({ child, index }) => {
        child.morphTargetInfluences[index] = influences[key];
      });
    }

    // Update the GUI to use the latest weigths
    gui.updateDisplay();
  },
};

我会将此功能作为按钮添加到 GUI。 对于这一部分,我还将滑块分组到一个文件夹中。

const gui = new dat.GUI({ autoPlace: false });
const folder = gui.addFolder("Sliders"); // Using a folder

const influences = {};
for (const key in morphTargets) {
  const targets = morphTargets[key];

  const { child, index } = targets[0];
  influences[key] = child.morphTargetInfluences[index];

  folder.add(influences, key, 0, 1, 0.01).onChange(function (v) {
    targets.forEach(({ child, index }) => {
      child.morphTargetInfluences[index] = v;
    });
  });
}

// Closing the folder by default
folder.close();

// Our randomization function
const funcs = {
  Randomize: () => {
    for (const key in morphTargets) {
      influences[key] = Math.random();
      morphTargets[key].forEach(({ child, index }) => {
        child.morphTargetInfluences[index] = influences[key];
      });
    }

    gui.updateDisplay();
  },
};

// Add that function as a button to the GUI
gui.add(funcs, "Randomize");

完美! 现在我们可以通过单击按钮来随机化面孔! 整洁吧?

你拥有的变化越多越好。 因为我这里只有一把,没什么特别的,但你明白了。

您可以将这个概念连接到一个循环中,并生成一组随机面孔,就像我为本文的缩略图所做的那样。 你可以在此处查看演示


原文链接:Character creation in ThreeJS

BimAnt翻译整理,转载请标明出