这节我们来做人物的编辑。
首先,我们要找男、女两种人物模型:
士兵模型:
https://github.com/mrdoob/three.js/blob/dev/examples/models/gltf/Soldier.glb

女孩的模型:

放到 public 目录:

首先创建人物管理的组件:
src/components/Main/CharacterView.tsx
import { useEffect, useRef, useState } from 'react'
import { initCharacterScene } from './init-character-3d'
const MODELS = {
michelle: '/Michelle.glb',
soldier: '/Soldier.glb',
} as const
type CharacterModelKey = keyof typeof MODELS
function CharacterView() {
const [active, setActive] = useState<CharacterModelKey>('michelle')
const leftRef = useRef<HTMLDivElement>(null)
const rightRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const leftEl = leftRef.current
const rightEl = rightRef.current
if (!leftEl || !rightEl) return
const leftApi = initCharacterScene(leftEl)
const rightApi = initCharacterScene(rightEl)
leftApi.loadModel(MODELS.michelle)
rightApi.loadModel(MODELS.soldier)
return () => {
}
}, [])
return (
<div className="character-view">
<header className="character-view-header">
<h2 className="character-view-title">人物模型</h2>
<p className="character-view-desc">左右各预览一个模型,点击卡片切换当前选中的角色。</p>
</header>
<div className="character-split">
<button
type="button"
className={`character-panel${active === 'michelle' ? ' character-panel--active' : ''}`}
onClick={() => setActive('michelle')}
aria-pressed={active === 'michelle'}
>
<div className="character-panel-head">
<span className="character-panel-name">Michelle</span>
{active === 'michelle' && <span className="character-panel-badge">当前选中</span>}
</div>
<div ref={leftRef} className="character-panel-viewport" />
</button>
<button
type="button"
className={`character-panel${active === 'soldier' ? ' character-panel--active' : ''}`}
onClick={() => setActive('soldier')}
aria-pressed={active === 'soldier'}
>
<div className="character-panel-head">
<span className="character-panel-name">Soldier</span>
{active === 'soldier' && <span className="character-panel-badge">当前选中</span>}
</div>
<div ref={rightRef} className="character-panel-viewport" />
</button>
</div>
</div>
)
}
export default CharacterView
就是左右加载两个 3D 场景来渲染人物模型。
写下 3d 场景的部分:
src/components/Main/init-character-3d.ts
import * as THREE from 'three';
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js';
export type CharacterSceneApi = {
loadModel: (url: string) => Promise<void>;
};
function getSize(dom: HTMLElement) {
const rect = dom.getBoundingClientRect();
const w = Math.max(rect.width, 1);
const h = Math.max(rect.height, 1);
return { width: w, height: h };
}
export function initCharacterScene(dom: HTMLElement): CharacterSceneApi {
const scene = new THREE.Scene();
const directionalLight = new THREE.DirectionalLight(0xffffff, 2.0);
directionalLight.position.set(4.5, 12, 6);
scene.add(directionalLight);
const ambientLight = new THREE.AmbientLight(0xffffff, 0.88);
scene.add(ambientLight);
const { width: initW, height: initH } = getSize(dom);
const camera = new THREE.PerspectiveCamera(42, initW / initH, 0.08, 2000);
camera.position.set(2.1, 1.45, 2.4);
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false });
renderer.setSize(initW, initH);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setClearColor(0xf0f2f5, 1);
dom.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
controls.target.set(0, 0.85, 0);
const loader = new GLTFLoader();
let currentRoot: THREE.Object3D | null = null;
function loop() {
requestAnimationFrame(loop);
controls.update();
renderer.render(scene, camera);
}
loop();
const resizeObserver = new ResizeObserver(() => {
const { width, height } = getSize(dom);
if (width < 2 || height < 2) return;
camera.aspect = width / height;
camera.updateProjectionMatrix();
renderer.setSize(width, height);
});
resizeObserver.observe(dom);
function fitCameraToObject(object: THREE.Object3D) {
const box = new THREE.Box3().setFromObject(object);
const center = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z, 0.05);
const dist = maxDim / (2 * Math.tan((camera.fov * Math.PI) / 360));
const offset = dist * 1.28;
camera.position.set(center.x + offset * 0.72, center.y + offset * 0.32, center.z + offset);
controls.target.copy(center);
controls.update();
}
function mountRoot(root: THREE.Object3D) {
if (currentRoot) {
scene.remove(currentRoot);
currentRoot = null;
}
currentRoot = root;
scene.add(currentRoot);
fitCameraToObject(currentRoot);
}
async function loadModel(url: string) {
const gltf = await loader.loadAsync(url);
mountRoot(gltf.scene);
}
return { loadModel };
}
改下 App.tsx
import { useState } from 'react'
import { Layout } from 'antd'
import './App.scss'
import AppHeader from './components/Header'
import AppSider from './components/Menu'
import Main, { type MainView } from './components/Main'
const { Header, Sider, Content } = Layout
function App() {
const [menuKey, setMenuKey] = useState<MainView>('gallery')
return (
<Layout className="gallery-editor-layout">
<Header className="app-header">
<AppHeader />
</Header>
<Layout>
<Sider width={200} className="app-sider">
<AppSider activeKey={menuKey} onSelect={setMenuKey} />
</Sider>
<Content className="app-content">
<Main view={menuKey} />
</Content>
</Layout>
</Layout>
)
}
export default App
我们把之前的 2d、3d 的场景抽离到了 GalleryView.tsx 里:
import { useEffect, useState } from 'react'
import { Segmented } from 'antd'
import { init3D } from './init-3d'
import { init2D } from './init-2d'
function GalleryView() {
useEffect(() => {
const dom = document.getElementById('threejs-3d-container')!
init3D(dom)
return () => {
dom.innerHTML = ''
}
}, [])
useEffect(() => {
const dom = document.getElementById('threejs-2d-container')!
init2D(dom)
return () => {
dom.innerHTML = ''
}
}, [])
const [curMode, setCurMode] = useState<'2d' | '3d'>('2d')
return (
<>
<div
id="threejs-3d-container"
style={{ display: curMode === '3d' ? 'block' : 'none' }}
/>
<div
id="threejs-2d-container"
style={{ display: curMode === '2d' ? 'block' : 'none' }}
/>
<div className="mode-change-btns">
<Segmented
options={[
{ label: '2D', value: '2d' },
{ label: '3D', value: '3d' },
]}
value={curMode}
onChange={(value) => setCurMode(value as '2d' | '3d')}
/>
</div>
</>
)
}
export default GalleryView
最后改下入口 index.tsx
import GalleryView from './GalleryView'
import CharacterView from './CharacterView'
export type MainView = 'gallery' | 'character'
type MainProps = {
view: MainView
}
function Main({ view }: MainProps) {
return (
<div className="main-content">
{view === 'gallery' ? <GalleryView /> : <CharacterView />}
</div>
)
}
export default Main
看下效果:

现在就能切换人物模型了。
案例代码上传了小册仓库
总结
这节我们做了人物模型的切换,选择好了人物,接下来做画廊的编辑。