快速从零打造小米SU7在线 3D 编辑器

2025年3月20日

前言

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

功能拆分

  1. 信息面板

    1.1 模型名称、缩略图

    1.2 材质列表

  2. 渲染器

    2.1 模型的渲染

    2.2 模型的移动、旋转、缩放

  3. 贴图面板

    3.1 贴图列表

    3.2 贴图的渲染

  4. 整体交互

    4.1 点击材质,模型高亮对应的材质

    4.2 点击贴图,将贴图应用到当前选择的材质上

实现

技术栈

框架: React、Three.js、MantineUI、TypeScript

构建部署: RSBuild、Vercel

前置知识

理解 3D 基本概念及Three.js

模块划分

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
    • layout
      • header
      • RootLayout.tsx
    • materials
      • models
    • utils
    • App.tsx

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 的流程:

  1. 初始化渲染器
  2. 初始化相机
  3. 初始化场景
  4. 初始化控制器
  5. 设置环境光
  6. 加载模型
  7. 效果合成
  8. 渲染循环
    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 的概念很多, 非这么简单的例子能够完全覆盖, 但是通过这个例子, 可以理解模型渲染的流程, 以及贴图的概念。