完整版材质系统升级:从精简版同步材质列表与覆盖编辑功能

SceneView 有两个编辑器版本:精简版(3d-editor-lite)用于快速迭代新功能,完整版(3d-editor)承载全部功能模块。本次任务是将精简版中已完成的材质列表 + 材质覆盖编辑功能完整同步到完整版,并确保构建通过、UI 样式一致。

这篇文章复盘整个同步过程,涵盖数据模型设计、Viewport 材质应用链路、右侧面板 UI 实现,以及同步过程中遇到的构建错误和样式问题的修复。

一、功能概览:材质列表与覆盖编辑

同步的功能包含两个部分:

1.1 材质列表

在编辑器右侧面板中,选中模型后展示该模型所有材质的列表,每个条目显示:

数据来源是模型加载时自动收集的 materialsMeta 字段:

// model.materialsMeta 结构
{
  "CarPaint_Red": { name: "CarPaint_Red", meshName: "Body_Mesh" },
  "Wheel_Material": { name: "Wheel_Material", meshName: "Wheel_01" }
}

1.2 材质覆盖编辑

点击材质列表中的某一项,右侧面板展开该材质的覆盖编辑区域,可单独调整:

覆盖配置存储在 model.materialOverrides 中:

// model.materialOverrides 结构
{
  "CarPaint_Red": {
    baseColor: "#ff0000",
    metalness: 0.9,
    roughness: 0.2,
    opacity: 1.0,
    envMapIntensity: 1.5
  }
}

二、数据模型:modelStore 的改动

modelStore.js 中,addModeladdModelToScene 两个函数都需要初始化两个新字段:

// 模型数据结构中新增字段
const newModel = {
  id: modelId,
  name: modelName,
  // ... 原有字段 ...
  materialOverrides: {},   // 材质覆盖配置
  materialsMeta: {},        // 材质元信息(名称 → { name, meshName })
}

同时新增 updateModelMaterialOverride 函数,用于更新单个材质的覆盖配置并触发视口刷新:

function updateModelMaterialOverride(modelId, materialName, overridePatch) {
  const model = models.value.find(m => m.id === modelId)
  if (!model) return
  if (!model.materialOverrides) model.materialOverrides = {}
  // 合并覆盖配置
  model.materialOverrides[materialName] = {
    ...model.materialOverrides[materialName],
    ...overridePatch
  }
  // 触发视口重新应用材质
  window.dispatchEvent(new CustomEvent('sync-model-materials', {
    detail: { modelId }
  }))
}

三、Viewport 材质应用链路

材质从配置到最终渲染,经过以下链路:

步骤函数/事件作用
1applyModelMaterials(model)读取 model.materialOverrides,应用到模型所有 mesh 的材质上
2sync-model-materials 事件通知 Viewport 重新调用 applyModelMaterials
3preview-model-material 事件实时预览材质调整(不写入 materialOverrides)
4applyPreviewMaterialPatch支持按 materialName 过滤,只预览指定材质

3.1 Phong → Standard 材质转换

部分旧模型使用 MeshPhongMaterial,但金属度、粗糙度是 PBR 专属属性。当材质需要应用 PBR 覆盖时,需要自动将 Phong 转换为 Standard:

// 检查材质是否需要 PBR 属性
const needsPbrConfig = (mat) =>
  mat && (mat.metalness !== undefined || mat.roughness !== undefined)

// Phong → Standard 转换
if (child.material.isMeshPhongMaterial && needsPbrConfig(child.material)) {
  const oldMat = child.material
  const newMat = new THREE.MeshStandardMaterial({
    color: oldMat.color,
    map: oldMat.map,
    transparent: oldMat.transparent,
    opacity: oldMat.opacity,
    // 从 Phong 属性推算 PBR 默认值
    metalness: oldMat.shininess ? 0.3 : 0.0,
    roughness: 1.0 - (oldMat.shininess || 30) / 100
  })
  child.material = newMat
  // 标记已克隆,避免重复转换
  child.userData._materialCloned = true
}

3.2 materialsMeta 自动收集

applyModelMaterials 函数末尾,遍历模型所有 mesh 的材质,按名称去重后填充 model.materialsMeta

