分享一个THREE.JS实现的魔法镜子效果!
最近依然在学习THREE.JS发现了一个比较有意思的方法renderTarget,借助这个方法我们能实现很多有意思的效果
初始renderTarget
简单来说,RenderTarget(渲染目标) 就像是给相机准备的一张“离屏画布”或“隐藏显示器”。
以下是它的核心概念:
- 它是干什么的?
平时渲染时,相机拍到的画面直接显示在你的显示器屏幕上。 而使用 RenderTarget 时,相机拍到的画面被画在了一张内存中的纹理(Texture) 上。这被称为“离屏渲染”。
- 为什么要用它? 当你需要“在 3D 场景里显示 3D 场景”时,它是必不可少的。
-
后处理效果:先把场景拍下来,加上模糊或调色滤镜,再贴到屏幕上。
-
镜面与传送门:用一个虚拟相机拍下镜子背后的场景存入 RenderTarget,然后把这张图贴在镜子的平面上。
-
动态贴图:比如做一个监控显示屏,画面是另一个房间的实时录像。
在React Three Fiber中使用的方法如下
const mainRenderTarget = useFBO();
useFrame((state) => {
const { gl, scene, camera } = state;
gl.setRenderTarget(mainRenderTarget);
gl.render(scene, camera);
mesh.current.material.map = mainRenderTarget.texture;
gl.setRenderTarget(null);
});
为什么要放到useFrame中呢,是因为我们需要绘制每一帧的状态并把当前的状态当作纹理传递给几何体
先从一个简单的例子开始🌰
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;
}
gl.setRenderTarget(renderTarget);
gl.render(scene, camera);
if (mesh.current) {
mesh.current.material.map = renderTarget.texture;
}
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>
}
核心是利用renderTarget把当前的屏幕内容当作纹理传递给了我们的planeGeometry,逻辑图如下
画外渲染
目前我们已经知道renderTarget是使用渲染目标来拍摄当前场景的快照,并将结果用作纹理,那么我们是不是可以考虑用到createPortal来渲染一个不在当前屏幕中的场景呢!使用createPortal的基本语法如下
import { Canvas, createPortal } from '@react-three/fiber';
import * as THREE from 'three';
const Scene = () => {
const otherScene = new THREE.Scene();
return (
<>
<mesh>
<planeGeometry args={[2, 2]} />
<meshBasicMaterial />
</mesh>
{createPortal(
<mesh>
<sphereGeometry args={[1, 64]} />
<meshBasicMaterial />
</mesh>,
otherScene
)}
</>
);
};
还是通过一个例子来学习
const Portal = () => {
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);
const otherScene = new THREE.Scene();
const renderTarget = useFBO();
useFrame((state) => {
const {gl, clock, camera} = state;
if (otherCamera.current) {
otherCamera.current.matrixWorldInverse.copy(camera.matrixWorldInverse);
}
gl.setRenderTarget(renderTarget);
if (otherCamera.current) {
gl.render(otherScene, otherCamera.current);
}
if (mesh.current) {
mesh.current.material.map = renderTarget.texture;
}
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);
}
gl.setRenderTarget(null);
});
return (
<>
<PerspectiveCamera
manual
ref={otherCamera}
aspect={1.5 / 1}
/>
{createPortal(
<>
<Sky sunPosition={[10, 10, 0]}/>
<Environment preset="sunset"/>
<directionalLight args={[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>
</>
);
}
这里有几个重点
PerspectiveCamera因为是画外渲染所以我们必须再建立一个摄影机otherCamera.current.matrixWorldInverse.copy(camera.matrixWorldInverse);新建立的摄影机视角和外部主视角保持一致<PerspectiveCamera aspect={1.5 / 1}/>1.5 / 1 是为了和<planeGeometry args={[3, 2]}/>保持一致
我们这里讨论一下重点3,如果我们把画外场景的摄影机比例改变了呢!
// <PerspectiveCamera aspect={1.5 / 1}/>
<PerspectiveCamera aspect={1 / 1}/>
可以很明显的看到我们的纹理比例被压缩了,这也引申出了另一个问题
uv坐标 or 屏幕坐标
首先我们还是先看一张示意图
我们现在使用的就是默认的UV坐标,我们生成的纹理图会自动的根据几何体的宽高来压缩以适配我们的UV坐标
如果我们要实现一个屏幕坐标需要几个步骤
- 实现自定义着色器
- 用
uniform来传递纹理和屏幕坐标
1. 实现自定义着色器
<mesh ref={mesh}>
<planeGeometry args={[3, 2]}/>
<meshBasicMaterial color="white"/>
</mesh>
// 👆之前我们一直用的是meshBasicMaterial现在需要改成
<mesh ref={mesh}>
<planeGeometry args={[3, 2]}/>
{/*<meshBasicMaterial color="white"/>*/}
<shaderMaterial
fragmentShader={fragmentShader}
vertexShader={vertexShader}
uniforms={uniforms}/>
</mesh>
// 顶点着色器vertexShader
void main() {
vec4 worldPos = modelMatrix * vec4(position, 1.0);
vec4 mvPosition = viewMatrix * worldPos;
gl_Position = projectionMatrix * mvPosition;
}
// fragmentShader
uniform vec2 winResolution;
uniform sampler2D uTexture;
void main() {
vec2 uv = gl_FragCoord.xy / winResolution.xy;
vec4 color = texture2D(uTexture, uv);
gl_FragColor = color;
#include <tonemapping_fragment>
#include <colorspace_fragment>
}
这里我们的顶点着色器vertexShader就是默认的几何体空间定位设置,我们重点看一下fragmentShader
gl_FragCoord.xy几何体在屏幕空间的位置winResolution通过uniform传递进来的屏幕大小uTexture通过uniform传递进来的纹理
所以我们现在uv计算逻辑就变成了几何体在屏幕空间中的坐标位置百分比!
2. 用uniform来传递纹理和屏幕坐标
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;
if (otherCamera.current) {
otherCamera.current.matrixWorldInverse.copy(camera.matrixWorldInverse);
}
gl.setRenderTarget(renderTarget);
if (otherCamera.current) {
gl.render(otherScene, otherCamera.current);
}
if (mesh.current) {
mesh.current.material.uniforms.uTexture.value = renderTarget.texture;
mesh.current.material.uniforms.winResolution.value = new THREE.Vector2(
window.innerWidth,
window.innerHeight
).multiplyScalar(Math.min(window.devicePixelRatio, 2));
}
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);
}
gl.setRenderTarget(null);
});
调整一下几何体和摄影机看的更明显
...
...
...
<PerspectiveCamera
makeDefault
manual
ref={otherCamera}
position={[0, 0, 8]}
/>
<mesh ref={mesh}>
<boxGeometry args={[3, 3,3]}/>
{/*<meshBasicMaterial color="white"/>*/}
<shaderMaterial
fragmentShader={fragmentShader}
vertexShader={vertexShader}
uniforms={uniforms}/>
</mesh>
一起来使用魔法吧!
我们的基础已经打完了,接下来我们就正式开始我们的魔法教程!
之前我们都是把整个的纹理绘制到几何体中,在这一章节我们开始使用动态的纹理图并把其传递给几何体
const Lens2: React.FC = () => {
const mesh1 = useRef<THREE.Mesh<THREE.DodecahedronGeometry, THREE.MeshPhysicalMaterial>>(null);
const lens = useRef<THREE.Mesh<THREE.SphereGeometry, THREE.ShaderMaterial>>(null);
const renderTarget = useFBO();
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, scene, camera, pointer} = state;
});
return (
<>
<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={4}
opacity={0.2}
/>
<mesh ref={lens} scale={0.5} position={[0, 0, 2.5]}>
<sphereGeometry args={[1, 128]}/>
<shaderMaterial
fragmentShader={fragmentShader}
vertexShader={vertexShader}
uniforms={uniforms}
wireframe={false}
/>
</mesh>
<group>
<mesh ref={mesh1}>
<dodecahedronGeometry args={[1]}/>
<meshPhysicalMaterial
roughness={0}
clearcoat={1}
clearcoatRoughness={0}
color="#73B9ED"
/>
</mesh>
</group>
</>
);
}
这是一个基础模板代码,此时的效果是
1. 使用 MeshTransmissionMaterial
MeshTransmissionMaterial是 Three.js 生态中(特别是在 react-three-drei 库中)非常受欢迎的一种高级材质,它主要用于模拟毛玻璃、塑料、水、或变色玻璃等具有物理感的高级透明效果。相比于原生的 MeshPhysicalMaterial,它的性能更优化,且效果更具“数字美感”。
...
...
useFrame((state) => {
const {gl, clock, scene, camera, pointer} = state;
const viewport = state.viewport.getCurrentViewport(state.camera, [0, 0, 2.5]);
if (!lens.current) return;
lens.current.position.x = THREE.MathUtils.lerp(
lens.current.position.x,
(pointer.x * viewport.width) / 2,
0.1
);
lens.current.position.y = THREE.MathUtils.lerp(
lens.current.position.y,
(pointer.y * viewport.height) / 2,
0.1
);
gl.setRenderTarget(renderTarget);
gl.render(scene, camera);
gl.setRenderTarget(null);
});
...
...
...
<mesh ref={lens} scale={0.5} position={[0, 0, 2.5]}>
<sphereGeometry args={[1, 128]}/>
<MeshTransmissionMaterial
buffer={renderTarget.texture}
ior={1.025}
thickness={0.5}
chromaticAberration={0.05}
backside/>
</mesh>
2. 保存瞬时状态!
在useFrame中我们是根据用户设备刷新率进行刷新的如1秒60次调用,而我们的
gl.setRenderTarget(renderTarget);
gl.render(scene, camera);
是保留当前帧的状态,所以我们可以利用这个特性做很多事情!
mesh1.current.material.wireframe = true;
// 👇保留线框状态
gl.setRenderTarget(renderTarget);
gl.render(scene, camera);
// 👇取消线框状态保证用户不能直接看到线框
mesh1.current.material.wireframe = false;
gl.setRenderTarget(null);
可以看到此时我们的效果看起来就像是鼠标滑过的地方直接展示了几何体的内部!!
3. 魔法筒
有了上面的基础我们再来画一个示意图看我们的魔法筒效果应该如何实现
图片来自Beautiful and mind-bending effects with WebGL Render Targets
也就是说我们利用两个圆柱体并且两个圆柱体的纹理分别使用小球和圆锥
- 圆柱A-纹理使用小球,所以视角在圆柱A时看不到圆锥只能看到小球
- 圆柱B-整体和圆柱A相反
return <>
<Sky sunPosition={[10, 10, 0]}/>
<Environment preset="sunset"/>
<directionalLight position={[10, 10, 0]} intensity={1}/>
<ambientLight intensity={0.5}/>
<group ref={groupRef}>
<mesh
ref={cylinder1}
position={[0, 0, -4]}>
<cylinderGeometry args={[3, 3, 8, 32]}/>
<meshStandardMaterial
color="green"
transparent
/>
</mesh>
<mesh
ref={cylinder2}
position={[0, 0, 4]}
>
<cylinderGeometry args={[3, 3, 8, 32]}/>
<meshStandardMaterial
color="red"
transparent
/>
</mesh>
<mesh>
<torusGeometry args={[3, 0.2, 16, 100]}/>
<meshStandardMaterial color="#F9F9F9"/>
</mesh>
</group>
</>
圆柱体本身是由以下形状组成的
- 顶部
- 柱体
- 底部
所以我们可以分别设置纹理!
<mesh
ref={cylinder2}
position={[0, 0, 4]}
>
<cylinderGeometry args={[3, 3, 8, 32]}/>
<meshStandardMaterial
attach="material-0"
color="red"
transparent
/>
<meshStandardMaterial
attach="material-1"
color="green"
transparent
/>
<meshStandardMaterial
attach="material-2"
color="blue"
transparent
/>
</mesh>
接下来旋转一下!
<mesh
ref={cylinder1}
position={[0, 0, -4]}
rotation={[-Math.PI / 2, 0, 0]}>
<cylinderGeometry args={[3, 3, 8, 32]}/>
<meshStandardMaterial
attach="material-0"
color="red"
transparent
/>
<meshStandardMaterial
attach="material-1"
color="green"
transparent
/>
<meshStandardMaterial
attach="material-2"
color="blue"
transparent
/>
</mesh>
<mesh
ref={cylinder2}
position={[0, 0, 4]}
rotation={[Math.PI / 2, 0, 0]}
>
<cylinderGeometry args={[3, 3, 8, 32]}/>
<meshStandardMaterial
attach="material-0"
color="red"
transparent
/>
<meshStandardMaterial
attach="material-1"
color="green"
transparent
/>
<meshStandardMaterial
attach="material-2"
color="blue"
transparent
/>
</mesh>
接下来我们暂时先把这两个圆柱体隐藏,来做一个几何体的移动效果
useFrame((state, delta) => {
const {gl, scene, camera, clock} = state;
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);
});
<mesh ref={torusRef} position={[0, 0, 0]}>
<torusKnotGeometry args={[0.75, 0.3, 100, 16]}/>
<meshPhysicalMaterial
roughness={0}
clearcoat={1}
clearcoatRoughness={0}
color="#73B9ED"
/>
</mesh>
<mesh ref={boxRef} position={[0, 0, 0]}>
<boxGeometry args={[2, 2, 2]}/>
<meshPhysicalMaterial
roughness={0}
clearcoat={1}
clearcoatRoughness={0}
color="#73B9ED"/>
</mesh>
接下来就是见证奇迹的时候了!我们要运用我们之前保留关键帧的模式来动态的visible模型
const TransformPortal2: React.FC = () => {
const groupRef = React.useRef<THREE.Group>(null)
const boxRef = React.useRef<THREE.Mesh<THREE.BoxGeometry, THREE.MeshPhysicalMaterial>>(null)
const torusRef = React.useRef<THREE.Mesh<THREE.TorusKnotGeometry, THREE.MeshPhysicalMaterial>>(null)
const cylinder1 = React.useRef<THREE.Mesh<THREE.CylinderGeometry, THREE.ShaderMaterial[]>>(null)
const cylinder2 = React.useRef<THREE.Mesh<THREE.TorusKnotGeometry, THREE.ShaderMaterial[]>>(null)
const renderTarget1 = useFBO();
const renderTarget2 = useFBO();
const uniforms = useMemo(() => ({
uTexture: {
value: null,
},
winResolution: {
value: new THREE.Vector2(window.innerWidth, window.innerHeight).multiplyScalar(Math.min(window.devicePixelRatio, 2)),
},
}), []);
useFrame((state, delta) => {
const {gl, scene, camera, clock} = state;
if (cylinder1.current) {
cylinder1.current.material.forEach((material) => {
if (material.type === "ShaderMaterial") {
material.uniforms.winResolution.value = new THREE.Vector2(
window.innerWidth,
window.innerHeight
).multiplyScalar(Math.min(window.devicePixelRatio, 2));
}
});
}
cylinder2.current!.material.forEach((material) => {
if (material.type === "ShaderMaterial") {
material.uniforms.winResolution.value = new THREE.Vector2(
window.innerWidth,
window.innerHeight
).multiplyScalar(Math.min(window.devicePixelRatio, 2));
}
});
if (torusRef.current) {
torusRef.current.visible = false;
}
if (boxRef.current) {
boxRef.current.visible = true;
}
gl.setRenderTarget(renderTarget1);
gl.render(scene, camera);
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);
});
return <>
<Sky sunPosition={[10, 10, 0]}/>
<Environment preset="sunset"/>
<directionalLight position={[10, 10, 0]} intensity={1}/>
<ambientLight intensity={0.5}/>
<group ref={groupRef}>
<mesh
ref={cylinder1}
position={[0, 0, -4]}
rotation={[-Math.PI / 2, 0, 0]}>
<cylinderGeometry args={[3, 3, 8, 32]}/>
<shaderMaterial
vertexShader={vertexShader}
fragmentShader={fragmentShader}
uniforms={{
...uniforms,
uTexture: {
value: renderTarget1.texture,
},
}}
attach="material-0"
/>
<shaderMaterial
vertexShader={vertexShader}
fragmentShader={fragmentShader}
uniforms={{
...uniforms,
uTexture: {
value: renderTarget1.texture,
},
}}
attach="material-1"
/>
<meshStandardMaterial
attach="material-2"
color="blue"
transparent
opacity={0}
/>
</mesh>
<mesh
ref={cylinder2}
position={[0, 0, 4]}
rotation={[Math.PI / 2, 0, 0]}
>
<cylinderGeometry args={[3, 3, 8, 32]}/>
<shaderMaterial
vertexShader={vertexShader}
fragmentShader={fragmentShader}
uniforms={{
...uniforms,
uTexture: {
value: renderTarget2.texture,
},
}}
attach="material-0"
/>
<shaderMaterial
vertexShader={vertexShader}
fragmentShader={fragmentShader}
uniforms={{
...uniforms,
uTexture: {
value: renderTarget2.texture,
},
}}
attach="material-1"
/>
<meshStandardMaterial
attach="material-2"
color="blue"
transparent
opacity={0}
/>
</mesh>
<mesh>
<torusGeometry args={[3, 0.2, 16, 100]}/>
<meshStandardMaterial color="#F9F9F9"/>
</mesh>
<mesh ref={torusRef} position={[0, 0, 0]}>
<torusKnotGeometry args={[0.75, 0.3, 100, 16]}/>
<meshPhysicalMaterial
roughness={0}
clearcoat={1}
clearcoatRoughness={0}
color="#73B9ED"
/>
</mesh>
<mesh ref={boxRef} position={[0, 0, 0]}>
<boxGeometry args={[2, 2, 2]}/>
<meshPhysicalMaterial
roughness={0}
clearcoat={1}
clearcoatRoughness={0}
color="#73B9ED"/>
</mesh>
</group>
</>
}
参考资料
Beautiful and mind-bending effects with WebGL Render Targets