前言
我们想要实现的效果如下:

功能拆分
-
信息面板
1.1 模型名称、缩略图
1.2 材质列表
-
渲染器
2.1 模型的渲染
2.2 模型的移动、旋转、缩放
-
贴图面板
3.1 贴图列表
3.2 贴图的渲染
-
整体交互
4.1 点击材质,模型高亮对应的材质
4.2 点击贴图,将贴图应用到当前选择的材质上
实现
技术栈
框架: React、Three.js、MantineUI、TypeScript
构建部署: RSBuild、Vercel
前置知识
模块划分
Layout 页面布局
Editor 编辑器
- MaterialPanel 物料信息面板, 目前只有3d模型信息
- Renderer 模型渲染器
- EditorView 编辑器视图控制器
- PropsPanel 模型编辑面板,目前只支持贴图
- Materials 物料列表, 目前只有3d模型
- Store 页面组件的数据操作 API
源代码地址: https://github.com/jy0529/material-editor
目录结构
- src
- editor
- controller
- EditorView.ts // 编辑器视图控制器
- features
- Maps.tsx // 贴图组件
- material-panel // 物料信息面板
- props-panel // 模型编辑面板
- renderer // 渲染器组件
- store // 页面组件的数据操作 API
- controller
- layout
- header
- RootLayout.tsx
- materials
- models
- utils
- App.tsx
- editor
EditorView 的详细设计
接口设计
interface EditorView {
container: HTMLDivElement
camera: PerspectiveCamera | null
scene: Scene | null
renderer: WebGLRenderer | null
controls: OrbitControls | null
model: any
modelMaterials: any[]
composer: EffectComposer | null
renderPass: RenderPass | null
outlinePass: OutlinePass | null
init(): void
onSelectedMesh(mesh: any): void
setMap(map: any): void
}
其中 onSelectedMesh 是点击材质时触发的事件,setMap 是设置贴图的事件。
init 的流程:
- 初始化渲染器
- 初始化相机
- 初始化场景
- 初始化控制器
- 设置环境光
- 加载模型
- 效果合成
- 渲染循环
private init(): void {
this.initRenderer()
this.initCamera()
this.initScene()
this.initControls()
this.createLight()
this.loadModel()
this.initEffectComposer()
this.animate()
}
初始化渲染器
private initRenderer(): void {
this.renderer = new WebGLRenderer({
antialias: true, // 抗锯齿
alpha: true, // 透明
})
this.renderer.setPixelRatio(window.devicePixelRatio)
this.renderer.setSize(this.container.clientWidth, this.container.clientHeight)
this.container.appendChild(this.renderer.domElement)
}
初始化相机
private initCamera(): void {
// 这些值都是初始化的位置,可以实时调整看效果
this.camera = new PerspectiveCamera(20, this.container.clientWidth / this.container.clientHeight, 1, 10000)
this.camera.position.set(0, 5, 10);
}
初始化场景
private initScene(): void {
this.scene = new Scene()
}
初始化控制器
private initControls(): void {
this.controls = new OrbitControls(this.camera as PerspectiveCamera, this.renderer?.domElement as HTMLCanvasElement)
this.controls.enableDamping = true; // 惯性效果,需要在渲染循环中调用
}
设置环境光
没有环境光,模型看起来会非常暗,所以需要设置环境光
private createLight(): void {
const ambientLight = new AmbientLight(0xffffff, 1);
this.scene.add(ambientLight);
}
加载模型
采用 dracoLoader 来加载模型,dracoLoader 是 three.js 的扩展库,用于加载和解析 Draco 压缩的 GLTF/GLB 模型。
这里需要将 draco 文件放在 public 目录下,然后通过 dracoLoader 来加载模型。可以参考源代码
private async loadModel({ filePath }: { filePath: string }): Promise<void> {
const dracoLoader = new DRACOLoader();
dracoLoader.setDecoderPath('draco/');
dracoLoader.setDecoderConfig({ type: 'js' });
dracoLoader.preload();
const loader = new GLTFLoader().setDRACOLoader(dracoLoader);
return new Promise((resolve, reject) => {
try {
loader.load(
filePath,
(gltf) => {
this.model = gltf.scene;
// 设置模型初始位置, 使其正对相机
this.model.rotation.set(0, degToRad(-90), 0);
// 遍历模型所有网格, 获取所有材质
this.model.traverse((child: any) => {
if (child.isMesh) {
this.modelMaterials.push(child);
}
});
// 将模型添加到场景
if (this.scene) {
this.scene.add(this.model);
}
resolve();
},
(progress) => {
console.log('加载进度:', (progress.loaded / progress.total) * 100, '%');
},
(error) => {
console.error('模型加载错误:', error);
reject(error);
}
);
} catch (error) {
console.error('加载模型时发生异常:', error);
reject(error);
}
});
}
初始化效果合成
这里主要是为了实现模型材质的轮廓高亮效果
注意 EffectComposer 需要放在渲染循环中调用, 不再使用 renderer.render 渲染, 有点类似一个高阶函数
EffectComposer 按顺序应用多个 pass, 最后使用 OutputPass 输出渲染结果
在这里我们的渲染链路是这样的:
WebGLRenderTarget 设置目标缓冲区 -> RenderPass 渲染场景和相机 -> OutlinePass 高亮选中的材质 -> OutputPass 输出渲染结果
private initEffectComposer(): void {
this.composer = new EffectComposer(this.renderer as WebGLRenderer, new WebGLRenderTarget(this.container.clientWidth, this.container.clientHeight, {
samples: 4 // 增加采样次数来提高抗锯齿的效果
}));
this.renderPass = new RenderPass(this.scene as Scene, this.camera as PerspectiveCamera);
this.composer.addPass(this.renderPass);
this.outlinePass = new OutlinePass(new Vector2(this.container.clientWidth, this.container.clientHeight), this.model, this.camera as PerspectiveCamera);
this.outlinePass.visibleEdgeColor = new Color("#FF8C00"); // 可见边缘的颜色
this.outlinePass.hiddenEdgeColor = new Color("#8a90f3"); // 不可见边缘的颜色
this.outlinePass.edgeGlow = 2; // 发光强度
this.outlinePass.usePatternTexture = false; // 是否使用纹理图案
this.outlinePass.edgeThickness = 1; // 边缘浓度
this.outlinePass.edgeStrength = 4; // 边缘的强度,值越高边框范围越大
this.outlinePass.pulsePeriod = 200; // 闪烁频率,值越大频率越低
this.composer.addPass(this.outlinePass);
this.composer.addPass(new OutputPass());
}
渲染循环
private animate(): void {
const render = () => {
requestAnimationFrame(render);
// 使用 EffectComposer 代替 renderer.render
if (this.composer) {
this.composer.render();
}
// 因为有惯性的原因,所以需要每次更新控制器
if (this.controls) {
this.controls.update();
}
};
render();
}
应用贴图
贴图的概念是应用于材质的特定类型的纹理
private setMap(mesh, map: any): void {
// 使用 TextureLoader 加载贴图
new TextureLoader().load(map.url, (texture) => {
// 克隆材质, 避免修改原材质影响其他应用该材质的模型
const newMaterial = mesh.material.clone();
newMaterial.map = texture;
// 如果存在旧材质的纹理,先释放它
if (mesh.material.map) {
mesh.material.map.dispose();
}
// apply
mesh.material = newMaterial;
mesh.mapId = map.id;
mesh.meshFrom = map.id;
});
}
选择贴图后的效果

其他模块的代码可以参考源代码, 比较常规的 React UI 组件。
总结
3D 的概念很多, 非这么简单的例子能够完全覆盖, 但是通过这个例子, 可以理解模型渲染的流程, 以及贴图的概念。