// 收集模型中所有 unique 材质的元数据
model.materialsMeta = {}
group.traverse((child) => {
  if (!child.isMesh || !child.material) return
  const materials = Array.isArray(child.material) ? child.material : [child.material]
  for (let i = 0; i < materials.length; i++) {
    const mat = materials[i]
    if (!mat) continue
    const name = mat.name || `Material_${i}`
    if (!model.materialsMeta[name]) {
      model.materialsMeta[name] = { name, meshName: child.name || 'Mesh' }
    }
  }
})

四、右侧面板 UI 实现

材质列表和覆盖编辑 UI 位于 EditorRightPanel.vue 中,选中模型后自动展示。

4.1 材质列表模板

<div v-if="modelMaterials.length > 0" class="config-section">
  <div class="section-title">材质列表 ({{ modelMaterials.length }})</div>
  <div class="material-list">
    <div
      v-for="mat in modelMaterials"
      :key="mat.matName"
      class="material-item"
      :class="{ selected: selectedMaterialName === mat.matName, overridden: hasMaterialOverride(mat.matName) }"
      @click="selectMaterial(mat.matName)"
    >
      <div class="material-item-preview" :style="{ background: getMaterialPreviewColor(mat.matName) }" />
      <div class="material-item-info">
        <span class="material-name">{{ mat.name }}</span>
        <span class="material-mesh">{{ mat.meshName }}</span>
      </div>
      <el-icon v-if="hasMaterialOverride(mat.matName)" class="override-badge" title="已自定义">
        <EditPen />
      </el-icon>
    </div>
  </div>
</div>

4.2 材质覆盖编辑模板

选中材质后,面板展开覆盖编辑区域,包含颜色选择器、滑块(金属度/粗糙度/透明度/反射强度)以及「重置」按钮。

4.3 CSS 样式

材质列表的样式定义在 <style scoped> 中,关键类包括:

.material-list {
  display: flex;
  flex-direction: column;
  gap: 2px;
  max-height: 220px;
  overflow-y: auto;
}

.material-item {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 6px 8px;
  border-radius: 6px;
  cursor: pointer;
  font-size: 12px;
  color: #d1d5db;
  transition: background 0.15s;
  border: 1px solid transparent;
}

.material-item.selected {
  background: rgba(59, 130, 246, 0.18);
  border-color: #3b82f6;
  color: #93c5fd;
}

.material-item.overridden {
  border-left: 3px solid #fbbf24;
}

五、同步过程中的问题与修复

5.1 构建错误:Identifier 'hasOwn' has already been declared

同步完成后首次构建报错:

[vue/compiler-sfc] SyntaxError: Identifier 'hasOwn' has already been declared. (3559:8)

根因:在 applyModelMaterials 函数中,const hasOwn = (obj, key) => !!obj && Object.prototype.hasOwnProperty.call(obj, key) 被声明了两次(第 3586 行和第 3616 行)。

修复:删除第 3616 行的重复声明。

5.2 材质列表不显示

构建通过后运行应用,发现材质列表始终不显示。

根因materialsMeta 字段虽然初始化为空 {},但完整版的 applyModelMaterials 末尾缺少收集逻辑(精简版有,但同步时遗漏了)。

修复:在完整版 applyModelMaterials 末尾添加遍历 mesh 材质并填充 materialsMeta 的逻辑(见第三节 3.2)。

5.3 材质列表样式不一致

样式修复后,材质列表能显示了,但外观与精简版不同。

根因:完整版 EditorRightPanel.vue<style scoped> 中缺少 .material-list.material-item 等全套 CSS 类的定义。精简版有完整定义,同步时只同步了模板和脚本,遗漏了样式部分。

修复:将精简版中 2853~2935 行的材质列表 CSS 完整复制到完整版对应的 <style> 区块中。

5.4 onSyncModelMaterials 调用不存在的函数

根因onSyncModelMaterials 中调用了 applyMaterials(model),但实际函数名是 applyModelMaterials

修复:改为 applyModelMaterials(model)

六、总结

本次同步涉及 3 个核心文件、4 个 Bug 修复,最终实现:

文件改动内容
modelStore.js新增 materialOverrides、materialsMeta 字段;新增 updateModelMaterialOverride 函数
EditorRightPanel.vue材质列表模板 + 覆盖编辑模板 + 全套 CSS 样式 + script 逻辑
EditorViewport.vueapplyModelMaterials 增强(materialOverrides 应用 + Phong→Standard 转换 + materialsMeta 收集);preview-model-material 支持 materialName 过滤;修复 onSyncModelMaterials 函数名

几个值得记住的工程教训: