THREE.JS实现一个魔法镜子!
- JavaScript
- 5天前
- 9热度
- 0评论
在Web 3D开发领域,Three.js 凭借其强大的渲染能力和灵活的生态体系,成为了构建沉浸式交互体验的首选工具。然而,要实现诸如“魔法镜子”、“传送门”或“实时监控屏”等高级视觉效果,仅靠基础的几何体与材质往往难以胜任。这就涉及到了图形学中一个核心概念——离屏渲染(Off-screen Rendering)。通过 WebGLRenderTarget,开发者可以将场景渲染到内存中的纹理而非直接输出到屏幕,从而为后续的后处理、动态贴图或复杂着色器计算提供数据源。
本文将深入探讨如何在 React Three Fiber (R3F) 环境中高效利用 useFBO Hook 实现离屏渲染,并结合 createPortal 技术构建独立的渲染上下文。我们将逐步解析从基础的纹理映射到高级的 屏幕空间坐标(Screen Space Coordinates) 转换,最终通过自定义 ShaderMaterial 实现一个具有折射与扭曲效果的“魔法透镜”。这不仅有助于理解 WebGL 的渲染管线机制,也为解决复杂场景下的性能优化与视觉创意提供了切实可行的技术方案。无论是游戏开发、数据可视化还是创意互动艺术,掌握这一技术栈都将极大拓展前端工程师的技术边界。
深入理解 RenderTarget:离屏渲染的核心机制
RenderTarget(渲染目标)在 WebGL 中扮演着“虚拟画布”的角色。在标准的渲染流程中,相机捕捉到的 3D 场景数据经过顶点着色器和片段着色处理后,直接绘制到浏览器的 Canvas 上,即用户可见的屏幕区域。然而,当引入 RenderTarget 时,这一流程发生了关键变化:相机拍摄的画面不再直接显示,而是被写入一张驻留在显存中的 纹理(Texture)。这种技术被称为“离屏渲染”,它是实现许多高级图形效果的基础设施。
使用 RenderTarget 的主要场景包括但不限于以下几类:首先是后处理效果(Post-processing),例如景深、运动模糊或色彩校正。系统先将完整场景渲染到 RenderTarget,再对该纹理应用滤镜算法,最后将处理后的结果呈现给用户。其次是镜面反射与传送门效果,正如本文主题所示,我们需要一个虚拟相机去拍摄镜子“背后”或传送门另一侧的场景,并将结果实时贴在镜面几何体上。最后是动态监控界面,比如在模拟驾驶舱中显示其他视角的实时画面。
在 React Three Fiber 框架中,@react-three/drei 库提供的 useFBO Hook 极大地简化了 RenderTarget 的创建与管理过程。以下代码展示了最基础的用法:
import { useFBO, useFrame } from '@react-three/drei';
import { useRef } from 'react';
import * as THREE from 'three';
const BasicRenderTargetExample = () => {
// 创建一个帧缓冲对象(Framebuffer Object),即渲染目标
const mainRenderTarget = useFBO();
const meshRef = useRef<THREE.Mesh>(null);
useFrame((state) => {
const { gl, scene, camera } = state;
// 1. 切换渲染目标:告诉 WebGL 接下来渲染到 mainRenderTarget 而不是屏幕
gl.setRenderTarget(mainRenderTarget);
// 2. 执行渲染:将当前场景和相机视角绘制到纹理中
gl.render(scene, camera);
// 3. 应用纹理:将渲染好的纹理赋值给网格的材质
if (meshRef.current) {
meshRef.current.material.map = mainRenderTarget.texture;
// 标记材质需要更新,确保纹理变化被重新编译
meshRef.current.material.needsUpdate = true;
}
// 4. 恢复默认渲染目标:后续渲染将回到屏幕
gl.setRenderTarget(null);
});
return (
<mesh ref={meshRef}>
<planeGeometry args={[2, 2]} />
<meshBasicMaterial />
</mesh>
);
};上述代码中,gl.setRenderTarget(mainRenderTarget) 是关键步骤,它重定向了 GPU 的输出流。随后 gl.render(scene, camera) 执行的是一次完整的场景遍历与绘制,但结果被捕获在了 mainRenderTarget.texture 中。最后,我们将该纹理绑定到 meshBasicMaterial 的 map 属性上,使得平面几何体能够显示出当前帧的场景快照。需要注意的是,这一过程必须在 useFrame 循环中执行,以确保每一帧都能获取最新的场景状态,从而实现动态效果。
基础实践:构建无限镜像效果
为了更直观地理解 RenderTarget 的工作流,我们从一个简单的“无限镜像”示例入手。在这个场景中,我们将创建一个包含多个几何体的场景,并通过一个平面网格来显示这些几何体的实时渲染画面。这不仅是魔法镜子的雏形,也是验证渲染逻辑正确性的最佳测试用例。
以下是完整的组件实现代码:
import React, { useRef } from 'react';
import { Canvas, useFrame } from '@react-three/fiber';
import { useFBO, Sky, Environment, OrbitControls, GizmoHelper, GizmoViewport } from '@react-three/drei';
import * as THREE from 'three';
const InfinityMirror = () => {
const mesh = useRef<THREE.Mesh<THREE.PlaneGeometry, THREE.MeshBasicMaterial>>(null);
// 初始化渲染目标
const renderTarget = useFBO();
useFrame((state) => {
const { gl, scene, camera } = state;
// 防止初始帧出现黑屏或旧纹理残留,先清空映射
if (mesh.current) {
mesh.current.material.map = null;
}
// 步骤1:设置离屏渲染目标
gl.setRenderTarget(renderTarget);
// 步骤2:渲染当前场景到纹理
gl.render(scene, camera);
// 步骤3:将生成的纹理应用到平面网格
if (mesh.current) {
mesh.current.material.map = renderTarget.texture;
mesh.current.material.needsUpdate = true;
}
// 步骤4:重置渲染目标,准备下一帧的正常屏幕渲染
gl.setRenderTarget(null);
});
return (
<>
{/* 环境光照与背景 */}
<Sky sunPosition={[10, 10, 0]} />
<directionalLight position={[10, 10, 0]} intensity={1} />
<ambientLight intensity={0.5} />
<Environment preset="sunset" />
{/* 场景中的装饰几何体,作为被反射的对象 */}
<mesh position={[-2, 0, 0]}>
<dodecahedronGeometry args={[1]} />
<meshPhysicalMaterial
roughness={0}
clearcoat={1}
clearcoatRoughness={0}
color="#73B9ED"
/>
</mesh>
<mesh position={[0, 2, 0]}>
<dodecahedronGeometry args={[1]} />
<meshPhysicalMaterial
roughness={0}
clearcoat={1}
clearcoatRoughness={0}
color="#73B9ED"
/>
</mesh>
<mesh position={[2, 0, 0]}>
<dodecahedronGeometry args={[1]} />
<meshPhysicalMaterial
roughness={0}
clearcoat={1}
clearcoatRoughness={0}
color="#73B9ED"
/>
</mesh>
<mesh position={[0, -2, 0]}>
<dodecahedronGeometry args={[1]} />
<meshPhysicalMaterial
roughness={0}
clearcoat={1}
clearcoatRoughness={0}
color="#73B9ED"
/>
</mesh>
{/* 承载渲染结果的“镜子”平面 */}
<mesh ref={mesh} scale={1}>
<planeGeometry args={[2, 2]} />
<meshBasicMaterial />
</mesh>
</>
);
};
function App() {
return (
<Canvas camera={{ position: [0, 0, 9] }} dpr={[1, 2]}>
<InfinityMirror />
<OrbitControls autoRotate={false} />
<GizmoHelper alignment="bottom-right" margin={[80, 80]}>
<GizmoViewport axisColors={['red', 'green', 'blue']} labelColor="white" />
</GizmoHelper>
</Canvas>
);
}在该示例中,核心逻辑在于 useFrame 钩子内的渲染顺序。我们首先将渲染目标切换至 renderTarget,此时 GPU 会将整个场景(包括周围的十二面体)绘制到内存纹理中。接着,我们将这张纹理赋值给中心平面的 material.map。由于这一过程在每一帧都发生,因此平面上的图像会随着相机移动或物体旋转而实时更新。这种机制虽然简单,但它揭示了“画中画”效果的本质:将三维场景二维化后,再作为纹理贴回三维空间。
进阶技巧:利用 createPortal 实现画外渲染
在前面的例子中,渲染的内容与显示的内容来自同一个场景图。然而,真正的“魔法镜子”或“传送门”往往需要展示一个完全独立的空间,或者一个位于当前视锥体之外的场景。这时,createPortal 便派上了用场。它允许我们在当前的 React Three Fiber 根节点下,创建一个隔离的子场景(Sub-scene),并指定其渲染到特定的 DOM 节点或渲染目标中。
通过结合 createPortal 与 RenderTarget,我们可以实现“画外渲染”:即渲染一个不可见的、独立的场景,并将其结果投射到主场景的某个几何体上。
import { Canvas, createPortal, useFrame } from '@react-three/fiber';
import { useFBO, PerspectiveCamera, Sky, Environment, ContactShadows } from '@react-three/drei';
import { useRef, useMemo } from 'react';
import * as THREE from 'three';
const PortalEffect = () => {
const mesh = useRef<THREE.Mesh<THREE.PlaneGeometry, THREE.MeshBasicMaterial>>(null);
const otherMesh = useRef<THREE.Mesh<THREE.DodecahedronGeometry, THREE.MeshPhysicalMaterial>>(null);
const otherCamera = useRef<THREE.PerspectiveCamera>(null);
// 创建一个独立的 Scene 对象,用于承载“门后”的世界
const otherScene = useMemo(() => new THREE.Scene(), []);
const renderTarget = useFBO();
useFrame((state) => {
const { gl, clock, camera } = state;
// 关键步骤:同步主相机与副相机的视角矩阵
// 这样“门后”的视角会跟随主视角的移动而产生透视变化,增强真实感
if (otherCamera.current) {
otherCamera.current.matrixWorldInverse.copy(camera.matrixWorldInverse);
}
// 1. 切换到离屏渲染目标
gl.setRenderTarget(renderTarget);
// 2. 渲染独立的 otherScene 到纹理
if (otherCamera.current) {
gl.render(otherScene, otherCamera.current);
}
// 3. 将渲染结果应用到主场景的平面网格
if (mesh.current) {
mesh.current.material.map = renderTarget.texture;
mesh.current.material.needsUpdate = true;
}
// 4. 让“门后”的物体动起来,以证明这是独立渲染的场景
if (otherMesh.current) {
otherMesh.current.rotation.x = Math.cos(clock.elapsedTime / 2);
otherMesh.current.rotation.y = Math.sin(clock.elapsedTime / 2);
otherMesh.current.rotation.z = Math.sin(clock.elapsedTime / 2);
}
// 5. 恢复主屏幕渲染
gl.setRenderTarget(null);
});
return (
<>
{/* 定义用于渲染副场景的相机 */}
<PerspectiveCamera
manual
ref={otherCamera}
aspect={1.5 / 1} // 宽高比需与接收纹理的几何体保持一致
/>
{/* 使用 createPortal 将内容注入到 otherScene 中 */}
{createPortal(
<>
<Sky sunPosition={[10, 10, 0]} />
<Environment preset="sunset" />
<directionalLight position={[10, 10, 0]} intensity={1} />
<ambientLight intensity={0.5} />
<ContactShadows
frames={1}
scale={10}
position={[0, -2, 0]}
blur={8}
opacity={0.75}
/>
<group>
<mesh ref={otherMesh}>
<dodecahedronGeometry args={[1]} />
<meshPhysicalMaterial
roughness={0}
clearcoat={1}
clearcoatRoughness={0}
color="#73B9ED"
/>
</mesh>
<mesh position={[-3, 1, -2]}>
<dodecahedronGeometry args={[1]} />
<meshPhysicalMaterial
roughness={0}
clearcoat={1}
clearcoatRoughness={0}
color="#73B9ED"
/>
</mesh>
<mesh position={[3, -1, -2]}>
<dodecahedronGeometry args={[1]} />
<meshPhysicalMaterial
roughness={0}
clearcoat={1}
clearcoatRoughness={0}
color="#73B9ED"
/>
</mesh>
</group>
</>,
otherScene // 指定目标场景
)}
{/* 主场景中的“窗口”或“镜子” */}
<mesh ref={mesh}>
<planeGeometry args={[3, 2]} />
<meshBasicMaterial color="white" />
</mesh>
</>
);
};在此实现中,有几个关键技术点值得注意。首先,我们必须为 otherScene 配备一个独立的 PerspectiveCamera。其次,为了保持视觉连贯性,我们在 useFrame 中将主相机的 matrixWorldInverse 复制给副相机。这意味着当用户在主场景中移动时,“门后”的世界也会以相同的视角变换进行渲染,从而产生透过窗口观察世界的错觉。此外,aspect 属性的设置至关重要,它决定了副场景渲染图像的纵横比。如果副相机的宽高比与接收纹理的几何体(这里是 3x2 的平面)不匹配,图像将会发生拉伸或压缩变形。
坐标系统探究:UV坐标与屏幕空间的差异
在使用 RenderTarget 进行纹理映射时,开发者经常会遇到图像变形的问题。这主要源于两种不同的坐标系统:UV 坐标 与 屏幕空间坐标(Screen Space Coordinates)。
默认情况下,Three.js 的材质使用 UV 坐标进行纹理采样。UV 坐标是依附于几何体顶点的属性,范围通常在 (0,0) 到 (1,1) 之间。当我们将 RenderTarget 生成的纹理贴图到一个几何体上时,纹理会根据几何体的 UV 映射关系进行拉伸适配。如果几何体的宽高比与纹理的宽高比不一致,图像就会失真。例如,将一个 16:9 的渲染结果贴到一个 1:1 的正方形上,图像会被压扁。
为了解决这一问题,特别是在实现类似“固定视角的监控屏”或“不受几何体形状影响的魔法透镜”效果时,我们需要使用屏幕空间坐标。这意味着纹理的采样不再依赖于几何体的 UV,而是依赖于该片元在屏幕上的绝对位置。无论几何体如何旋转、缩放或变形,纹理始终相对于屏幕保持静止和正确的比例。
要实现屏幕空间采样,必须摒弃标准的 meshBasicMaterial,转而使用自定义的 ShaderMaterial。
1. 编写自定义着色器
我们需要编写顶点着色器(Vertex Shader)和片段着色器(Fragment Shader)。顶点着色器负责将顶点转换到裁剪空间,而片段着色器则负责根据屏幕坐标计算纹理颜色。
顶点着色器 (vertexShader.glsl):
void main() {
// 标准的位置变换:模型空间 -> 世界空间 -> 视图空间 -> 裁剪空间
vec4 worldPos = modelMatrix * vec4(position, 1.0);
vec4 mvPosition = viewMatrix * worldPos;
gl_Position = projectionMatrix * mvPosition;
}片段着色器 (fragmentShader.glsl):
uniform vec2 winResolution; // 屏幕分辨率
uniform sampler2D uTexture; // 渲染目标纹理
void main() {
// gl_FragCoord.xy 表示当前片元在屏幕窗口中的像素坐标
// 除以 winResolution 将其归一化为 0.0 到 1.0 的范围
vec2 uv = gl_FragCoord.xy / winResolution.xy;
// 使用计算出的屏幕 UV 坐标采样纹理
vec4 color = texture2D(uTexture, uv);
gl_FragColor = color;
// 包含 Three.js 的标准色调映射和色彩空间转换
#include
<tonemapping_fragment>
#include
<colorspace_fragment>
}在片段着色器中,gl_FragCoord 是一个内置变量,提供了当前像素在窗口坐标系中的位置。通过将其除以屏幕分辨率 winResolution,我们得到了一个与屏幕宽高比完全匹配的 UV 坐标。这样,无论几何体形状如何,纹理都会严格按照屏幕比例进行显示。
2. 在 React 组件中传递 Uniforms
接下来,我们需要在 JavaScript 端创建 ShaderMaterial 并动态更新 uniforms。
import { useMemo } from 'react';
import * as THREE from 'three';
// ... 在组件内部
const uniforms = useMemo(() => ({
uTexture: { value: null },
winResolution: {
value: new THREE.Vector2(
window.innerWidth,
window.innerHeight
).multiplyScalar(Math.min(window.devicePixelRatio, 2)),
},
}), []);
useFrame((state) => {
const { gl, clock, camera } = state;
// ... 之前的渲染逻辑 ...
gl.setRenderTarget(renderTarget);
if (otherCamera.current) {
gl.render(otherScene, otherCamera.current);
}
// 更新 Shader 的 Uniforms
if (mesh.current) {
mesh.current.material.uniforms.uTexture.value = renderTarget.texture;
// 动态更新分辨率,以应对窗口大小改变或 DPR 变化
mesh.current.material.uniforms.winResolution.value = new THREE.Vector2(
window.innerWidth,
window.innerHeight
).multiplyScalar(Math.min(window.devicePixelRatio, 2));
}
// ... 动画逻辑 ...
gl.setRenderTarget(null);
});
// ... JSX 部分
<mesh ref={mesh}>
<planeGeometry args={[3, 2]} />
<shaderMaterial
fragmentShader={fragmentShader}
vertexShader={vertexShader}
uniforms={uniforms}
/>
</mesh>通过这种方式,我们成功地将纹理映射方式从“依赖几何体 UV”转换为“依赖屏幕空间”。即使我们将几何体替换为一个立方体 (boxGeometry) 或球体,纹理依然会像背景一样固定在屏幕上,透过几何体的表面显现出来。这种技术是实现复杂透明效果、折射透镜以及全息投影的基础。
进阶材质应用:MeshTransmissionMaterial 与动态交互
在实现了基础的反射效果后,我们可以引入更高级的材质来增强视觉表现力。MeshTransmissionMaterial 是 Three.js 生态中(特别是配合 react-three-drei 库使用时)极为流行的一种高级材质。它专为模拟具有物理真实感的透明介质而设计,如毛玻璃、厚塑料、水体或变色玻璃。与原生且计算昂贵的 MeshPhysicalMaterial 相比,它在保持高性能的同时,提供了更具“数字美感”和风格化的折射效果,非常适合用于制作魔法透镜或科幻界面。
为了实现跟随鼠标移动的透镜效果,我们需要在每一帧中更新透镜的位置,并重新渲染场景到 RenderTarget。在 useFrame 钩子中,我们首先获取当前的视口信息,利用线性插值函数 THREE.MathUtils.lerp 平滑地更新透镜网格的位置,使其跟随指针移动。随后,关键步骤是将 WebGL 上下文的目标设置为预先创建的 renderTarget,执行一次额外的场景渲染,最后将目标重置为 null以恢复屏幕显示。这一过程确保了透镜材质能够实时获取其背后场景的最新纹理数据。
`javascript useFrame((state) => { const {gl, clock, scene, camera, pointer} = state;
// 获取当前相机视角下的视口尺寸,用于坐标映射
const viewport = state.viewport.getCurrentViewport(state.camera, [0, 0, 2.5]);
if (!lens.current) return;
// 使用线性插值平滑更新透镜的 X 轴位置,0.1 为阻尼系数
lens.current.position.x = THREE.MathUtils.lerp(
lens.current.position.x,
(pointer.x * viewport.width) / 2,
0.1
);
// 使用线性插值平滑更新透镜的 Y 轴位置
lens.current.position.y = THREE.MathUtils.lerp(
lens.current.position.y,
(pointer.y * viewport.height) / 2,
0.1
);
// 关键步骤:将渲染目标切换为 off-screen buffer
gl.setRenderTarget(renderTarget);
// 在此缓冲区中渲染当前场景,生成纹理供材质使用
gl.render(scene, camera);
// 恢复默认渲染目标,即屏幕
gl.setRenderTarget(null);
});`
在 JSX 结构中,我们将生成的 renderTarget.texture 传递给 MeshTransmissionMaterial 的 buffer 属性。通过调整 ior(折射率)、thickness(厚度)和 chromaticAberration(色差)等参数,可以精确控制光线的弯曲程度和色彩分离效果。backside 属性的启用允许材质正确渲染背面的几何体,这对于球形或复杂形状的透镜至关重要。这种组合不仅实现了真实的折射,还赋予了物体一种梦幻般的扭曲感,极大地提升了交互体验的沉浸度。
javascript &lt;mesh ref={lens} scale={0.5} position={[0, 0, 2.5]}&gt; &lt;sphereGeometry args={[1, 128]}/&gt; &lt;MeshTransmissionMaterial buffer={renderTarget.texture} // 绑定离线渲染的纹理 ior={1.025} // 折射率,模拟玻璃或水 thickness={0.5} // 介质厚度,影响折射强度 chromaticAberration={0.05} // 色差,增加光学瑕疵真实感 backside/&gt; // 启用背面渲染 &lt;/mesh&gt;
状态快照技术:利用 RenderTarget 实现隐藏效果
RenderTarget 的核心价值在于它能够捕获某一特定时刻的场景状态,并将其保存为纹理。在 useFrame 的高频调用循环中(通常每秒 60 次),我们可以利用这一特性执行“状态快照”。这意味着我们可以在渲染到屏幕之前,先修改场景中某些物体的属性,将其渲染到离屏缓冲区,然后立即恢复这些属性。由于用户最终看到的是主屏幕的渲染结果,而离屏渲染的结果仅作为纹理被引用,因此中间的状态变化对用户是不可见的。
这种技术常用于实现“X射线”透视或内部结构展示效果。例如,我们可以暂时将一个不透明物体的材质设置为 wireframe(线框模式),执行离屏渲染以捕获其内部几何结构,然后迅速将材质恢复为正常的不透明状态。接着,将这个包含线框信息的纹理应用到一个覆盖在物体表面的透明层或透镜上。这样,用户透过透镜看到的将是物体的内部线框结构,而直接观察物体时则看到的是完整的外观,从而创造出一种神奇的透视交互体验。
`javascript // 临时修改材质属性,开启线框模式以捕获内部结构 mesh1.current.material.wireframe = true;
// 👇 将当前带有线框的场景状态渲染到 RenderTarget
gl.setRenderTarget(renderTarget);
gl.render(scene, camera);
// 👇 立即恢复材质状态,确保主屏幕渲染时不显示线框
mesh1.current.material.wireframe = false;
// 恢复默认渲染目标,准备进行主屏幕渲染
gl.setRenderTarget(null);`
通过这种方式,我们实际上是在每一帧中创建了一个“平行宇宙”的场景副本。在这个副本中,我们可以自由地改变光照、材质可见性或几何体形态,而不影响主场景的最终呈现。这种技术不仅限于线框效果,还可以用于隐藏特定图层、应用特殊的后期处理滤镜,或者动态生成遮罩。它极大地扩展了 WebGL 的表现力,使得开发者能够在不增加额外几何体复杂度的情况下,实现复杂的视觉分层和交互逻辑。
魔法筒特效:多视图投影与动态可见性控制
基于上述原理,我们可以构建更为复杂的“魔法筒”效果。该效果的核心思路是利用两个圆柱体作为观察窗口,每个窗口展示场景中不同部分或不同状态的物体。为了实现这一目标,我们需要为每个圆柱体分配独立的 RenderTarget。圆柱体 A 的纹理将绑定到第一个 RenderTarget,其中只渲染物体 A(如球体);圆柱体 B 的纹理绑定到第二个 RenderTarget,其中只渲染物体 B(如圆锥体)。通过精确控制每个渲染通道中物体的可见性,我们可以实现“透过圆柱 A 只能看到球体,透过圆柱 B 只能看到圆锥体”的视觉错觉。
在几何体构建方面,圆柱体由顶部、侧面和底部三个部分组成。Three.js 允许我们为这三个部分分别指定不同的材质(通过 attach="material-0/1/2")。在魔法筒的实现中,我们通常只对侧面(material-1)应用自定义的着色器或纹理映射,而将顶部和底部设置为透明或不可见,以确保视觉焦点集中在圆柱内部的投影内容上。此外,为了增强立体感,我们可以将两个圆柱体沿 Z 轴错开排列,并赋予它们相反的旋转角度,使得用户在移动视角时能感受到空间深度的变化。
javascript &lt;mesh ref={cylinder1} position={[0, 0, -4]} rotation={[-Math.PI / 2, 0, 0]}&gt; &lt;cylinderGeometry args={[3, 3, 8, 32]}/&gt; {/* 侧面材质:使用 ShaderMaterial 映射 RenderTarget 纹理 */} &lt;shaderMaterial vertexShader={vertexShader} fragmentShader={fragmentShader} uniforms={{ ...uniforms, uTexture: { value: renderTarget1.texture, // 绑定第一个离屏纹理 }, }} attach="material-1" /&gt; {/* 顶部和底部材质:设置为透明以隐藏 */} &lt;meshStandardMaterial attach="material-0" color="blue" transparent opacity={0} /&gt; &lt;meshStandardMaterial attach="material-2" color="blue" transparent opacity={0} /&gt; &lt;/mesh&gt;
在渲染循环中,动态控制物体的 visible 属性是实现多视图投影的关键。在每一帧开始时,我们首先隐藏物体 B,显示物体 A,并将场景渲染到 renderTarget1。紧接着,我们切换状态:隐藏物体 A,显示物体 B,并将场景渲染到 renderTarget2。最后,恢复所有物体的可见性并进行主屏幕渲染。由于这一系列操作发生在单次帧刷新内,用户不会察觉到物体的闪烁或消失,只会看到两个圆柱体分别稳定地显示着不同的内容。这种技术巧妙地利用了渲染管线的时序特性,实现了高效的多层场景合成。
`javascript useFrame((state, delta) => { const {gl, scene, camera, clock} = state;
// 更新着色器中的分辨率均匀量,确保纹理映射正确
if (cylinder1.current) {
cylinder1.current.material.forEach((material) =&gt; {
if (material.type === "ShaderMaterial") {
material.uniforms.winResolution.value = new THREE.Vector2(
window.innerWidth,
window.innerHeight
).multiplyScalar(Math.min(window.devicePixelRatio, 2));
}
});
}
// --- 第一遍渲染:捕获物体 Box 的状态到 renderTarget1 ---
if (torusRef.current) torusRef.current.visible = false; // 隐藏环面结
if (boxRef.current) boxRef.current.visible = true; // 显示立方体
gl.setRenderTarget(renderTarget1);
gl.render(scene, camera);
// --- 第二遍渲染:捕获物体 Torus 的状态到 renderTarget2 ---
if (torusRef.current) torusRef.current.visible = true; // 显示环面结
if (boxRef.current) boxRef.current.visible = false; // 隐藏立方体
gl.setRenderTarget(renderTarget2);
gl.render(scene, camera);
// 恢复默认渲染目标
gl.setRenderTarget(null);
// 更新物体位置和旋转,产生动画效果
const newPosZ = Math.sin(clock.elapsedTime) * 3.5;
boxRef.current!.position.z = newPosZ;
torusRef.current!.position.z = newPosZ;
boxRef.current!.rotation.x = Math.cos(clock.elapsedTime / 2);
boxRef.current!.rotation.y = Math.sin(clock.elapsedTime / 2);
boxRef.current!.rotation.z = Math.sin(clock.elapsedTime / 2);
torusRef.current!.rotation.x = Math.cos(clock.elapsedTime / 2);
torusRef.current!.rotation.y = Math.sin(clock.elapsedTime / 2);
torusRef.current!.rotation.z = Math.sin(clock.elapsedTime / 2);
});``
通过结合 RenderTarget 的离屏渲染能力、ShaderMaterial 的自定义纹理映射以及动态的可见性控制,我们成功构建了一个具有高度互动性和视觉冲击力的魔法筒特效。这种技术栈不仅适用于艺术创作,在游戏开发、数据可视化和虚拟展示等领域也有着广泛的应用前景。开发者可以通过替换纹理源、调整着色器算法或增加更多的渲染通道,进一步拓展这一基础框架,创造出更加丰富和复杂的 Web 3D 体验。