Skip to content

255. 实战:3D 画廊编辑器(六)

Published:

这节我们来做人物的编辑。

首先,我们要找男、女两种人物模型:

士兵模型:

https://github.com/mrdoob/three.js/blob/dev/examples/models/gltf/Soldier.glb

image.png

女孩的模型:

https://github.com/mrdoob/three.js/blob/e3ee9682fb2c776cd77fd8b89f73c321945fa52c/examples/models/gltf/Michelle.glb

image.png

放到 public 目录:

image.png

首先创建人物管理的组件:

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

看下效果:

2026-03-22 22.51.52.gif

现在就能切换人物模型了。

案例代码上传了小册仓库

总结

这节我们做了人物模型的切换,选择好了人物,接下来做画廊的编辑。

评论