JS程序化角色生成
最近,有人联系我,想要为他们要制作的游戏提供一些随机角色。
我从来没有研究过程序化角色生成,关于如何这样做的信息很少,但我决定接受挑战并让它发挥作用。
在这篇文章中,我将探讨我用来生成随机字符的几种方法。在这里我们考虑随机的头部,但这些技术也可以应用于其他身体部位。
可以在此处的 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翻译整理,转载请标明出