From a158b85f4eff6f3fdfa4dfdaa01087fca9fabcf6 Mon Sep 17 00:00:00 2001
From: "Mr.doob" When I first started this I used just one radius for all animals
but of course that was no good as the pug is much smaller than the horse.
So I added the difference sizes but I wanted to be able to visualize
-things. To do that I made a Making a Game
StatusDisplayHelper component.StateDisplayHelper component.
I uses a PolarGridHelper to draw a circle around each character
and it uses html elements to let each character show some status using
the techniques covered in the article on aligning html elements to 3D.
To make a coroutine you make a JavaScript generator function.
A generator function is preceded by the keyword function* (the asterisk is important!)
Generator functions can yield. For example
function* countOTo9() {
+function* count0To9() {
for (let i = 0; i < 10; ++i) {
console.log(i);
yield;
diff --git a/manual/fr/game.html b/manual/fr/game.html
index bc87447df8ee68..21b96c6d2c2823 100644
--- a/manual/fr/game.html
+++ b/manual/fr/game.html
@@ -1196,7 +1196,7 @@ Créer un jeu
Lorsque j'ai commencé cela, j'ai utilisé un seul rayon pour tous les animaux,
mais bien sûr, ce n'était pas bon, car le carlin est beaucoup plus petit que le cheval.
J'ai donc ajouté les différentes tailles, mais je voulais pouvoir visualiser
-les choses. Pour ce faire, j'ai créé un composant StatusDisplayHelper.
+les choses. Pour ce faire, j'ai créé un composant StateDisplayHelper.
J'utilise un PolarGridHelper pour dessiner un cercle autour de chaque personnage,
et il utilise des éléments html pour permettre à chaque personnage d'afficher un certain statut en utilisant
les techniques couvertes dans l'article sur l'alignement des éléments html en 3D.
@@ -1498,7 +1498,7 @@ Créer un jeu
Pour créer une coroutine, vous créez une fonction génératrice JavaScript.
Une fonction génératrice est précédée du mot-clé function* (l'astérisque est important !)
Les fonctions génératrices peuvent yield (céder). Par exemple
-function* countOTo9() {
+function* count0To9() {
for (let i = 0; i < 10; ++i) {
console.log(i);
yield;
diff --git a/manual/ko/game.html b/manual/ko/game.html
index b2aa2e07d3a6fc..7df5ab41148927 100644
--- a/manual/ko/game.html
+++ b/manual/ko/game.html
@@ -1206,7 +1206,7 @@ 로 게임 만들기
+ globals.playerRadius = model.size / 2;
이제 와 생각해보니 플레이어가 아니라 기차의 머리를 바라보게 하는 편이 더 나았겠네요. 이건 나중에 돌아와 고치도록 하겠습니다.
-예제를 처음 만들었을 때는 동물들이 모두 같은 크기의 경계 원(radius)을 썼지만, 이렇게 하고 보니 말과 퍼그(강아지)의 크기가 같은 게 말이 안 된다는 생각이 들었습니다. 그래서 각 모델의 크기에 따라 경계 원을 따로 지정했죠. 그리고 상태를 보여주면 좋겠다는 생각이 들어 상태를 보여 줄 StatusDisplayHelper 컴포넌트를 추가했습니다.
+예제를 처음 만들었을 때는 동물들이 모두 같은 크기의 경계 원(radius)을 썼지만, 이렇게 하고 보니 말과 퍼그(강아지)의 크기가 같은 게 말이 안 된다는 생각이 들었습니다. 그래서 각 모델의 크기에 따라 경계 원을 따로 지정했죠. 그리고 상태를 보여주면 좋겠다는 생각이 들어 상태를 보여 줄 StateDisplayHelper 컴포넌트를 추가했습니다.
또한 PolarGridHelper를 써 각 캐릭터의 경계 원이 보이도록 했고, HTML 요소를 3D로 정렬하기에서 썼던 방법으로 각 캐릭터의 상태를 HTML로 보여주도록 했습니다.
먼저 각 요소를 담을 HTML을 추가합니다.
<body>
@@ -1438,7 +1438,7 @@ 로 게임 만들기
위 클래스는 다른 코루틴이 실행되는 동안 요소를 안전하게 제거/추가하도록 SafeArray와 비슷한 구조로 만들었습니다. 또한 이 클래스는 중첩된 코루틴도 처리합니다.
코루틴을 만들려면 자바스크립트의 제너레이터 함수를 만들어야 합니다. 제너레이터 함수는 function*이라는 키워드로 생성하죠(별표를 붙여야 합니다!).
제너레이터 함수는 yield 키워드로 실행 순서를 양보(yield)할 수 있습니다.
-function* countOTo9() {
+function* count0To9() {
for (let i = 0; i < 10; ++i) {
console.log(i);
yield;
diff --git a/manual/zh/game.html b/manual/zh/game.html
index 1a6bd3c0641de1..3e5cf057883f45 100644
--- a/manual/zh/game.html
+++ b/manual/zh/game.html
@@ -1,10 +1,10 @@
- Making a Game
+ 制作一个游戏
-
+
@@ -18,18 +18,1624 @@
}
}
-
- Making a Game
+ 制作一个游戏
- 抱歉,还没有中文翻译哦。 欢迎加入翻译! 😄
-
+ 很多人想用 three.js 来写游戏。这篇文章希望能给你一些如何开始的思路。
+至少在我写这篇文章的时候,它可能会成为本站最长的文章。这里的代码可能过度设计了,但在我编写每个新功能时,都会遇到需要解决的问题,而这些解决方案都来自我以前写过的其他游戏。换句话说,每个新的解决方案看起来都很重要,所以我会尽量解释为什么需要它们。当然,你的游戏越小,就越不需要这里展示的某些解决方案,但这本身是一个相当小的游戏,然而由于 3D 角色的复杂性,许多事情比 2D 角色需要更多的组织。
+举个例子,如果你在制作 2D 版的吃豆人,吃豆人转弯时会瞬间完成 90 度旋转,没有中间过程。但在 3D 游戏中,我们通常需要角色在多帧之间旋转。这个简单的变化就会增加很多复杂性,并需要不同的解决方案。
+这里的大部分代码实际上并不是 three.js 的代码,这一点很重要,three.js 不是一个游戏引擎。Three.js 是一个 3D 库。它提供了一个场景图以及在场景图中显示 3D 对象的功能,但它不提供制作游戏所需的所有其他东西。没有碰撞检测,没有物理引擎,没有输入系统,没有寻路等等...所以,我们必须自己提供这些功能。
+我最终写了相当多的代码来制作这个简单的未完成的游戏原型,而且我觉得可能过度设计了,应该有更简单的解决方案,但我觉得我实际上还没有写够代码,希望我能解释我认为还缺少什么。
+这里的许多想法深受 Unity 的影响。如果你不熟悉 Unity,那可能并不重要。我提到它只是因为有数以万计的游戏是使用这些理念发布的。
+让我们从 three.js 部分开始。我们需要为游戏加载模型。
+在 opengameart.org 上我找到了这个由 quaternius 制作的动画骑士模型
+
+quaternius 还制作了这些动画动物。
+
+
+这些看起来是很好的起步模型,所以我们首先需要加载它们。
+我们之前讲过加载 glTF 文件。这次的不同之处在于我们需要加载多个模型,而且在所有模型加载完成之前不能开始游戏。
+幸运的是 three.js 提供了 LoadingManager 来满足这个需求。我们创建一个 LoadingManager 并将它传递给其他加载器。LoadingManager 提供了 onProgress 和 onLoad 属性供我们附加回调函数。当所有文件加载完成时会调用 onLoad 回调。每个单独的文件加载完成后会调用 onProgress 回调,让我们有机会显示加载进度。
+从加载 glTF 文件的代码开始,我移除了所有与场景取景相关的代码,并添加了以下代码来加载所有模型。
+const manager = new THREE.LoadingManager();
+manager.onLoad = init;
+const models = {
+ pig: { url: 'resources/models/animals/Pig.gltf' },
+ cow: { url: 'resources/models/animals/Cow.gltf' },
+ llama: { url: 'resources/models/animals/Llama.gltf' },
+ pug: { url: 'resources/models/animals/Pug.gltf' },
+ sheep: { url: 'resources/models/animals/Sheep.gltf' },
+ zebra: { url: 'resources/models/animals/Zebra.gltf' },
+ horse: { url: 'resources/models/animals/Horse.gltf' },
+ knight: { url: 'resources/models/knight/KnightCharacter.gltf' },
+};
+{
+ const gltfLoader = new GLTFLoader(manager);
+ for (const model of Object.values(models)) {
+ gltfLoader.load(model.url, (gltf) => {
+ model.gltf = gltf;
+ });
+ }
+}
+
+function init() {
+ // 待实现
+}
+
+这段代码会加载上面所有的模型,LoadingManager 会在完成后调用 init。我们稍后会使用 models 对象来访问已加载的模型,所以每个模型的 GLTFLoader 回调会将加载的数据附加到该模型的信息上。
+所有模型及其动画目前大约 6.6MB。这是一个相当大的下载量。假设你的服务器支持压缩(本站的服务器就支持),可以将它们压缩到大约 1.4MB。这肯定比 6.6MB 好,但仍然不是很小的数据量。如果我们添加一个进度条,让用户知道还需要等待多长时间,那就好了。
+所以,让我们添加一个 onProgress 回调。调用时会传入 3 个参数:最后加载的对象的 url,到目前为止已加载的项目数量,以及项目总数。
+让我们设置一些 HTML 来做加载条
+<body>
+ <canvas id="c"></canvas>
++ <div id="loading">
++ <div>
++ <div>...loading...</div>
++ <div class="progress"><div id="progressbar"></div></div>
++ </div>
++ </div>
+</body>
+
+我们会查找 #progressbar div,并将其宽度从 0% 设置到 100% 来显示进度。我们只需要在回调中设置它即可。
+const manager = new THREE.LoadingManager();
+manager.onLoad = init;
+
++const progressbarElem = document.querySelector('#progressbar');
++manager.onProgress = (url, itemsLoaded, itemsTotal) => {
++ progressbarElem.style.width = `${itemsLoaded / itemsTotal * 100 | 0}%`;
++};
+
+我们已经设置了 init 在所有模型加载完成时被调用,所以我们可以通过隐藏 #loading 元素来关闭进度条。
+function init() {
++ // 隐藏加载条
++ const loadingElem = document.querySelector('#loading');
++ loadingElem.style.display = 'none';
+}
+
+这是一堆用于样式化进度条的 CSS。CSS 使 #loading <div> 占满整个页面并居中其子元素。CSS 创建了一个 .progress 区域来包含进度条。CSS 还为进度条添加了对角条纹的 CSS 动画。
+#loading {
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+ font-size: xx-large;
+ font-family: sans-serif;
+}
+#loading>div>div {
+ padding: 2px;
+}
+.progress {
+ width: 50vw;
+ border: 1px solid black;
+}
+#progressbar {
+ width: 0;
+ transition: width ease-out .5s;
+ height: 1em;
+ background-color: #888;
+ background-image: linear-gradient(
+ -45deg,
+ rgba(255, 255, 255, .5) 25%,
+ transparent 25%,
+ transparent 50%,
+ rgba(255, 255, 255, .5) 50%,
+ rgba(255, 255, 255, .5) 75%,
+ transparent 75%,
+ transparent
+ );
+ background-size: 50px 50px;
+ animation: progressanim 2s linear infinite;
+}
+
+@keyframes progressanim {
+ 0% {
+ background-position: 50px 50px;
+ }
+ 100% {
+ background-position: 0 0;
+ }
+}
+
+现在我们有了进度条,让我们来处理模型。这些模型有动画,我们希望能够访问这些动画。动画默认存储在数组中,但我们希望能够通过名称轻松访问它们,所以让我们为每个模型设置一个 animations 属性来实现这一点。当然,这意味着动画必须有唯一的名称。
++function prepModelsAndAnimations() {
++ Object.values(models).forEach(model => {
++ const animsByName = {};
++ model.gltf.animations.forEach((clip) => {
++ animsByName[clip.name] = clip;
++ });
++ model.animations = animsByName;
++ });
++}
+
+function init() {
+ // 隐藏加载条
+ const loadingElem = document.querySelector('#loading');
+ loadingElem.style.display = 'none';
+
++ prepModelsAndAnimations();
+}
+
+让我们显示带动画的模型。
+与之前加载 glTF 文件的例子不同,这次我们可能想要显示每个模型的多个实例。为此,我们不是像在加载 glTF 文章中那样直接添加加载的 gltf 场景,而是要克隆场景,特别是为蒙皮动画角色克隆场景。幸运的是有一个工具函数 SkeletonUtils.clone 可以用来做这件事。所以,首先我们需要引入这个工具。
+import * as THREE from 'three';
+import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
+import {GLTFLoader} from 'three/addons/loaders/GLTFLoader.js';
++import * as SkeletonUtils from 'three/addons/utils/SkeletonUtils.js';
+
+然后我们可以克隆刚刚加载的模型
+function init() {
+ // 隐藏加载条
+ const loadingElem = document.querySelector('#loading');
+ loadingElem.style.display = 'none';
+
+ prepModelsAndAnimations();
+
++ Object.values(models).forEach((model, ndx) => {
++ const clonedScene = SkeletonUtils.clone(model.gltf.scene);
++ const root = new THREE.Object3D();
++ root.add(clonedScene);
++ scene.add(root);
++ root.position.x = (ndx - 3) * 3;
++ });
+}
+
+上面的代码中,对于每个模型,我们克隆了加载的 gltf.scene 并将其挂载到一个新的 Object3D 上。我们需要将它挂载到另一个对象上,因为播放动画时,动画会将动画位置应用到加载场景中的节点上,这意味着我们将无法控制这些位置。
+要播放动画,每个克隆的模型都需要一个 AnimationMixer。一个 AnimationMixer 包含一个或多个 AnimationAction。AnimationAction 引用一个 AnimationClip。AnimationAction 有各种播放设置,可以链接到另一个动作或在动作之间交叉淡入淡出。让我们先获取第一个 AnimationClip 并为它创建一个动作。默认情况下,动作会永远循环播放其片段。
++const mixers = [];
+
+function init() {
+ // 隐藏加载条
+ const loadingElem = document.querySelector('#loading');
+ loadingElem.style.display = 'none';
+
+ prepModelsAndAnimations();
+
+ Object.values(models).forEach((model, ndx) => {
+ const clonedScene = SkeletonUtils.clone(model.gltf.scene);
+ const root = new THREE.Object3D();
+ root.add(clonedScene);
+ scene.add(root);
+ root.position.x = (ndx - 3) * 3;
+
++ const mixer = new THREE.AnimationMixer(clonedScene);
++ const firstClip = Object.values(model.animations)[0];
++ const action = mixer.clipAction(firstClip);
++ action.play();
++ mixers.push(mixer);
+ });
+}
+
+我们调用了 play 来启动动作,并将所有 AnimationMixer 存储在一个名为 mixers 的数组中。最后我们需要在渲染循环中更新每个 AnimationMixer,计算自上一帧以来经过的时间并将其传递给 AnimationMixer.update。
++let then = 0;
+function render(now) {
++ now *= 0.001; // 转换为秒
++ const deltaTime = now - then;
++ then = now;
+
+ if (resizeRendererToDisplaySize(renderer)) {
+ const canvas = renderer.domElement;
+ camera.aspect = canvas.clientWidth / canvas.clientHeight;
+ camera.updateProjectionMatrix();
+ }
+
++ for (const mixer of mixers) {
++ mixer.update(deltaTime);
++ }
+
+ renderer.render(scene, camera);
+
+ requestAnimationFrame(render);
+}
+
+这样我们应该能加载每个模型并播放其第一个动画了。
+
+
+ 点击此处在新标签页中打开
+
+
+
+让我们能够检查所有动画。我们将所有片段作为动作添加,然后一次只启用一个。
+-const mixers = [];
++const mixerInfos = [];
+
+function init() {
+ // 隐藏加载条
+ const loadingElem = document.querySelector('#loading');
+ loadingElem.style.display = 'none';
+
+ prepModelsAndAnimations();
+
+ Object.values(models).forEach((model, ndx) => {
+ const clonedScene = SkeletonUtils.clone(model.gltf.scene);
+ const root = new THREE.Object3D();
+ root.add(clonedScene);
+ scene.add(root);
+ root.position.x = (ndx - 3) * 3;
+
+ const mixer = new THREE.AnimationMixer(clonedScene);
+- const firstClip = Object.values(model.animations)[0];
+- const action = mixer.clipAction(firstClip);
+- action.play();
+- mixers.push(mixer);
++ const actions = Object.values(model.animations).map((clip) => {
++ return mixer.clipAction(clip);
++ });
++ const mixerInfo = {
++ mixer,
++ actions,
++ actionNdx: -1,
++ };
++ mixerInfos.push(mixerInfo);
++ playNextAction(mixerInfo);
+ });
+}
+
++function playNextAction(mixerInfo) {
++ const {actions, actionNdx} = mixerInfo;
++ const nextActionNdx = (actionNdx + 1) % actions.length;
++ mixerInfo.actionNdx = nextActionNdx;
++ actions.forEach((action, ndx) => {
++ const enabled = ndx === nextActionNdx;
++ action.enabled = enabled;
++ if (enabled) {
++ action.play();
++ }
++ });
++}
+
+上面的代码为每个 AnimationClip 创建了一个 AnimationAction 数组。它创建了一个 mixerInfos 对象数组,其中包含对每个模型的 AnimationMixer 和所有 AnimationAction 的引用。然后它调用 playNextAction,将除了一个动作之外的所有动作的 enabled 设为 false。
+我们需要为新数组更新渲染循环
+-for (const mixer of mixers) {
++for (const {mixer} of mixerInfos) {
+ mixer.update(deltaTime);
+}
+
+让我们实现按键 1 到 8 来播放每个模型的下一个动画
+window.addEventListener('keydown', (e) => {
+ const mixerInfo = mixerInfos[e.keyCode - 49];
+ if (!mixerInfo) {
+ return;
+ }
+ playNextAction(mixerInfo);
+});
+
+现在你应该能点击示例,然后按 1 到 8 键来循环切换每个模型的可用动画。
+
+
+ 点击此处在新标签页中打开
+
+
+
+这基本上就是本文 three.js 部分的全部内容了。我们讲解了加载多个文件、克隆蒙皮模型以及在它们上播放动画。在真正的游戏中,你需要对 AnimationAction 对象做更多的操作。
+让我们开始构建游戏基础架构
+制作现代游戏的一个常见模式是使用实体组件系统(Entity Component System)。在实体组件系统中,游戏中的对象被称为实体(entity),由一组组件(component)组成。你通过决定将哪些组件附加到实体上来构建实体。那么,让我们来构建一个实体组件系统。
+我们将实体称为 GameObject。它实际上只是组件的集合和一个 three.js Object3D。
+function removeArrayElement(array, element) {
+ const ndx = array.indexOf(element);
+ if (ndx >= 0) {
+ array.splice(ndx, 1);
+ }
+}
+
+class GameObject {
+ constructor(parent, name) {
+ this.name = name;
+ this.components = [];
+ this.transform = new THREE.Object3D();
+ parent.add(this.transform);
+ }
+ addComponent(ComponentType, ...args) {
+ const component = new ComponentType(this, ...args);
+ this.components.push(component);
+ return component;
+ }
+ removeComponent(component) {
+ removeArrayElement(this.components, component);
+ }
+ getComponent(ComponentType) {
+ return this.components.find(c => c instanceof ComponentType);
+ }
+ update() {
+ for (const component of this.components) {
+ component.update();
+ }
+ }
+}
+
+调用 GameObject.update 会调用所有组件的 update。
+我添加 name 只是为了帮助调试,这样在调试器中查看 GameObject 时可以看到一个名称来帮助识别。
+有些东西可能看起来有点奇怪:
+GameObject.addComponent 用于创建组件。我不确定这是好主意还是坏主意。我的想法是,组件存在于游戏对象之外没有意义,所以我认为如果创建组件时自动将该组件添加到游戏对象并将游戏对象传递给组件的构造函数会比较好。换句话说,添加组件时你这样做
+const gameObject = new GameObject(scene, 'foo');
+gameObject.addComponent(TypeOfComponent);
+
+如果我不这样做,你就需要这样写
+const gameObject = new GameObject(scene, 'foo');
+const component = new TypeOfComponent(gameObject);
+gameObject.addComponent(component);
+
+第一种方式更短更自动化,这是更好还是更差,因为它看起来不太常规?我不知道。
+GameObject.getComponent 通过类型查找组件。这意味着你不能在一个游戏对象上有两个相同类型的组件,或者至少如果你有的话,在不添加其他 API 的情况下只能查找到第一个。
+一个组件查找另一个组件是很常见的,查找时必须按类型匹配,否则你可能会找到错误的组件。我们也可以给每个组件一个名称,然后按名称查找。这样会更灵活,因为你可以有多个相同类型的组件,但也会更繁琐。同样,我不确定哪种更好。
+现在来看组件本身。这是它们的基类。
+// 所有组件的基类
+class Component {
+ constructor(gameObject) {
+ this.gameObject = gameObject;
+ }
+ update() {
+ }
+}
+
+组件需要基类吗?JavaScript 不像大多数严格类型语言,所以实际上我们可以没有基类,让每个组件在其构造函数中做它想做的事,知道第一个参数始终是组件的游戏对象。如果它不关心游戏对象就不存储它。但我还是觉得这个公共基类是好的。它意味着如果你有一个组件的引用,你总是可以找到它的父游戏对象,从父对象你可以轻松查找其他组件以及查看它的变换。
+要管理游戏对象,我们可能需要某种游戏对象管理器。你可能认为我们可以只维护一个游戏对象数组,但在真正的游戏中,游戏对象的组件可能在运行时添加和移除其他游戏对象。例如,一个枪游戏对象可能在每次开火时添加一个子弹游戏对象。一个怪物游戏对象可能在被杀死时移除自己。这样我们就会遇到一个问题,我们可能有这样的代码
+for (const gameObject of globalArrayOfGameObjects) {
+ gameObject.update();
+}
+
+如果在某个组件的 update 函数中在循环中途向 globalArrayOfGameObjects 添加或移除游戏对象,上面的循环就会失败或产生意外行为。
+为了防止这个问题,我们需要一些更安全的东西。这是一个尝试。
+class SafeArray {
+ constructor() {
+ this.array = [];
+ this.addQueue = [];
+ this.removeQueue = new Set();
+ }
+ get isEmpty() {
+ return this.addQueue.length + this.array.length > 0;
+ }
+ add(element) {
+ this.addQueue.push(element);
+ }
+ remove(element) {
+ this.removeQueue.add(element);
+ }
+ forEach(fn) {
+ this._addQueued();
+ this._removeQueued();
+ for (const element of this.array) {
+ if (this.removeQueue.has(element)) {
+ continue;
+ }
+ fn(element);
+ }
+ this._removeQueued();
+ }
+ _addQueued() {
+ if (this.addQueue.length) {
+ this.array.splice(this.array.length, 0, ...this.addQueue);
+ this.addQueue = [];
+ }
+ }
+ _removeQueued() {
+ if (this.removeQueue.size) {
+ this.array = this.array.filter(element => !this.removeQueue.has(element));
+ this.removeQueue.clear();
+ }
+ }
+}
+
+上面的类允许你向 SafeArray 添加或移除元素,但在遍历时不会直接修改数组本身。新元素会被添加到 addQueue,要移除的元素添加到 removeQueue,然后在循环之外进行实际的添加或移除。
+使用它,这是我们管理游戏对象的类。
+class GameObjectManager {
+ constructor() {
+ this.gameObjects = new SafeArray();
+ }
+ createGameObject(parent, name) {
+ const gameObject = new GameObject(parent, name);
+ this.gameObjects.add(gameObject);
+ return gameObject;
+ }
+ removeGameObject(gameObject) {
+ this.gameObjects.remove(gameObject);
+ }
+ update() {
+ this.gameObjects.forEach(gameObject => gameObject.update());
+ }
+}
+
+有了这些,现在让我们创建第一个组件。这个组件只负责管理像我们刚才创建的那种蒙皮 three.js 对象。为了简单起见,它只有一个方法 setAnimation,接受要播放的动画名称并播放它。
+class SkinInstance extends Component {
+ constructor(gameObject, model) {
+ super(gameObject);
+ this.model = model;
+ this.animRoot = SkeletonUtils.clone(this.model.gltf.scene);
+ this.mixer = new THREE.AnimationMixer(this.animRoot);
+ gameObject.transform.add(this.animRoot);
+ this.actions = {};
+ }
+ setAnimation(animName) {
+ const clip = this.model.animations[animName];
+ // 关闭所有当前动作
+ for (const action of Object.values(this.actions)) {
+ action.enabled = false;
+ }
+ // 获取或创建该片段的动作
+ const action = this.mixer.clipAction(clip);
+ action.enabled = true;
+ action.reset();
+ action.play();
+ this.actions[animName] = action;
+ }
+ update() {
+ this.mixer.update(globals.deltaTime);
+ }
+}
+
+你可以看到,它基本上就是我们之前的代码,克隆加载的场景,然后设置一个 AnimationMixer。setAnimation 为特定的 AnimationClip 添加一个 AnimationAction(如果还不存在的话),并禁用所有现有的动作。
+代码引用了 globals.deltaTime。让我们创建一个 globals 对象
+const globals = {
+ time: 0,
+ deltaTime: 0,
+};
+
+并在渲染循环中更新它
+let then = 0;
+function render(now) {
+ // 转换为秒
+ globals.time = now * 0.001;
+ // 确保 deltaTime 不会太大
+ globals.deltaTime = Math.min(globals.time - then, 1 / 20);
+ then = globals.time;
+
+上面确保 deltaTime 不超过 1/20 秒的检查是因为,如果我们隐藏标签页,就会得到一个巨大的 deltaTime 值。我们可能隐藏标签页几秒或几分钟,然后当标签页被切回前台时 deltaTime 会非常大,如果我们有这样的代码,可能会把角色传送到游戏世界的另一端
+position += velocity * deltaTime;
+
+通过限制 deltaTime 的最大值可以防止这个问题。
+现在让我们为玩家创建一个组件。
+class Player extends Component {
+ constructor(gameObject) {
+ super(gameObject);
+ const model = models.knight;
+ this.skinInstance = gameObject.addComponent(SkinInstance, model);
+ this.skinInstance.setAnimation('Run');
+ }
+}
+
+玩家用 'Run' 调用 setAnimation。为了知道有哪些可用的动画,我修改了之前的示例来打印动画名称
+function prepModelsAndAnimations() {
+ Object.values(models).forEach(model => {
++ console.log('------->:', model.url);
+ const animsByName = {};
+ model.gltf.animations.forEach((clip) => {
+ animsByName[clip.name] = clip;
++ console.log(' ', clip.name);
+ });
+ model.animations = animsByName;
+ });
+}
+
+运行后在 JavaScript 控制台中得到了这个列表。
+ ------->: resources/models/animals/Pig.gltf
+ Idle
+ Death
+ WalkSlow
+ Jump
+ Walk
+ ------->: resources/models/animals/Cow.gltf
+ Walk
+ Jump
+ WalkSlow
+ Death
+ Idle
+ ------->: resources/models/animals/Llama.gltf
+ Jump
+ Idle
+ Walk
+ Death
+ WalkSlow
+ ------->: resources/models/animals/Pug.gltf
+ Jump
+ Walk
+ Idle
+ WalkSlow
+ Death
+ ------->: resources/models/animals/Sheep.gltf
+ WalkSlow
+ Death
+ Jump
+ Walk
+ Idle
+ ------->: resources/models/animals/Zebra.gltf
+ Jump
+ Walk
+ Death
+ WalkSlow
+ Idle
+ ------->: resources/models/animals/Horse.gltf
+ Jump
+ WalkSlow
+ Death
+ Walk
+ Idle
+ ------->: resources/models/knight/KnightCharacter.gltf
+ Run_swordRight
+ Run
+ Idle_swordLeft
+ Roll_sword
+ Idle
+ Run_swordAttack
+
幸运的是,所有动物的动画名称都是一样的,这在之后会很方便。目前我们只关心玩家有一个叫 Run 的动画。
+让我们使用这些组件。这是更新后的 init 函数。它所做的就是创建一个 GameObject 并添加一个 Player 组件。
+const globals = {
+ time: 0,
+ deltaTime: 0,
+};
++const gameObjectManager = new GameObjectManager();
+
+function init() {
+ // 隐藏加载条
+ const loadingElem = document.querySelector('#loading');
+ loadingElem.style.display = 'none';
+
+ prepModelsAndAnimations();
+
++ {
++ const gameObject = gameObjectManager.createGameObject(scene, 'player');
++ gameObject.addComponent(Player);
++ }
+}
+
+我们需要在渲染循环中调用 gameObjectManager.update
+let then = 0;
+function render(now) {
+ // 转换为秒
+ globals.time = now * 0.001;
+ // 确保 deltaTime 不会太大
+ globals.deltaTime = Math.min(globals.time - then, 1 / 20);
+ then = globals.time;
+
+ if (resizeRendererToDisplaySize(renderer)) {
+ const canvas = renderer.domElement;
+ camera.aspect = canvas.clientWidth / canvas.clientHeight;
+ camera.updateProjectionMatrix();
+ }
+
+- for (const {mixer} of mixerInfos) {
+- mixer.update(deltaTime);
+- }
++ gameObjectManager.update();
+
+ renderer.render(scene, camera);
+
+ requestAnimationFrame(render);
+}
+
+如果我们运行它,会得到一个单独的玩家。
+
+
+ 点击此处在新标签页中打开
+
+
+
+仅仅为了一个实体组件系统就写了这么多代码,但这是大多数游戏需要的基础设施。
+让我们添加一个输入系统。与其直接读取按键,我们将创建一个类,让代码的其他部分可以检查 left 或 right。这样我们可以分配多种方式来输入 left 或 right 等。我们先从按键开始
+// 保持按键/按钮的状态
+//
+// 你可以检查
+//
+// inputManager.keys.left.down
+//
+// 来查看左键是否当前被按住
+// 你也可以检查
+//
+// inputManager.keys.left.justPressed
+//
+// 来查看左键是否在这一帧被按下
+//
+// 按键有 'left', 'right', 'a', 'b', 'up', 'down'
+class InputManager {
+ constructor() {
+ this.keys = {};
+ const keyMap = new Map();
+
+ const setKey = (keyName, pressed) => {
+ const keyState = this.keys[keyName];
+ keyState.justPressed = pressed && !keyState.down;
+ keyState.down = pressed;
+ };
+
+ const addKey = (keyCode, name) => {
+ this.keys[name] = { down: false, justPressed: false };
+ keyMap.set(keyCode, name);
+ };
+
+ const setKeyFromKeyCode = (keyCode, pressed) => {
+ const keyName = keyMap.get(keyCode);
+ if (!keyName) {
+ return;
+ }
+ setKey(keyName, pressed);
+ };
+
+ addKey(37, 'left');
+ addKey(39, 'right');
+ addKey(38, 'up');
+ addKey(40, 'down');
+ addKey(90, 'a');
+ addKey(88, 'b');
+
+ window.addEventListener('keydown', (e) => {
+ setKeyFromKeyCode(e.keyCode, true);
+ });
+ window.addEventListener('keyup', (e) => {
+ setKeyFromKeyCode(e.keyCode, false);
+ });
+ }
+ update() {
+ for (const keyState of Object.values(this.keys)) {
+ if (keyState.justPressed) {
+ keyState.justPressed = false;
+ }
+ }
+ }
+}
+
+上面的代码跟踪按键是按下还是松开,你可以通过检查例如 inputManager.keys.left.down 来判断一个键是否当前被按下。它还为每个键提供了 justPressed 属性,这样你可以检查用户是否刚刚按下了该键。例如跳跃键,你不想知道按钮是否被持续按住,你想知道用户是否现在按下了它。
+让我们创建一个 InputManager 实例
+const globals = {
+ time: 0,
+ deltaTime: 0,
+};
+const gameObjectManager = new GameObjectManager();
++const inputManager = new InputManager();
+
+并在渲染循环中更新它
+function render(now) {
+
+ ...
+
+ gameObjectManager.update();
++ inputManager.update();
+
+ ...
+}
+
+它需要在 gameObjectManager.update 之后调用,否则 justPressed 在组件的 update 函数中永远不会为 true。
+让我们在 Player 组件中使用它
++const kForward = new THREE.Vector3(0, 0, 1);
+const globals = {
+ time: 0,
+ deltaTime: 0,
++ moveSpeed: 16,
+};
+
+class Player extends Component {
+ constructor(gameObject) {
+ super(gameObject);
+ const model = models.knight;
+ this.skinInstance = gameObject.addComponent(SkinInstance, model);
+ this.skinInstance.setAnimation('Run');
++ this.turnSpeed = globals.moveSpeed / 4;
+ }
++ update() {
++ const {deltaTime, moveSpeed} = globals;
++ const {transform} = this.gameObject;
++ const delta = (inputManager.keys.left.down ? 1 : 0) +
++ (inputManager.keys.right.down ? -1 : 0);
++ transform.rotation.y += this.turnSpeed * delta * deltaTime;
++ transform.translateOnAxis(kForward, moveSpeed * deltaTime);
++ }
+}
+
+上面的代码使用 Object3D.transformOnAxis 来向前移动玩家。Object3D.transformOnAxis 在本地空间中工作,所以只有当对象在场景的根级别时才有效,如果它是其他东西的子对象则不行 1
+我们还添加了一个全局 moveSpeed,并基于移动速度计算 turnSpeed。转向速度基于移动速度,以确保角色能够足够快地转向以到达目标。如果 turnSpeed 太小,角色会围绕目标转圈但永远无法到达。我没有费心去计算给定移动速度所需的转向速度,只是猜的。
+到目前为止的代码可以工作,但如果玩家跑出屏幕就无法知道他们在哪里了。让我们实现如果他们离开屏幕超过一定时间就传送回原点。我们可以使用 three.js 的 Frustum 类来检查一个点是否在摄像机的视锥体内。
+我们需要从摄像机构建一个视锥体。我们可以在 Player 组件中做这件事,但其他对象可能也想使用它,所以让我们添加另一个带有管理视锥体组件的游戏对象。
+class CameraInfo extends Component {
+ constructor(gameObject) {
+ super(gameObject);
+ this.projScreenMatrix = new THREE.Matrix4();
+ this.frustum = new THREE.Frustum();
+ }
+ update() {
+ const {camera} = globals;
+ this.projScreenMatrix.multiplyMatrices(
+ camera.projectionMatrix,
+ camera.matrixWorldInverse);
+ this.frustum.setFromProjectionMatrix(this.projScreenMatrix);
+ }
+}
+
+然后让我们在初始化时设置另一个游戏对象。
+function init() {
+ // 隐藏加载条
+ const loadingElem = document.querySelector('#loading');
+ loadingElem.style.display = 'none';
+
+ prepModelsAndAnimations();
+
++ {
++ const gameObject = gameObjectManager.createGameObject(camera, 'camera');
++ globals.cameraInfo = gameObject.addComponent(CameraInfo);
++ }
+
+ {
+ const gameObject = gameObjectManager.createGameObject(scene, 'player');
+ gameObject.addComponent(Player);
+ }
+}
+
+现在我们可以在 Player 组件中使用它了。
+class Player extends Component {
+ constructor(gameObject) {
+ super(gameObject);
+ const model = models.knight;
+ this.skinInstance = gameObject.addComponent(SkinInstance, model);
+ this.skinInstance.setAnimation('Run');
+ this.turnSpeed = globals.moveSpeed / 4;
++ this.offscreenTimer = 0;
++ this.maxTimeOffScreen = 3;
+ }
+ update() {
+- const {deltaTime, moveSpeed} = globals;
++ const {deltaTime, moveSpeed, cameraInfo} = globals;
+ const {transform} = this.gameObject;
+ const delta = (inputManager.keys.left.down ? 1 : 0) +
+ (inputManager.keys.right.down ? -1 : 0);
+ transform.rotation.y += this.turnSpeed * delta * deltaTime;
+ transform.translateOnAxis(kForward, moveSpeed * deltaTime);
+
++ const {frustum} = cameraInfo;
++ if (frustum.containsPoint(transform.position)) {
++ this.offscreenTimer = 0;
++ } else {
++ this.offscreenTimer += deltaTime;
++ if (this.offscreenTimer >= this.maxTimeOffScreen) {
++ transform.position.set(0, 0, 0);
++ }
++ }
+ }
+}
+
+在试运行之前还有一件事,让我们为移动端添加触摸屏支持。首先添加一些 HTML 用于触摸
+<body>
+ <canvas id="c"></canvas>
++ <div id="ui">
++ <div id="left"><img src="../resources/images/left.svg"></div>
++ <div style="flex: 0 0 40px;"></div>
++ <div id="right"><img src="../resources/images/right.svg"></div>
++ </div>
+ <div id="loading">
+ <div>
+ <div>...loading...</div>
+ <div class="progress"><div id="progressbar"></div></div>
+ </div>
+ </div>
+</body>
+
+以及一些 CSS 来样式化它
+#ui {
+ position: absolute;
+ left: 0;
+ top: 0;
+ width: 100%;
+ height: 100%;
+ display: flex;
+ justify-items: center;
+ align-content: stretch;
+}
+#ui>div {
+ display: flex;
+ align-items: flex-end;
+ flex: 1 1 auto;
+}
+.bright {
+ filter: brightness(2);
+}
+#left {
+ justify-content: flex-end;
+}
+#right {
+ justify-content: flex-start;
+}
+#ui img {
+ padding: 10px;
+ width: 80px;
+ height: 80px;
+ display: block;
+}
+
+这里的想法是有一个 #ui div 覆盖整个页面。里面有两个 div,#left 和 #right,它们都几乎是页面宽度的一半,高度为整个屏幕。中间有一个 40px 的分隔。如果用户在左侧或右侧滑动手指,我们需要更新 InputManager 中的 keys.left 和 keys.right。这使整个屏幕都对触摸敏感,这比仅使用小箭头要好。
+class InputManager {
+ constructor() {
+ this.keys = {};
+ const keyMap = new Map();
+
+ const setKey = (keyName, pressed) => {
+ const keyState = this.keys[keyName];
+ keyState.justPressed = pressed && !keyState.down;
+ keyState.down = pressed;
+ };
+
+ const addKey = (keyCode, name) => {
+ this.keys[name] = { down: false, justPressed: false };
+ keyMap.set(keyCode, name);
+ };
+
+ const setKeyFromKeyCode = (keyCode, pressed) => {
+ const keyName = keyMap.get(keyCode);
+ if (!keyName) {
+ return;
+ }
+ setKey(keyName, pressed);
+ };
+
+ addKey(37, 'left');
+ addKey(39, 'right');
+ addKey(38, 'up');
+ addKey(40, 'down');
+ addKey(90, 'a');
+ addKey(88, 'b');
+
+ window.addEventListener('keydown', (e) => {
+ setKeyFromKeyCode(e.keyCode, true);
+ });
+ window.addEventListener('keyup', (e) => {
+ setKeyFromKeyCode(e.keyCode, false);
+ });
+
++ const sides = [
++ { elem: document.querySelector('#left'), key: 'left' },
++ { elem: document.querySelector('#right'), key: 'right' },
++ ];
++
++ const clearKeys = () => {
++ for (const {key} of sides) {
++ setKey(key, false);
++ }
++ };
++
++ const handleMouseMove = (e) => {
++ e.preventDefault();
++ // 这是必要的,因为我们调用了 preventDefault();
++ // 我们还给 canvas 添加了 tabindex 以便它可以
++ // 获得焦点
++ canvas.focus();
++ window.addEventListener('pointermove', handleMouseMove);
++ window.addEventListener('pointerup', handleMouseUp);
++
++ for (const {elem, key} of sides) {
++ let pressed = false;
++ const rect = elem.getBoundingClientRect();
++ const x = e.clientX;
++ const y = e.clientY;
++ const inRect = x >= rect.left && x < rect.right &&
++ y >= rect.top && y < rect.bottom;
++ if (inRect) {
++ pressed = true;
++ }
++ setKey(key, pressed);
++ }
++ };
++
++ function handleMouseUp() {
++ clearKeys();
++ window.removeEventListener('pointermove', handleMouseMove, {passive: false});
++ window.removeEventListener('pointerup', handleMouseUp);
++ }
++
++ const uiElem = document.querySelector('#ui');
++ uiElem.addEventListener('pointerdown', handleMouseMove, {passive: false});
++
++ uiElem.addEventListener('touchstart', (e) => {
++ // 阻止滚动
++ e.preventDefault();
++ }, {passive: false});
+ }
+ update() {
+ for (const keyState of Object.values(this.keys)) {
+ if (keyState.justPressed) {
+ keyState.justPressed = false;
+ }
+ }
+ }
+}
+
+现在我们应该能用左右方向键或在触摸屏上用手指来控制角色了
+
+
+ 点击此处在新标签页中打开
+
+
+
+理想情况下,如果玩家离开屏幕我们会做其他事情,比如移动摄像机或者离开屏幕就死亡,但这篇文章已经够长了,所以目前传送回中心是最简单的方案。
+让我们添加一些动物。我们可以像 Player 类似地开始,创建一个 Animal 组件。
+class Animal extends Component {
+ constructor(gameObject, model) {
+ super(gameObject);
+ const skinInstance = gameObject.addComponent(SkinInstance, model);
+ skinInstance.mixer.timeScale = globals.moveSpeed / 4;
+ skinInstance.setAnimation('Idle');
+ }
+}
+
+上面的代码设置 AnimationMixer.timeScale 来设置动画相对于移动速度的播放速度。这样如果我们调整移动速度,动画也会相应加速或减速。
+首先我们可以设置每种动物各一个
+function init() {
+ // 隐藏加载条
+ const loadingElem = document.querySelector('#loading');
+ loadingElem.style.display = 'none';
+
+ prepModelsAndAnimations();
+ {
+ const gameObject = gameObjectManager.createGameObject(camera, 'camera');
+ globals.cameraInfo = gameObject.addComponent(CameraInfo);
+ }
+
+ {
+ const gameObject = gameObjectManager.createGameObject(scene, 'player');
+ globals.player = gameObject.addComponent(Player);
+ globals.congaLine = [gameObject];
+ }
+
++ const animalModelNames = [
++ 'pig',
++ 'cow',
++ 'llama',
++ 'pug',
++ 'sheep',
++ 'zebra',
++ 'horse',
++ ];
++ animalModelNames.forEach((name, ndx) => {
++ const gameObject = gameObjectManager.createGameObject(scene, name);
++ gameObject.addComponent(Animal, models[name]);
++ gameObject.transform.position.x = (ndx + 1) * 5;
++ });
+}
+
+这样我们会得到站在屏幕上的动物,但我们希望它们做些什么。
+让我们让它们在玩家靠近时跟随玩家排成康加舞队列。为此我们需要几种状态。
+
+空闲(Idle):
+动物等待玩家靠近
+
+等待队尾(Wait for End of Line):
+动物被玩家标记了,但现在需要等待队列末尾的动物过来,这样它才能加入队尾。
+
+走向队尾(Go to Last):
+动物需要走到它跟随的动物之前所在的位置,同时记录它跟随的动物当前的位置历史。
+
+跟随(Follow)
+动物需要持续记录它跟随的动物的位置历史,同时移动到它跟随的动物之前所在的位置。
+
+
+处理这样的不同状态有很多方式。一种常见的方式是使用有限状态机(Finite State Machine),并构建一些类来帮助我们管理状态。
+那么,让我们来实现它。
+class FiniteStateMachine {
+ constructor(states, initialState) {
+ this.states = states;
+ this.transition(initialState);
+ }
+ get state() {
+ return this.currentState;
+ }
+ transition(state) {
+ const oldState = this.states[this.currentState];
+ if (oldState && oldState.exit) {
+ oldState.exit.call(this);
+ }
+ this.currentState = state;
+ const newState = this.states[state];
+ if (newState.enter) {
+ newState.enter.call(this);
+ }
+ }
+ update() {
+ const state = this.states[this.currentState];
+ if (state.update) {
+ state.update.call(this);
+ }
+ }
+}
+
+这是一个简单的类。我们传给它一个包含一堆状态的对象。每个状态有 3 个可选函数:enter、update 和 exit。要切换状态,我们调用 FiniteStateMachine.transition 并传入新状态的名称。如果当前状态有 exit 函数就会被调用。然后如果新状态有 enter 函数也会被调用。最后每一帧 FiniteStateMachine.update 会调用当前状态的 update 函数。
+让我们用它来管理动物的状态。
+// 如果 obj1 和 obj2 足够近则返回 true
+function isClose(obj1, obj1Radius, obj2, obj2Radius) {
+ const minDist = obj1Radius + obj2Radius;
+ const dist = obj1.position.distanceTo(obj2.position);
+ return dist < minDist;
+}
+
+// 将 v 限制在 -min 和 +min 之间
+function minMagnitude(v, min) {
+ return Math.abs(v) > min
+ ? min * Math.sign(v)
+ : v;
+}
+
+const aimTowardAndGetDistance = function() {
+ const delta = new THREE.Vector3();
+
+ return function aimTowardAndGetDistance(source, targetPos, maxTurn) {
+ delta.subVectors(targetPos, source.position);
+ // 计算我们想要面朝的方向
+ const targetRot = Math.atan2(delta.x, delta.z) + Math.PI * 1.5;
+ // 沿最短方向旋转
+ const deltaRot = (targetRot - source.rotation.y + Math.PI * 1.5) % (Math.PI * 2) - Math.PI;
+ // 确保转向速度不超过 maxTurn
+ const deltaRotation = minMagnitude(deltaRot, maxTurn);
+ // 将旋转保持在 0 到 Math.PI * 2 之间
+ source.rotation.y = THREE.MathUtils.euclideanModulo(
+ source.rotation.y + deltaRotation, Math.PI * 2);
+ // 返回到目标的距离
+ return delta.length();
+ };
+}();
+
+class Animal extends Component {
+ constructor(gameObject, model) {
+ super(gameObject);
++ const hitRadius = model.size / 2;
+ const skinInstance = gameObject.addComponent(SkinInstance, model);
+ skinInstance.mixer.timeScale = globals.moveSpeed / 4;
++ const transform = gameObject.transform;
++ const playerTransform = globals.player.gameObject.transform;
++ const maxTurnSpeed = Math.PI * (globals.moveSpeed / 4);
++ const targetHistory = [];
++ let targetNdx = 0;
++
++ function addHistory() {
++ const targetGO = globals.congaLine[targetNdx];
++ const newTargetPos = new THREE.Vector3();
++ newTargetPos.copy(targetGO.transform.position);
++ targetHistory.push(newTargetPos);
++ }
++
++ this.fsm = new FiniteStateMachine({
++ idle: {
++ enter: () => {
++ skinInstance.setAnimation('Idle');
++ },
++ update: () => {
++ // 检查玩家是否靠近
++ if (isClose(transform, hitRadius, playerTransform, globals.playerRadius)) {
++ this.fsm.transition('waitForEnd');
++ }
++ },
++ },
++ waitForEnd: {
++ enter: () => {
++ skinInstance.setAnimation('Jump');
++ },
++ update: () => {
++ // 获取康加舞队列末尾的游戏对象
++ const lastGO = globals.congaLine[globals.congaLine.length - 1];
++ const deltaTurnSpeed = maxTurnSpeed * globals.deltaTime;
++ const targetPos = lastGO.transform.position;
++ aimTowardAndGetDistance(transform, targetPos, deltaTurnSpeed);
++ // 检查康加舞队列的最后一个是否靠近
++ if (isClose(transform, hitRadius, lastGO.transform, globals.playerRadius)) {
++ this.fsm.transition('goToLast');
++ }
++ },
++ },
++ goToLast: {
++ enter: () => {
++ // 记住我们跟随的是谁
++ targetNdx = globals.congaLine.length - 1;
++ // 将自己加入康加舞队列
++ globals.congaLine.push(gameObject);
++ skinInstance.setAnimation('Walk');
++ },
++ update: () => {
++ addHistory();
++ // 走向历史记录中最旧的点
++ const targetPos = targetHistory[0];
++ const maxVelocity = globals.moveSpeed * globals.deltaTime;
++ const deltaTurnSpeed = maxTurnSpeed * globals.deltaTime;
++ const distance = aimTowardAndGetDistance(transform, targetPos, deltaTurnSpeed);
++ const velocity = distance;
++ transform.translateOnAxis(kForward, Math.min(velocity, maxVelocity));
++ if (distance <= maxVelocity) {
++ this.fsm.transition('follow');
++ }
++ },
++ },
++ follow: {
++ update: () => {
++ addHistory();
++ // 移除最旧的历史记录并将自己放到那个位置
++ const targetPos = targetHistory.shift();
++ transform.position.copy(targetPos);
++ const deltaTurnSpeed = maxTurnSpeed * globals.deltaTime;
++ aimTowardAndGetDistance(transform, targetHistory[0], deltaTurnSpeed);
++ },
++ },
++ }, 'idle');
++ }
++ update() {
++ this.fsm.update();
++ }
+}
+
+这是一大段代码,但它实现了上面描述的功能。希望你逐步浏览每个状态时会觉得很清晰。
+我们还需要添加一些东西。我们需要让玩家将自己添加到 globals 中,以便动物可以找到它,并且我们需要用玩家的 GameObject 来开始康加舞队列。
+function init() {
+
+ ...
+
+ {
+ const gameObject = gameObjectManager.createGameObject(scene, 'player');
++ globals.player = gameObject.addComponent(Player);
++ globals.congaLine = [gameObject];
+ }
+
+}
+
+我们还需要计算每个模型的大小
+function prepModelsAndAnimations() {
++ const box = new THREE.Box3();
++ const size = new THREE.Vector3();
+ Object.values(models).forEach(model => {
++ box.setFromObject(model.gltf.scene);
++ box.getSize(size);
++ model.size = size.length();
+ const animsByName = {};
+ model.gltf.animations.forEach((clip) => {
+ animsByName[clip.name] = clip;
+ // 这个应该在 .blend 文件中修复
+ if (clip.name === 'Walk') {
+ clip.duration /= 2;
+ }
+ });
+ model.animations = animsByName;
+ });
+}
+
+我们还需要让玩家记录自己的大小
+class Player extends Component {
+ constructor(gameObject) {
+ super(gameObject);
+ const model = models.knight;
++ globals.playerRadius = model.size / 2;
+
+现在想想,让动物瞄准康加舞队列的头部而不是特定的玩家可能会更聪明。也许我以后会回来改。
+刚开始时我对所有动物只用一个半径,但这当然不好,因为哈巴狗比马小得多。所以我添加了不同的大小,但我想要能够可视化这些东西。为此我创建了一个 StateDisplayHelper 组件。
+它使用 PolarGridHelper 在每个角色周围画一个圆圈,并使用 HTML 元素让每个角色显示一些状态,使用的是将 HTML 元素对齐到 3D 的文章中介绍的技术。
+首先我们需要添加一些 HTML 来承载这些元素
+<body>
+ <canvas id="c"></canvas>
+ <div id="ui">
+ <div id="left"><img src="../resources/images/left.svg"></div>
+ <div style="flex: 0 0 40px;"></div>
+ <div id="right"><img src="../resources/images/right.svg"></div>
+ </div>
+ <div id="loading">
+ <div>
+ <div>...loading...</div>
+ <div class="progress"><div id="progressbar"></div></div>
+ </div>
+ </div>
++ <div id="labels"></div>
+</body>
+
+并添加一些 CSS
+#labels {
+ position: absolute; /* 让我们可以在容器内定位自己 */
+ left: 0; /* 将位置设为容器的左上角 */
+ top: 0;
+ color: white;
+ width: 100%;
+ height: 100%;
+ overflow: hidden;
+ pointer-events: none;
+}
+#labels>div {
+ position: absolute; /* 让我们可以在容器内定位它们 */
+ left: 0; /* 将它们的默认位置设为容器的左上角 */
+ top: 0;
+ font-size: large;
+ font-family: monospace;
+ user-select: none; /* 禁止文本被选中 */
+ text-shadow: /* 创建黑色描边 */
+ -1px -1px 0 #000,
+ 0 -1px 0 #000,
+ 1px -1px 0 #000,
+ 1px 0 0 #000,
+ 1px 1px 0 #000,
+ 0 1px 0 #000,
+ -1px 1px 0 #000,
+ -1px 0 0 #000;
+}
+
+然后这是组件
+const labelContainerElem = document.querySelector('#labels');
+
+class StateDisplayHelper extends Component {
+ constructor(gameObject, size) {
+ super(gameObject);
+ this.elem = document.createElement('div');
+ labelContainerElem.appendChild(this.elem);
+ this.pos = new THREE.Vector3();
+
+ this.helper = new THREE.PolarGridHelper(size / 2, 1, 1, 16);
+ gameObject.transform.add(this.helper);
+ }
+ setState(s) {
+ this.elem.textContent = s;
+ }
+ setColor(cssColor) {
+ this.elem.style.color = cssColor;
+ this.helper.material.color.set(cssColor);
+ }
+ update() {
+ const {pos} = this;
+ const {transform} = this.gameObject;
+ const {canvas} = globals;
+ pos.copy(transform.position);
+
+ // 获取该位置的归一化屏幕坐标
+ // x 和 y 的范围在 -1 到 +1 之间,x = -1 在左边
+ // y = -1 在底部
+ pos.project(globals.camera);
+
+ // 将归一化位置转换为 CSS 坐标
+ const x = (pos.x * .5 + .5) * canvas.clientWidth;
+ const y = (pos.y * -.5 + .5) * canvas.clientHeight;
+
+ // 将元素移动到该位置
+ this.elem.style.transform = `translate(-50%, -50%) translate(${x}px,${y}px)`;
+ }
+}
+
+然后我们可以这样将它们添加到动物上
+class Animal extends Component {
+ constructor(gameObject, model) {
+ super(gameObject);
++ this.helper = gameObject.addComponent(StateDisplayHelper, model.size);
+
+ ...
+
+ }
+ update() {
+ this.fsm.update();
++ const dir = THREE.MathUtils.radToDeg(this.gameObject.transform.rotation.y);
++ this.helper.setState(`${this.fsm.state}:${dir.toFixed(0)}`);
+ }
+}
+
+趁此机会让我们也实现用 lil-gui 来开关它们,就像我们在其他地方使用的那样
+import * as THREE from 'three';
+import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
+import {GLTFLoader} from 'three/addons/loaders/GLTFLoader.js';
+import * as SkeletonUtils from 'three/addons/utils/SkeletonUtils.js';
++import {GUI} from 'three/addons/libs/lil-gui.module.min.js';
+
++const gui = new GUI();
++gui.add(globals, 'debug').onChange(showHideDebugInfo);
++showHideDebugInfo();
+
+const labelContainerElem = document.querySelector('#labels');
++function showHideDebugInfo() {
++ labelContainerElem.style.display = globals.debug ? '' : 'none';
++}
++showHideDebugInfo();
+
+class StateDisplayHelper extends Component {
+
+ ...
+
+ update() {
++ this.helper.visible = globals.debug;
++ if (!globals.debug) {
++ return;
++ }
+
+ ...
+ }
+}
+
+这样我们就有了一个游戏的雏形
+
+
+ 点击此处在新标签页中打开
+
+
+
+最初我打算做一个贪吃蛇游戏,随着你将动物添加到队列中,游戏会变得更难,因为你需要避免撞到它们。我还会在场景中放置一些障碍物,也许还有围栏或围绕周边的某种屏障。
+不幸的是,这些动物又长又细。从上面看,这是斑马。
+
+
+目前的代码使用圆形碰撞,这意味着如果我们有像围栏这样的障碍物,那么这将被视为碰撞
+
+
+这不行。即使是动物与动物之间也会有同样的问题。
+我考虑过写一个 2D 矩形对矩形的碰撞系统,但很快意识到这可能需要很多代码。检查两个任意方向的矩形是否重叠本身代码量不大,对于只有少量对象的游戏可能够用,但当对象多了之后你很快就需要优化碰撞检测。首先你可能需要遍历所有可能相互碰撞的对象,检查它们的包围球、包围圆或轴对齐包围盒。一旦你知道哪些对象可能碰撞,你还需要做更多工作来检查它们是否实际碰撞了。通常即使检查包围球也太费劲,你需要某种更好的空间结构来更快地只检查可能彼此靠近的对象。
+然后,一旦你写了检查两个对象是否碰撞的代码,你通常想要做一个碰撞系统,而不是手动询问"我是否与这些对象碰撞"。碰撞系统会发出事件或调用与碰撞相关的回调。优势在于它可以一次检查所有碰撞,这样没有对象会被检查多次,而如果你手动调用某个"我是否碰撞"的函数,对象往往会被多次检查,浪费时间。
+制作这样的碰撞系统可能只需要 100-300 行代码来检查任意方向的矩形,但这仍然是很多额外的代码,所以最好先不做。
+另一个解决方案是尝试找一些从顶部看大致是圆形的其他角色。例如其他人形角色而不是动物,这样圆形检测可能适用于动物之间的碰撞。但对于动物与围栏之间则不行,我们必须添加圆形对矩形的检测。我考虑过把围栏做成灌木丛或柱子,圆形的东西,但那样我可能需要 120 到 200 个来围绕游戏区域,这就会遇到上面提到的优化问题。
+这就是为什么很多游戏使用现有的解决方案。这些解决方案通常是物理库的一部分。物理库需要知道对象是否相互碰撞,所以在提供物理效果的基础上还可以用来检测碰撞。
+如果你在寻找解决方案,一些 three.js 示例使用了 ammo.js,这可能是一个选择。
+另一个解决方案可能是将障碍物放在网格上,让每个动物和玩家只需要查看网格。虽然这样性能会很好,但我觉得这最好留作读者的练习 😜
+还有一件事,很多游戏系统有一种叫做协程(coroutines)的东西。协程是可以在运行时暂停并在之后继续的例程。
+让我们让主角发出音符,就像它在通过唱歌带领队伍一样。我们有很多方式可以实现这个,但现在让我们用协程来做。
+首先,这是一个管理协程的类
+function* waitSeconds(duration) {
+ while (duration > 0) {
+ duration -= globals.deltaTime;
+ yield;
+ }
+}
+
+class CoroutineRunner {
+ constructor() {
+ this.generatorStacks = [];
+ this.addQueue = [];
+ this.removeQueue = new Set();
+ }
+ isBusy() {
+ return this.addQueue.length + this.generatorStacks.length > 0;
+ }
+ add(generator, delay = 0) {
+ const genStack = [generator];
+ if (delay) {
+ genStack.push(waitSeconds(delay));
+ }
+ this.addQueue.push(genStack);
+ }
+ remove(generator) {
+ this.removeQueue.add(generator);
+ }
+ update() {
+ this._addQueued();
+ this._removeQueued();
+ for (const genStack of this.generatorStacks) {
+ const main = genStack[0];
+ // 处理一个协程移除另一个协程的情况
+ if (this.removeQueue.has(main)) {
+ continue;
+ }
+ while (genStack.length) {
+ const topGen = genStack[genStack.length - 1];
+ const {value, done} = topGen.next();
+ if (done) {
+ if (genStack.length === 1) {
+ this.removeQueue.add(topGen);
+ break;
+ }
+ genStack.pop();
+ } else if (value) {
+ genStack.push(value);
+ } else {
+ break;
+ }
+ }
+ }
+ this._removeQueued();
+ }
+ _addQueued() {
+ if (this.addQueue.length) {
+ this.generatorStacks.splice(this.generatorStacks.length, 0, ...this.addQueue);
+ this.addQueue = [];
+ }
+ }
+ _removeQueued() {
+ if (this.removeQueue.size) {
+ this.generatorStacks = this.generatorStacks.filter(genStack => !this.removeQueue.has(genStack[0]));
+ this.removeQueue.clear();
+ }
+ }
+}
+
+它和 SafeArray 做了类似的事情,确保在其他协程运行时添加或移除协程是安全的。它还处理嵌套协程。
+要创建协程,你需要创建一个 JavaScript 生成器函数。生成器函数前面有关键字 function*(星号很重要!)
+生成器函数可以 yield。例如
+function* count0To9() {
+ for (let i = 0; i < 10; ++i) {
+ console.log(i);
+ yield;
+ }
+}
+
+如果我们将这个函数添加到上面的 CoroutineRunner 中,它会每帧打印一个数字(0 到 9),或者更准确地说是每次调用 runner.update 时打印一个。
+const runner = new CoroutineRunner();
+runner.add(count0To9);
+while(runner.isBusy()) {
+ runner.update();
+}
+
+协程在完成时会自动被移除。要提前移除一个协程,在它结束之前你需要保持对其生成器的引用,像这样
+const gen = count0To9();
+runner.add(gen);
+
+// 稍后某个时候
+
+runner.remove(gen);
+
+无论如何,在玩家中让我们使用协程每隔 0.5 到 1 秒发出一个音符
+class Player extends Component {
+ constructor(gameObject) {
+
+ ...
+
++ this.runner = new CoroutineRunner();
++
++ function* emitNotes() {
++ for (;;) {
++ yield waitSeconds(rand(0.5, 1));
++ const noteGO = gameObjectManager.createGameObject(scene, 'note');
++ noteGO.transform.position.copy(gameObject.transform.position);
++ noteGO.transform.position.y += 5;
++ noteGO.addComponent(Note);
++ }
++ }
++
++ this.runner.add(emitNotes());
+ }
+ update() {
++ this.runner.update();
+
+ ...
+
+ }
+}
+
+function rand(min, max) {
+ if (max === undefined) {
+ max = min;
+ min = 0;
+ }
+ return Math.random() * (max - min) + min;
+}
+
+你可以看到我们创建了一个 CoroutineRunner 并添加了一个 emitNotes 协程。这个函数会永远运行,等待 0.5 到 1 秒然后创建一个带有 Note 组件的游戏对象。
+对于 Note 组件,首先让我们制作一个带有音符的纹理,我们不加载音符图片,而是像画布纹理文章中介绍的那样使用画布来制作。
+function makeTextTexture(str) {
+ const ctx = document.createElement('canvas').getContext('2d');
+ ctx.canvas.width = 64;
+ ctx.canvas.height = 64;
+ ctx.font = '60px sans-serif';
+ ctx.textAlign = 'center';
+ ctx.textBaseline = 'middle';
+ ctx.fillStyle = '#FFF';
+ ctx.fillText(str, ctx.canvas.width / 2, ctx.canvas.height / 2);
+ return new THREE.CanvasTexture(ctx.canvas);
+}
+const noteTexture = makeTextTexture('♪');
+
+我们创建的纹理是白色的,这意味着使用时我们可以设置材质的颜色来获得任意颜色的音符。
+现在我们有了 noteTexture,这是 Note 组件。它使用了 SpriteMaterial 和 Sprite,就像我们在广告牌文章中介绍的那样
+class Note extends Component {
+ constructor(gameObject) {
+ super(gameObject);
+ const {transform} = gameObject;
+ const noteMaterial = new THREE.SpriteMaterial({
+ color: new THREE.Color().setHSL(rand(1), 1, 0.5),
+ map: noteTexture,
+ side: THREE.DoubleSide,
+ transparent: true,
+ });
+ const note = new THREE.Sprite(noteMaterial);
+ note.scale.setScalar(3);
+ transform.add(note);
+ this.runner = new CoroutineRunner();
+ const direction = new THREE.Vector3(rand(-0.2, 0.2), 1, rand(-0.2, 0.2));
+
+ function* moveAndRemove() {
+ for (let i = 0; i < 60; ++i) {
+ transform.translateOnAxis(direction, globals.deltaTime * 10);
+ noteMaterial.opacity = 1 - (i / 60);
+ yield;
+ }
+ transform.parent.remove(transform);
+ gameObjectManager.removeGameObject(gameObject);
+ }
+
+ this.runner.add(moveAndRemove());
+ }
+ update() {
+ this.runner.update();
+ }
+}
+
+它所做的就是设置一个 Sprite,然后选择一个随机速度,以该速度移动变换 60 帧,同时通过设置材质的 opacity 使音符淡出。循环结束后,它将变换从场景中移除,并将音符本身从活动游戏对象中移除。
+最后一件事,让我们添加更多动物
+function init() {
+
+ ...
+
+ const animalModelNames = [
+ 'pig',
+ 'cow',
+ 'llama',
+ 'pug',
+ 'sheep',
+ 'zebra',
+ 'horse',
+ ];
++ const base = new THREE.Object3D();
++ const offset = new THREE.Object3D();
++ base.add(offset);
++
++ // 将动物排列成螺旋形
++ const numAnimals = 28;
++ const arc = 10;
++ const b = 10 / (2 * Math.PI);
++ let r = 10;
++ let phi = r / b;
++ for (let i = 0; i < numAnimals; ++i) {
++ const name = animalModelNames[rand(animalModelNames.length) | 0];
+ const gameObject = gameObjectManager.createGameObject(scene, name);
+ gameObject.addComponent(Animal, models[name]);
++ base.rotation.y = phi;
++ offset.position.x = r;
++ offset.updateWorldMatrix(true, false);
++ offset.getWorldPosition(gameObject.transform.position);
++ phi += arc / r;
++ r = b * phi;
+ }
+
+
+
+ 点击此处在新标签页中打开
+
+
+
+你可能会问,为什么不用 setTimeout?setTimeout 的问题是它与游戏时钟无关。例如上面我们将帧之间允许的最大时间设为 1/20 秒。我们的协程系统会遵守这个限制,但 setTimeout 不会。
+当然我们可以自己做一个简单的计时器
+class Player ... {
+ update() {
+ this.noteTimer -= globals.deltaTime;
+ if (this.noteTimer <= 0) {
+ // 重置计时器
+ this.noteTimer = rand(0.5, 1);
+ // 创建一个带有音符组件的游戏对象
+ }
+ }
+
+对于这个特定情况这可能更好,但随着你添加越来越多的东西,你的类中会添加越来越多的变量,而使用协程你通常可以触发后就不用管了。
+鉴于我们动物的简单状态,我们也可以用以下形式的协程来实现它们
+// 伪代码!
+function* animalCoroutine() {
+ setAnimation('Idle');
+ while(playerIsTooFar()) {
+ yield;
+ }
+ const target = endOfLine;
+ setAnimation('Jump');
+ while(targetIsTooFar()) {
+ aimAt(target);
+ yield;
+ }
+ setAnimation('Walk')
+ while(notAtOldestPositionOfTarget()) {
+ addHistory();
+ aimAt(target);
+ yield;
+ }
+ for(;;) {
+ addHistory();
+ const pos = history.unshift();
+ transform.position.copy(pos);
+ aimAt(history[0]);
+ yield;
+ }
+}
+
+这样做是可行的,但当然一旦我们的状态不再是线性的,我们就不得不切换到 FiniteStateMachine。
+我也不确定协程是否应该独立于它们的组件运行。我们可以创建一个全局的 CoroutineRunner 并将所有协程放在上面。但这会使清理变得更难。目前如果游戏对象被移除,它的所有组件都会被移除,因此创建的协程运行器不再被调用,一切都会被垃圾回收。如果我们有一个全局运行器,那么每个组件都有责任移除它添加的任何协程,否则需要某种其他机制将协程注册到特定组件或游戏对象,以便移除一个时也移除其他的。
+一个正常的游戏引擎会处理更多问题。目前游戏对象或其组件的运行没有顺序。它们只是按添加顺序运行。许多游戏系统会添加优先级,以便可以设置或更改顺序。
+我们遇到的另一个问题是 Note 从场景中移除其游戏对象的变换。这似乎应该在 GameObject 中发生,因为最初是 GameObject 添加的变换。也许 GameObject 应该有一个 dispose 方法,由 GameObjectManager.removeGameObject 调用?
+还有一个问题是我们手动调用 gameObjectManager.update 和 inputManager.update。也许应该有一个 SystemManager,这些全局服务可以将自己添加进去,每个服务的 update 函数都会被调用。这样如果我们添加了像 CollisionManager 这样的新服务,我们只需要将它添加到系统管理器中,而不必编辑渲染循环。
+我会把这些问题留给你。希望这篇文章给了你一些关于制作自己游戏引擎的思路。
+也许我应该搞一个 Game Jam。如果你点击最后一个示例上方的 jsfiddle 或 codepen 按钮,它们会在这些网站上打开,准备好编辑。添加一些功能,把游戏改成一只哈巴狗带领一群骑士。用骑士的翻滚动画做保龄球,制作一个动物保龄球游戏。制作一个动物接力赛。如果你做出了很酷的游戏,请在下面的评论中发布链接。
+
From 2655fa0e905fcf37369bb883347ce3c0e35929f6 Mon Sep 17 00:00:00 2001
From: Dan Rossi
Date: Thu, 5 Mar 2026 20:44:54 +1100
Subject: [PATCH 3/4] WebXRController: Add grip update event if enabled.
(#33118)
Co-authored-by: Michael Herzog
---
src/renderers/webxr/WebXRController.js | 12 ++++++++++++
1 file changed, 12 insertions(+)
diff --git a/src/renderers/webxr/WebXRController.js b/src/renderers/webxr/WebXRController.js
index 3c90d9b79a4d6c..936fd93aa4b51c 100644
--- a/src/renderers/webxr/WebXRController.js
+++ b/src/renderers/webxr/WebXRController.js
@@ -109,6 +109,7 @@ class WebXRController {
this._grip.linearVelocity = new Vector3();
this._grip.hasAngularVelocity = false;
this._grip.angularVelocity = new Vector3();
+ this._grip.eventsEnabled = false;
}
@@ -321,6 +322,17 @@ class WebXRController {
}
+ // grip update event if enabled
+ if ( grip.eventsEnabled ) {
+
+ grip.dispatchEvent( {
+ type: 'gripUpdated',
+ data: inputSource,
+ target: this
+ } );
+
+ }
+
}
}
From 192c265780ea9093724d9be79434a29bfa48efe4 Mon Sep 17 00:00:00 2001
From: Boris Kourtoukov
Date: Thu, 5 Mar 2026 11:25:47 +0100
Subject: [PATCH 4/4] GLTFExporter: Add `EXT_texture_webp` support. (#33117)
---
examples/jsm/exporters/GLTFExporter.js | 23 ++++++++++---
examples/misc_exporter_gltf.html | 34 +++++++++++++++++++-
examples/screenshots/misc_exporter_gltf.jpg | Bin 93551 -> 52007 bytes
3 files changed, 52 insertions(+), 5 deletions(-)
diff --git a/examples/jsm/exporters/GLTFExporter.js b/examples/jsm/exporters/GLTFExporter.js
index a4c36a1005c5e6..a348e7f4b76a19 100644
--- a/examples/jsm/exporters/GLTFExporter.js
+++ b/examples/jsm/exporters/GLTFExporter.js
@@ -1533,15 +1533,30 @@ class GLTFWriter {
}
- let mimeType = map.userData.mimeType;
+ const mimeType = map.userData.mimeType;
- if ( mimeType === 'image/webp' ) mimeType = 'image/png';
+ const imageIndex = this.processImage( map.image, map.format, map.flipY, mimeType );
const textureDef = {
- sampler: this.processSampler( map ),
- source: this.processImage( map.image, map.format, map.flipY, mimeType )
+ sampler: this.processSampler( map )
};
+ if ( mimeType === 'image/webp' ) {
+
+ textureDef.extensions = textureDef.extensions || {};
+ textureDef.extensions[ 'EXT_texture_webp' ] = {
+ source: imageIndex
+ };
+
+ this.extensionsUsed[ 'EXT_texture_webp' ] = true;
+ this.extensionsRequired[ 'EXT_texture_webp' ] = true;
+
+ } else {
+
+ textureDef.source = imageIndex;
+
+ }
+
if ( map.name ) textureDef.name = map.name;
await this._invokeAllAsync( async function ( ext ) {
diff --git a/examples/misc_exporter_gltf.html b/examples/misc_exporter_gltf.html
index 146e0b92e8efde..c2840acc5e3fcf 100644
--- a/examples/misc_exporter_gltf.html
+++ b/examples/misc_exporter_gltf.html
@@ -98,7 +98,7 @@
let container;
let camera, object, object2, material, geometry, scene1, scene2, renderer;
- let gridHelper, sphere, model, coffeemat;
+ let gridHelper, sphere, model, coffeemat, webpBox;
const params = {
trs: false,
@@ -112,6 +112,7 @@
exportObjects: exportObjects,
exportSceneObject: exportSceneObject,
exportCompressedObject: exportCompressedObject,
+ exportWebPModel: exportWebPModel,
};
init();
@@ -501,6 +502,30 @@
} );
+ // ---------------------------------------------------------------------
+ // Box with WebP texture (EXT_texture_webp)
+ // ---------------------------------------------------------------------
+ const canvas = document.createElement( 'canvas' );
+ canvas.width = 64;
+ canvas.height = 64;
+ const ctx = canvas.getContext( '2d' );
+ ctx.fillStyle = '#005BBB';
+ ctx.fillRect( 0, 0, 64, 64 );
+ ctx.fillStyle = '#FFD500';
+ ctx.fillRect( 16, 16, 32, 32 );
+
+ const webpTexture = new THREE.CanvasTexture( canvas );
+ webpTexture.userData.mimeType = 'image/webp';
+ webpTexture.colorSpace = THREE.SRGBColorSpace;
+
+ webpBox = new THREE.Mesh(
+ new THREE.BoxGeometry( 100, 100, 100 ),
+ new THREE.MeshBasicMaterial( { map: webpTexture } )
+ );
+ webpBox.position.set( 400, 0, 0 );
+ webpBox.name = 'WebPBox';
+ scene1.add( webpBox );
+
//
const gui = new GUI();
@@ -519,6 +544,7 @@
h.add( params, 'exportObjects' ).name( 'Export Sphere With Grid' );
h.add( params, 'exportSceneObject' ).name( 'Export Scene 1 and Object' );
h.add( params, 'exportCompressedObject' ).name( 'Export Coffeemat (from compressed data)' );
+ h.add( params, 'exportWebPModel' ).name( 'Export WebP Model (EXT_texture_webp)' );
gui.open();
@@ -566,6 +592,12 @@
}
+ function exportWebPModel() {
+
+ exportGLTF( webpBox );
+
+ }
+
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight;
diff --git a/examples/screenshots/misc_exporter_gltf.jpg b/examples/screenshots/misc_exporter_gltf.jpg
index ee8072602102db0cf0ed3801d4f1871fd3b3efcd..6866d46dae5fe9656c3826af1346f0313ff0b91a 100644
GIT binary patch
literal 52007
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