React Three Fiber(以下称 R3F) 实际上是如何工作的?也许你有很好的 Three.js 基础,但将其引入 React 一直是一个挑战。或者你像我一样有 React 背景,但发现 Three.js 世界有点难以理解——阅读本教程就对了。本文将带你一对一地构建 Three.js 页面和 R3F 页面,并了解它们背后的工作原理。
背景
有时,了解像 R3F 这样的库的最佳方法是将其与 Three.js 比较,这样我们就可以发现 Three.js 侧和 R3F 侧的不同,并挖掘其背后的工作原理。
我们将首先在 Three.js侧 和 R3F 侧创建一个场景。
最好从 Three.js 侧开始,因为 Three.js 侧涉及更多设置,而 R3F 为我们包装了很多内容。
在 Three.js 侧创建场景
首先我们需要通过创建一个场景实例。对于那些不熟悉 Three.js 开发者来说,整个库由你可以创建实例的类组成。接下来我们有我们的相机。在本例中,我们将使用具有这些默认值的透视相机。这些参数涉及视野角度、长宽比、近视锥体和远视锥体(场景在什么时候被剪裁)。我们使用 R3F 的默认值,以便我们可以拥有相同的场景。
// Three.js 侧
import { PerspectiveCamera, Scene, WebGLRenderer } from "three";
// Create "Canvas"
const scene = new Scene();
const camera = new PerspectiveCamera(
75,
window.innerWidth / window.innerHeight,
0.1,
1000
);
// Render
const renderer = new WebGLRenderer({ alpha: true });
renderer.setSize(window.innerWidth, window.innerHeight);
const container = document.getElementById("c");
if (container) {
container.appendChild(renderer.domElement);
}
function animate() {
requestAnimationFrame(animate);
renderer.render(scene, camera);
}
animate();
如上述代码所示,当你构建 Three.js 项目时,你将需要创建一个渲染器。Three.js 支持多种渲染,最常见的是 WebGL 渲染。默认情况下,它没有透明背景,(R3F 有)。因此,我们将把 alpha
设置 为 true
来实现这一点,否则它将是一个黑色的画布。我们将渲染器设置为窗口内部宽度和高度,然后将其作为子元素附加到网页上。
这是一个 Vite 项目,因此我使用c
作为容器。
最后我们需要通过一个函数(通常称为 animate)完成渲染循环。调用请求动画帧时,浏览器对每一帧都会调用 animate()
函数。当然,需要在一开始就调用 animate()
函数,否则什么都不会启动。
// r3f 侧
import { Canvas } from "@react-three/fiber";
export default function R3fDemo() {
return (
<Canvas>
<></>
</Canvas>
);
}
对于 R3F,你可以看到启动起来要简单得多。我们只需要画布。请注意,这不是 HTML5 画布——这是我们从 @react-three/fiber
库导入的特殊画布。此时我们还没有孩元素,所以让我们继续添加网格。那么让我们回到 Three.js 示例,我们将向场景添加一个网格。在本例中,我们添加一个立方几何体。
在 Three.js 侧,网格需要具有几何体和材质才能显示在屏幕上,因此我们将添加一个立方几何体。这些参数需要宽度、高度、深度和其他参数。默认情况下,宽度、高度和深度均为 1
,这对我们来说是有效的。然后我们还要添加一种材质。现在我们将使用默认颜色为黑色的 MeshStandardMaterial。要将其添加到立方几何体中,我们只需设置 cube.geometry
为 geometry
并且我们将对材质执行相同的操作。最后,如果我们想在屏幕上看到立方几何体,我们需要执行 scene.add()
添加我们的立方几何体。
// Three.js 侧
camera.position.z = 5;
const cube = new Mesh();
const geometry = new BoxGeometry();
const material = new MeshStandardMaterial();
cube.geometry = geometry;
cube.material = material;
scene.add(cube)
当我们保存它时,我们看不到任何东西。为什么我们看不到任何东西?默认情况下,我们的相机位于 [0, 0]
,这是 R3F 设置的默认值。现在我们可以看到我们的网格了!
在 R3F 侧,我们来实现同样的效果。首先,需要添加网格,使用一个称为网格的简单 JSX 元素。然后将我们的几何体和材质添加到其中,
它们只是网格的子元素。因此,在本例中,我们将有一个 BoxGeometry,并且我们将有一个 MeshStandardMaterial。
// r3f 侧
<mesh>
<boxGeometry />
<meshStandardMaterial />
</mesh>
所以你可能会想,“等等。你只是给了这个完美的例子,但这并不能真正解释其中的任何一个实际上是如何工作的。比如 R3F 侧场景在哪里?相机是如何工作的?我们做了渲染,渲染在哪里?” 我对此的回应是:“你需要冷静下来!” 你知道你在哪个频道吗?当然。
我们要回顾一下这些东西。那么我们就从场景开始吧。别着急,后面我们会一一讲解。
理解 R3F 侧的相同场景
在 Three.js 侧,我们必须实例化创建一个新场景。然后我们通过动画循环渲染该场景。在 R3F 侧,这一切都在画布中处理。由于 R3F 试图让 Three.js 在 React 生态系统中可访问,所以大多数开发者通常不想也不需要自己管理的东西。R3F 在选择你通常不需要担心的事情方面做得非常好,但如果你确实想要的话,仍然可以选择调整这些事情,这是最好的情况。所以设置默认值非常合理。所以画布正在为我们创建一个场景——那么渲染循环呢?他们也在这样做。所以animate()
函数和请求动画帧——这一切都是在这个 Canvas 组件中处理的。我们不需要担心这个。
注意:R3F 侧的画布相当于 Three.js 侧的场景。
那么相机呢?我们在 Three.js 侧需要显式创建相机。在 R3F 上,相机在画布中创建。为什么是这样?R3F 为什么不要求我们自己在场景中创建相机呢?可能有点太神奇了。但我认为这是一种很好的魔法,我们再次来看看这里的动画循环实现,render()
函数明确依赖于场景和相机。因此,如果相机是我们自己添加到场景中,那么R3F 为我们管理这个动画循环就会变得复杂。因此,在了解这一点之后,我认为 Canvas 实际上为我们管理相机是合理的。如果你想知道“如果我想自己管理相机怎么办?” 没问题,实际上有几个选择。
- 将相机作为 prop 元素管理。
例如,你可以在画布上设置 camera
prop 位置。默认情况下,position
为 [0, 0 , 5]
,这与 Three.js 侧的设置一致: camera.position.z = 5
。如果我们将 z
值修改为 10
并刷新此页面,那么可以看到立方几何体 z
方向上进一步向后移动。这正如我们所预期的那样。所以让我们继续并实际上删除它。
// r3f 侧
<Canvas camera={{ position: [0, 0, 5]}}>
// ...
</Canvas>
注意:热重载不适用于相机。这与 prop 的工作方式有关。
- 将相机作为 JSX 元素管理。
也许你猜对了,我们可以通过导入一个名为 PerspectiveCamera
(透视相机)的特殊组件来做到这一点但。这个组件来自 @react-Three/drei
包,@react-Three/drei
是 R3F 的一个独立包,其中包含 R3F 的一堆好东西。因此,R3F 本身只是让 Three.js 在 R3F 侧工作的最小核心库,而 Drei 提供了很多额外的功能来协助它。
我们将在画布插入透视相机。首先添加一个视野角度,使其与 Three.js 侧的视野角度一致:75
。然后我们还需要给它一个于 Three.js 侧相同的位置。Drei 的透视相机组件的位置下默认都是为 0
,因此我们将其 z
值更改为 5
。同时不能忘记将 makeDefault
设置为 true
,这将覆盖原来的默认相机,并使该相机现在成为活动相机。我们可以将 z
值更改为其他值来观察 UI 是否实时更新。如你所见,这个透视相机确实可以在 R3F 侧进行热重载。
// r3f 侧
<Canvas camera={{ position: [0, 0, 5]}}>
<PerspectiveCamera fov={75} makeDefault position={[0, 0, 5]} />
// ...
</Canvas>
JSX 是如何工作的
接下来我们来谈谈 R3F 如何处理 JSX 中的 Three.js 元素。因此,从 Canvas 开始,你基本上可以将 Canvas 视为当前上下文中的场景。添加到画布内根级别的任何内容都将添加到场景中。
// Three.js 侧
const anotherMesh = new Mesh();
cube.add(anotherMesh);
因此,在本例中,我们有了网格,就像我们在 Three.js 侧所做的那样,我们将立方几何体添加到场景中。然后从场景开始以相同的方式添加任何嵌套的子元素。例如,如果我要在这个网格内部添加另一个网格。
群组
老实说这并不常见——但如果你要这样做,那么这相当于使用 mesh.add()
添加另一个网格。所以在这种情况下,假设我有一个立方几何体,然后 new Mesh()
实例化另一个网格 。然后使用 cube.add()
将其添加到该网格。这实际上会添加另一个网格作为立方几何体的子元素。你可以看到这与 R3F 方面发生的情况相当。一般来说,你不会嵌套网格元素。如果你愿意的话也可以这么做——更常见的是,你可能会看到所谓的“群组”(group)。你可以在一个组内拥有多个网格,组拥有自己的位置和旋转属性,可以允许你将其所有子元素作为一个整体移动。
从 Three.js 侧来看,这并不是一个新概念。例如,你只需创建一个群组,然后使用 group.add()
添加立方几何体。那么立方几何体和标准网格材质又是如何工作的呢?由于这些是网格的子元素,它们实际上不是以与你所说的 cube.add()
几何体相同的方式添加。
// Three.js 侧
❌ cube.add(geometry);
元素附加
实际上,你会设置网格的几何体属性及其材质属性。那么这两个 JSX 元素为何受到特殊对待呢?它们实际上是如何工作的?
要回答这个问题,你需要了解 R3F 侧的一个非常关键的概念——“附加”。本质上 R3F 的行为是这样的:元素的任何子元素都将使用 .add()
函数添加,除非它们有 一个名为“attach”的特殊属性。如果它们具有该属性,那么它实际上将作为父级的属性添加,而不是作为其父级的子元素添加。因此,在这种情况下,如果我说附加几何体,那就是说“将此立方几何体添加到网格的几何属性中”。
你可以在该代码中看到 attach="geometry"
——这实际上会附加它。添加材质与之相同—— attach="material"
,它将此网格标准材质附加到材质属性,就像在 Three.js 侧所做的那样。
// r3f 侧
<mesh>
<boxGeometry attach="geometry"/>
<meshStandardMaterial attach="material" />
</mesh>
那么为什么我以前不需要这样做?为了方便起见,R3F 会自动将所有以 “Geometry”结尾的几何体附加到几何属性,并将所有以“Material”结尾的元素附加到材料属性。这只是为了让你的生活变得轻松,因为你会一直添加这些东西。
R3F 只是包装
当谈到 R3F 时,一个重要的注意事项是 R3F 实际上对 Three.js 知之甚少。这听起来可能有点疯狂,但 R3F 的工作方式实际上只是简单地获取 这个对象并找到一个 Three.js 等效项,然后插入它默认情况下,它会尝试 使用 add()
函数添加子元素,除非它看到 “attach”。
// react-three-fiber/packages/fiber/src/core/renderer.ts
// Auto-attach geometries and materials
if (instance.__r3f.attach === undefined) {
if (instance instanceof THREE.BufferGeometry) instance.__r3f.attach = 'geometry'
else if (instance instanceof THREE.Material) instance.__r3f.attach = 'material'
}
如果你去实际查看 R3F 渲染器源码,你会发现它实际上非常非常简单。R3F 的核心只是 Three.js 的一个非常薄的包装。之所以如此重要,是因为,特别是对于那些来自 React 开发者,你确实需要了解 R3F 并没有做任何特别的事情。它实际上只是 将普通的普通 Three.js 组件放入 React 生态系统中。但他们并没有创造任何特别的东西,当然,除非你从 React Three Drei 包中提取额外的组件——这些或多或少都是新东西。但 R3F 本身实际上并没有做任何特别的事情。
如果你像我一样,熟悉 React 开始,但对 Three.js 真的一无所知,然后尝试直接深入到 Three.js 侧 R3F 库,我几乎期望 R3F 能够为我提供在 R3F 侧构建 3D 世界所需的所有文档(给我所有说明)。这其实是错误的想法。你首先要了解 Three.js 的 工作原理,然后在 R3F 侧构建它会更容易,因为所有 R3F 所做的都是采用 Three.js 概念并使它们在 React 生态系统中工作。但要在 Three.js 世界中实际构建应用,你首先需要了解 Three.js。当你第一次开始使用时,这一点非常重要。
R3F 命名
最后来理解 R3F 侧所有 Three.js 元素背后的命名。
你可能已经注意到,我们在 R3F 侧遵循了这种驼峰式大小写( 第一个字符小写),然后在 Three.js 侧,我们当然遵循标准的基于类的 pascal 大小写 (开头大写)。因此,你可能想知道,“对于每个存在的 Three.js 类,我是否可以在 JSX 中进行等效操作,只是将第一个字母更改为小写?” 这基本上是正确的。
为什么 R3F 决定这些应该是驼峰式而不是帕斯卡式?为什么第一个字符是小写的?嗯,这就是 React 的约定。如果你熟悉 React 的 DOM,那么你所有的 div、h1、a 标签——默认情况下一切都是小写的这就是你认为原始的任何元素的约定。
因此,R3F 遵循完全相同的约定——所有原始组件(在本例中是关于 Three.js 的原始组件)都将遵循首字母小写约定。总而言之,R3F JSX 元素一对一映射到各自的 Three.js 组件,JSX 元素中的子元素 通过 .add()
函数添加到其父元素,除非它们有一个特殊的“attach”方法,该方法在该情况会添加到你选择的相应属性中,几何体和材质节点会得到特殊处理,因为你一直使用它们。
从技术上讲,如果愿意的话,你在可以在 R3F 侧使用三维向量(Vector3)之类的东西。但我从未见过有人真正这样做过。这是没有意义的——你不能将普通的旧三维向量添加到网格中。但如果你熟悉 Three.js,可以新建向量。
例如,我可以轻松地将其添加到 Three.js 侧。在这种情况下,这将不起作用,因为多维数据集无法添加 维向量。但这可以帮助你更多地了解 R3F 的幕后实际情况。所以在 Three.js 侧我没有使用参数——我只是使用默认值。但是,如果你可以想象我们有一个立方几何体,并且该构造函数期望的参数是宽度、高度、深度等一堆东西。假设我想将几何体设置为 [2, 2, 2]
,几何体会是原来的两倍大。
三维向量由x
、y
和z
三个有序数字值组成。
如何在 R3F 侧做同样的事情呢?R3F 有一个 名为“args”的特殊 prop,它几乎存在于每个元素上。这需要一个数组。这实际上会作为参数传递给你要构建的组件的构造函数。因此,在这种情况下,如果我们使用 [2, 2, 2]
,从字面上看,这是一个分散到相关组件的参数(如果你可以想象的话)中的数组。如你所见,它也会将立方几何体更改 为 2
个宽度、2
个高度和 2
个深度。
组件化
现在我们来看看 3D 场景中组件化对象。因此,在 Three.js 侧,由于我们在这里或多或少使用了原生 JavaScript,因此你基本上可以根据自己的喜好组件化对象。尽管这是一种常见的模式 使用类来执行此操作。例如,我们创建一个名为 Cube
的类,它将扩展网格。然后在构造函数中我们可以创建我们的几何体。
// Three.js 侧
class Cube extends Mesh {
constructor() {
super();
const geometry = new BoxGeometry();
const material = new MeshStandardMaterial();
material.color.set("blue");
this.geometry = geometry;
this.material = material;
}
}
在 R3F 侧,这当然只是另一个 React 组件。
// R3F 侧
function Cube() {
return (
<mesh>
<boxGeometry />
<meshStandardMaterial />
</mesh>
);
}
动画
我们来谈谈动画吧。在 Three.js 侧,你可以通过在动画循环中修改立方几何体的旋转参数来该旋转该立方几何体。由于我们已将此立方几何体抽象为组件,因此,我们希望将此逻辑移至该组件中。通常,你可以使用名为 update()
的函数来执行此操作。然后我们只需要确保在动画循环内对立方几何体调用 update()
即可。现在我们的立方几何体动起来了!
// Three.js 侧
update() {
this.rotation.x += 0.01;
this.rotation.y += 0.01;
}
function animate() {
// ...
cube.update();
}
如何在 R3F 侧做同样的事情呢?R3F 为我们提供了一个名为 useFrame()
的 Hook。你可以认为useFrame()
Hook 的行为与多维数据集上的 update()
函数类似,渲染每一帧都会调用该函数。因此,在 R3F 侧,我们需要以某种方式访问该网格,以便修改其旋转参数:x
和 y
,就像我们在 Three.js 侧所做的那样。为此,我们可以使用 ref
。
// R3F 侧
const meshRef = useRef<Mesh>(null);
useFrame(() => {
if (!meshRef.current) {
return;
}
meshRef.current.rotation.x += 0.01;
meshRef.current.rotation.y += 0.01;
});
完美,现在我们在 R3F 侧也有了一个旋转立方几何体。我们需要在这里讨论两件事:
- 上下文的引用
首先,什么是具有 Three.js 上下文的 ref
(引用)?在 R3F 侧,每个 JSX 元素上的 ref
实际上都指向 Three.js 侧创建的确切实例。因此,在该网格上附加一个 ref
实际上将指向确切的网格实例。如果我要添加对几何 或材质的引用,同样的事情也会适用——这将指向 Three.js 侧该几何体的实例。如果你在 R3F 侧为 DOM 元素使用了 ref
,比如你向 div 添加引用,实际上你就拥有该 div 实例的 DOM 引用。
- 像素化外观
第二件要指出的事是:如果你像我一样有强迫症,那么 Three.js 侧的动画现在可能会让你发疯。为什么它看起来如此像素化,而 R3F 看起来却非常光滑?别担心,我们稍后会讨论这个问题。
顺便说一句,在 R3F 侧还有其他方法可以制作动画,特别是有一个名为 “React Spring” 的库,它与 R3F 无缝集成。React Spring 本质上允许你以更具声明性的方式创建动画,实际上它是跨平台的——可以在 DOM、React Native 上工作,当然还可以在 Three 中工作。
清理
如果你以前使用过 Three.js 你可能会熟悉如何清理(dispose),例如 Three.js 实际上要求你手动清理几何体和材质。这样做的原因是因为通常你会缓存这些内容,Three.js 是不知道该何时完成这个操作的。因此,你可以做的一件事就是添加一个 dispose()
函数,然后你可以处理任何内容,例如此处的几何体。当你想要删除你的立方几何体时,你可以调用 cube.dispose()
。
// Three.js 侧
class Cube extends Mesh {
// ...
dispose() {
this.geometry.dispose();
}
}
在 R3F 侧,我们实际上根本不需要担心这个,那是因为 React 实际上已经了解 R3F 侧所有不同组件的确切生命周期。因此,R3F 利用了这一点,当元素从 R3F 侧删除时,实际上会自动为你调用 dispose
。
灯光
让我们添加一些灯光。
// Three.js 侧
// Add light
const ambientLight = new AmbientLight();
scene.add(ambientLight);
const pointLight = new PointLight();
pointLight.position.set(10, 10, 10);
scene.add(pointLight);
Three.js 侧的环境光是均匀分布在所有对象上的光。我们还添加一个点光源,以便为场景添加更多维度。
点光源存在于场景中非常特定的位置内。现在这似乎没有任何作用——我们稍后会回到这个问题。
让我们在 R3F 侧做同样的事情。
// R3F 侧
// Add elements
<Canvas camera={{ position: [0, 0, 5]}}>
<ambientLight />
<pointLight position={[10, 10, 10]} />
<Cube />
</Canvas>
如你所见,我们实际上在 R3F 获得了一些维度。为什么 R3F 版本看起来比 Three.js 版本好很多呢?现在是讨论渲染外观的好时机。
渲染外观
我们可以通过多种不同的方式来配置 Three.js 渲染时的显示方式。
- 首先让我们解决这种像素化的外观。
你可以通过向 WebGL 渲染器添加另一个名为“抗锯齿”(antialias)的属性来解决此问题。实际上,默认情况下,Three.js 侧并未启用此功能,但 R3F 侧却默认启用了。
启用抗锯齿后看起来已经好一点了。但仍然不完美——Three.js 侧的立方几何体边缘看起来有点模糊,而 R3F 侧边缘却很清晰。
这与像素比有关。默认情况下,Three.js 渲染像素比为 1
,而 R3F 将尝试匹配你的浏览器窗口大小来计算像素比。因此我们必须在 Three.js 侧手动执行此操作。
// Three.js 侧
❌renderer.setPixelRatio(window.devicePixelRatio);
现在看起来清晰多了。
- 色调映射
与 R3F 侧的 3D 盒子相比,Three.js 侧仍然是一个扁平的白色盒子,这与色调映射有关。默认情况下,R3F 选择使用 ACES 电影色调映射。我们需要在Three.js 侧使用相同的 toneMapping
:
// Three.js 侧
renderer.toneMapping = ACESFilmicToneMapping;
- 色彩空间
现在我们在 Three.js 侧有了一个 3D 外观的对象,但它看起来仍然比 R3F 侧暗一点。这与 Three.js 输出的色彩空间有关。我们可以通过将输出编码设置为 sRGB 编码来解决这个问题。
// Three.js 侧
renderer.outputEncoding = sRGBEncoding;
现在我们的立方几何体看起来是一样的。那么为什么 R3F 的默认值与 Three.js 不同呢?这是 R3F 的策略,R3F希望提供大多数开发者会想要的默认值。将色调映射和输出编码设置为默认值即可解决问题。当然,如果愿你可以意更改这些值。在 R3F 侧,你可以通过画布上的“gl”属性访问设置 WebGLRenderer 渲染器。
添加颜色
当然,如果不添加一些颜色,我们的立方几何体就不完整,所以让我们将立方几何体设置为蓝色。
// Three.js 侧
renderer.outputEncoding = sRGBEncoding;
在 Three.js 侧,我们可以通过设置材质的颜色来做到这一点因此,继续在构造函数中设置颜色,或者我们可以在材质本身上设置颜色。
在 R3F 侧的做法类似。你可以为 MeshStandardMaterial
元素设置 color
prop。
// T3F.js 侧
<meshStandardMaterial color="blue" />
或者你想要疯狂一些,可以在 MeshStandardMaterial
中添加 <color>
元素,并使用“attach”属性将此颜色附加到 MeshStandardMaterial
颜色属性。请不要这样做——我认为将颜色作为子元素几乎没有任何价值,我只是想演示一下。
位置向量作为三元组
好吧,我们在这里从未讨论过的最后一件事是这个由三个值组成的数组(也 称为三元组)实际上是如何工作的。
从 Three.js 的角度来看,并没有真正看到这个约定。那么我们为什么在 R3F 侧一直使用它呢?要回答这个问题,你需要了解诸如位置之类的东西在 Three.js 侧实际上是如何工作的。
例如,当我新建一个点光源,它会有一个位置。对于场景中的大多数其他事物来说也是如此(比如你的网格有一个位置,组有一个位置,等等)。如果我将鼠标悬停在位置上,你可以看到这是一个三维向量。现在我从未创建过这个位置——一旦我创建了一个新的点光源,该位置就会自动添加到我的点光源中。在幕后这个位置实际发生的事情是——我们正在新建一个三维向量实例(我们之前讨论过它 由“x”、“y”和“z”分量组成)。值得注意的是:位置是一个只读值,不能重新分配。因此,你实际上更改位置的方法不是覆盖 Vector3,而是只需在该 Vector3 上调用 set()
方法。
const position = new Vector3(10, 10, 10);
❌pointLight.position = position;
pointLight.position.set(position);
这是为什么呢?试想一下,如果每次要移动一个对象,都必须新建一个三维向量,这将是非常昂贵的。因此,我们只是通过调用 set()
来改变三维向量。
如果我们查看位置类型,可以看到它是一个三维向量,它允许你输入数字。
在 R3F 侧,R3F 侧的一种反模式,原因与我们刚才讨论的完全相同。因此,最佳实践是使用三元组表示法。相比之下,重新创建每个渲染的三元组相对便宜,但这仍然感觉有点神奇——这个三元组是如何传递到我的点光源的位置的呢?几乎就像 args 一样,这个三元组实际上被作为位置参数转递到set()
函数中。
总结
本文中,我们创建了场景,讨论了相机、网格及其几何体和材质、动画和灯光如何工作,然后深入研究le使 R3F 渲染看起来更漂亮的小技巧,包括抗锯齿、色调映射、颜色输出和一些额外的设置。
参考示例
访问 codesandbox 查看项目代码。
评论 (0)