本文 实现了一个 双场景对比查看器,使用 Three.js 创建了两个不同的 3D 场景,并通过一个可拖动的滑块控制左右分屏显示比例,方便用户直观比较两个场景的差异。核心逻辑是在单个渲染器中渲染两个场景,通过裁剪(
scissor
)来分屏显示。
双场景渲染:
左侧场景 :显示一个 实体二十面体(
IcosahedronGeometry
+MeshStandardMaterial
)。右侧场景 :显示同一个几何体的 线框模式(
wireframe
)。滑块控制分屏:
用户可以通过拖动中间的 圆形滑块 调整左右视图的分割比例。
滑块位置动态更新渲染区域(
renderer.setScissor
)。交互功能:
支持 OrbitControls(鼠标拖拽旋转/缩放场景)。
滑块拖动时禁用相机控制,避免冲突。
如何做到单个物体实现两个场景的不同渲染效果?
要在单个物体上实现两个场景中渲染不同类型的效果,关键在于共享几何体但使用不同材质
几何体共享
const sharedGeometry = new THREE.IcosahedronGeometry(1, 3);
两个场景使用同一个几何体实例
节省内存,确保几何形状完全一致
材质分离
// 实体材质 const solidMaterial = new THREE.MeshStandardMaterial({...}); // 线框材质 const wireMaterial = new THREE.MeshStandardMaterial({ wireframe: true });
相同几何体绑定不同材质
材质属性完全独立设置
场景隔离
const solidMesh = new THREE.Mesh(sharedGeometry, solidMaterial); sceneL.add(solidMesh); const wireMesh = new THREE.Mesh(sharedGeometry, wireMaterial); sceneR.add(wireMesh);
每个场景包含独立的网格实例
共享几何体但渲染效果不同
扩展应用
可以轻松扩展为其他对比效果:
typescript
// 不同颜色对比
const material1 = new THREE.MeshBasicMaterial({ color: 0xff0000 });
const material2 = new THREE.MeshBasicMaterial({ color: 0x00ff00 });
// 不同光照效果对比
const materialA = new THREE.MeshPhongMaterial({ shininess: 100 });
const materialB = new THREE.MeshLambertMaterial();
// 不同纹理对比
const textureLoader = new THREE.TextureLoader();
const materialX = new THREE.MeshStandardMaterial({
map: textureLoader.load('texture1.jpg')
});
const materialY = new THREE.MeshStandardMaterial({
map: textureLoader.load('texture2.jpg')
单个画布上同时渲染两个场景是怎么做到的?
实现在一个canvas上渲染两个独立的场景关键方法是
setScissor
,通过指定渲染区域可以将canvas划分为多个独立渲染区域
setScissor
的关键作用
setScissor
是 Three.js 渲染器的一个重要方法,它定义了渲染的剪裁区域:
renderer.setScissor(x, y, width, height)
参数说明:
x, y
: 剪裁区域的左下角坐标(相对于画布)
width, height
: 剪裁区域的尺寸
关键特性:
剪裁测试:必须先用
setScissorTest(true)
启用剪裁测试,否则设置无效局部渲染:所有渲染操作只会影响指定的矩形区域
性能优化:只渲染指定区域,减少不必要的绘制
自定义 render
函数的双场景渲染流程
// 渲染函数
const render =() => {
if (!rendererRef.current || !cameraRef.current) return
rendererRef.current.clear()
// 渲染左侧场景
rendererRef.current.setScissor(
0,
0,
sliderPosRef.current,
window.innerHeight,
)
if (sceneLRef.current) {
rendererRef.current.render(sceneLRef.current, cameraRef.current)
}
// 渲染右侧场景
rendererRef.current.setScissor(
sliderPosRef.current,
0,
window.innerWidth - sliderPosRef.current,
window.innerHeight,
)
if (sceneRRef.current) {
rendererRef.current.render(sceneRRef.current, cameraRef.current)
}
controlsRef.current?.update()
requestRef.current = requestAnimationFrame(render)
}
详细执行流程:
清除画布:rendererRef.current.clear() 清除整个画布,准备新帧的渲染
左侧场景渲染:
设置剪裁区域为从画布左边缘到滑块位置
只在这个区域内渲染左侧场景
示例:如果滑块在中间,则渲染左半部分
右侧场景渲染:
设置剪裁区域为从滑块位置到画布右边缘
只在这个区域内渲染右侧场景
示例:如果滑块在中间,则渲染右半部分
动画循环:通过
requestAnimationFrame
持续调用渲染函数
滑块位置移动的实现机制,如何实现左右拖拽的效果?
滑块移动是通过结合React状态管理、DOM事件处理和Three.js渲染协同工作实现的。监听鼠标点击、移动、放开时操作,实时更新滑块的位置,并更新渲染器。下面详细解析其工作原理:
核心实现要素
状态管理:
const [sliderPosition, setSliderPosition] = useState(window.innerWidth / 2) const sliderPosRef = useRef(sliderPosition) // 同步滑块位置的ref
DOM结构:
<div ref={sliderRef} style={{ left: `${sliderPosition - 20}px` }} />
移动实现的三阶段
1. 鼠标/触摸按下阶段(pointerdown)
const onPointerDown = (e: PointerEvent) => {
if (e.isPrimary === false) return
isDragging = true
startX = e.clientX // 记录初始鼠标X位置
startPos = sliderPosRef.current// 记录滑块初始位置
if (controlsRef.current) controlsRef.current.enabled = false// 禁用相机控制
slider.setPointerCapture(e.pointerId)// 锁定指针事件
}
作用:准备拖拽操作,保存初始状态
关键点:
setPointerCapture
确保后续事件即使离开滑块元素也能被捕获禁用OrbitControls防止与相机旋转冲突
2. 移动阶段(pointermove)
const onPointerMove = (e: PointerEvent) => {
if (!isDragging || e.isPrimary === false) return
const deltaX = e.clientX - startX// 计算鼠标移动距离
const newPos = Math.max(0, Math.min(window.innerWidth, startPos + deltaX))// 计算新位置
setSliderPosition(newPos)// 更新React状态
}
位置计算:
deltaX
= 当前鼠标X - 初始鼠标X
newPos
= 初始滑块位置 + deltaX
Math.max/min
约束滑块不超出窗口边界实时反馈:
状态更新触发React重新渲染
滑块DOM元素的left样式随之更新
3. 释放阶段(pointerup)
const onPointerUp = (e: PointerEvent) => {
isDragging = false
if (controlsRef.current) controlsRef.current.enabled = true// 恢复相机控制
slider.releasePointerCapture(e.pointerId)// 释放指针捕获
}
事件监听
// 滑块交互
useEffect(() => {
const slider = sliderRef.current
if (!slider) return
let isDragging = false
let startX = 0
let startPos = 0
const onPointerDown = (e: PointerEvent) => {
if (e.isPrimary === false) return
isDragging = true
startX = e.clientX // 记录初始鼠标X位置
startPos = sliderPosRef.current// 记录滑块初始位置
if (controlsRef.current) controlsRef.current.enabled = false// 禁用相机控制
slider.setPointerCapture(e.pointerId)// 锁定指针事件
}
const onPointerMove = (e: PointerEvent) => {
if (!isDragging || e.isPrimary === false) return
const deltaX = e.clientX - startX// 计算鼠标移动距离
const newPos = Math.max(0, Math.min(window.innerWidth, startPos + deltaX))// 计算新位置
setSliderPosition(newPos)// 更新React状态
}
const onPointerUp = (e: PointerEvent) => {
isDragging = false
if (controlsRef.current) controlsRef.current.enabled = true// 恢复相机控制
slider.releasePointerCapture(e.pointerId)// 释放指针捕获
}
slider.addEventListener('pointerdown', onPointerDown)
window.addEventListener('pointermove', onPointerMove)
window.addEventListener('pointerup', onPointerUp)
return () => {
slider.removeEventListener('pointerdown', onPointerDown)
window.removeEventListener('pointermove', onPointerMove)
window.removeEventListener('pointerup', onPointerUp)
}
}, [])
与Three.js渲染的协同
状态同步:
// 更新滑块位置的ref
useEffect(() => {
sliderPosRef.current = sliderPosition
}, [sliderPosition])
渲染适应:
在render函数中
// 左侧剪裁区域宽度 = slider位置
renderer.setScissor(0, 0, sliderPosRef.current, height)
// 右侧剪裁区域起点 = slider位置,宽度 = 总宽度 - slider位置
renderer.setScissor(sliderPosRef.current, 0, width - sliderPosRef.current, height)
跨设备兼容处理
指针事件统一:
使用PointerEvent同时处理鼠标和触摸事件
isPrimary
检查避免多指触摸的干扰触摸优化:
CSS设置
touch-action: none
防止浏览器默认触摸行为指针捕获确保手指移出滑块后仍能跟踪
性能优化点
引用缓存:
使用
sliderPosRef
避免渲染循环频繁读取状态减少React状态更新对渲染循环的影响
节流渲染:
自然遵循requestAnimationFrame的刷新率(通常60fps)
不需要额外的节流/防抖逻辑
这种实现方式通过合理分层(交互层+渲染层)实现了流畅的滑块控制体验,同时保持代码的简洁性和可维护性。
完整代码
import React, { useRef, useState, useEffect } from 'react'
import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
const SceneComparison: React.FC = () => {
const containerRef = useRef<HTMLDivElement>(null)
const sliderRef = useRef<HTMLDivElement>(null)
const [sliderPosition, setSliderPosition] = useState(window.innerWidth / 2)
// Three.js相关引用
const rendererRef = useRef<THREE.WebGLRenderer | null>(null)
const cameraRef = useRef<THREE.PerspectiveCamera | null>(null)
const controlsRef = useRef<OrbitControls | null>(null)
const sceneLRef = useRef<THREE.Scene | null>(null)
const sceneRRef = useRef<THREE.Scene | null>(null)
const requestRef = useRef<number>(0)
const sliderPosRef = useRef(sliderPosition) // 新增ref跟踪滑块位置
// 更新滑块位置的ref
useEffect(() => {
sliderPosRef.current = sliderPosition
}, [sliderPosition])
// 渲染函数
const render =() => {
if (!rendererRef.current || !cameraRef.current) return
rendererRef.current.clear()
// 渲染左侧场景
rendererRef.current.setScissor(
0,
0,
sliderPosRef.current,
window.innerHeight,
)
if (sceneLRef.current) {
rendererRef.current.render(sceneLRef.current, cameraRef.current)
}
// 渲染右侧场景
rendererRef.current.setScissor(
sliderPosRef.current,
0,
window.innerWidth - sliderPosRef.current,
window.innerHeight,
)
if (sceneRRef.current) {
rendererRef.current.render(sceneRRef.current, cameraRef.current)
}
controlsRef.current?.update()
requestRef.current = requestAnimationFrame(render)
}
// 初始化场景
useEffect(() => {
if (!containerRef.current) return
// 1. 创建渲染器
createRenderer()
// 2. 创建相机
createCamera()
// 3. 创建控制器
const controls = new OrbitControls(cameraRef.current!, containerRef.current)
controlsRef.current = controls
// 4. 创建场景
createScene()
// 启动渲染循环
requestRef.current = requestAnimationFrame(render)
window.addEventListener('resize', handleResize)
return () => {
cancelAnimationFrame(requestRef.current)
window.removeEventListener('resize', handleResize)
if (containerRef.current && rendererRef.current?.domElement) {
containerRef.current.removeChild(rendererRef.current.domElement)
}
}
}, [])
// 滑块交互
useEffect(() => {
const slider = sliderRef.current
if (!slider) return
let isDragging = false
let startX = 0
let startPos = 0
const onPointerDown = (e: PointerEvent) => {
if (e.isPrimary === false) return
isDragging = true
startX = e.clientX // 记录初始鼠标X位置
startPos = sliderPosRef.current// 记录滑块初始位置
if (controlsRef.current) controlsRef.current.enabled = false// 禁用相机控制
slider.setPointerCapture(e.pointerId)// 锁定指针事件
}
const onPointerMove = (e: PointerEvent) => {
if (!isDragging || e.isPrimary === false) return
const deltaX = e.clientX - startX// 计算鼠标移动距离
const newPos = Math.max(0, Math.min(window.innerWidth, startPos + deltaX))// 计算新位置
setSliderPosition(newPos)// 更新React状态
}
const onPointerUp = (e: PointerEvent) => {
isDragging = false
if (controlsRef.current) controlsRef.current.enabled = true// 恢复相机控制
slider.releasePointerCapture(e.pointerId)// 释放指针捕获
}
slider.addEventListener('pointerdown', onPointerDown)
window.addEventListener('pointermove', onPointerMove)
window.addEventListener('pointerup', onPointerUp)
return () => {
slider.removeEventListener('pointerdown', onPointerDown)
window.removeEventListener('pointermove', onPointerMove)
window.removeEventListener('pointerup', onPointerUp)
}
}, [])
//创建渲染器
const createRenderer = () => {
if (!containerRef.current) return
const renderer = new THREE.WebGLRenderer({ antialias: true })
renderer.setPixelRatio(window.devicePixelRatio)
renderer.setSize(window.innerWidth, window.innerHeight)
renderer.setScissorTest(true)
rendererRef.current = renderer
containerRef.current.appendChild(renderer.domElement)
}
//创建相机
const createCamera = () => {
const camera = new THREE.PerspectiveCamera(
35,
window.innerWidth / window.innerHeight,
0.1,
100,
)
camera.position.z = 6
cameraRef.current = camera
}
//创建左右场景
const createScene = () => {
// 创建左侧场景
const sceneL = new THREE.Scene()
sceneL.background = new THREE.Color(0xbcd48f)
const lightL = new THREE.HemisphereLight(0xffffff, 0x444444, 3)
lightL.position.set(-2, 2, 2)
sceneL.add(lightL)
const sharedGeometry = new THREE.IcosahedronGeometry(1, 3)
const solidMesh = new THREE.Mesh(
sharedGeometry,
new THREE.MeshStandardMaterial(),
)
sceneL.add(solidMesh)
sceneLRef.current = sceneL
// 创建右侧场景
const sceneR = new THREE.Scene()
sceneR.background = new THREE.Color(0x8fbcd4)
const lightR = lightL.clone()
sceneR.add(lightR)
const wireMesh = new THREE.Mesh(
sharedGeometry.clone(),
new THREE.MeshStandardMaterial({ wireframe: true }),
)
sceneR.add(wireMesh)
sceneRRef.current = sceneR
}
// 窗口大小调整
const handleResize = () => {
if (cameraRef.current && rendererRef.current) {
cameraRef.current.aspect = window.innerWidth / window.innerHeight
cameraRef.current.updateProjectionMatrix()
rendererRef.current.setSize(window.innerWidth, window.innerHeight)
setSliderPosition(window.innerWidth / 2)
}
}
return (
<div style={{ position: 'relative', width: '100vw', height: '100vh' }}>
<div ref={containerRef} style={{ width: '100%', height: '100%' }} />
<div
ref={sliderRef}
style={{
position: 'absolute',
cursor: 'ew-resize',
width: '40px',
height: '40px',
backgroundColor: '#F32196',
opacity: 0.7,
borderRadius: '50%',
top: 'calc(50% - 20px)',
left: `${sliderPosition - 20}px`,
touchAction: 'none',
}}
/>
</div>
)
}
export default SceneComparison
总结
本文介绍了一种基于Three.js的双场景对比查看器实现方案。该方案通过可拖动的滑块控制左右分屏显示比例,允许用户直观比较两个3D场景的差异。核心技术要点包括:1.使用setScissor方法实现单画布双场景渲染;2.共享几何体但分别应用不同材质(实体与线框模式);3.通过React状态管理与DOM事件处理实现滑块交互;4.与OrbitControls相机控制的无缝集成。该系统具有内存高效(共享几何体)、交互流畅(60fps渲染)和扩展性强(支持多种对比效果)的特点,适用于3D模型、材质和光照效果的直观对比