DriveLerpControllerEditor开发总结:一个3D编辑器插值控制系统的实现

一、总体内容结构总结

本次开发的核心是构建一个基于Vue 3 + TypeScript + Babylon.js的3D对象驱动行为管理系统,重点实现了DriveLerpControllerEditor类及其配套的UI界面。整个系统采用行为(Behavior)模式、命令模式(Command Pattern)和响应式编程架构,为3D场景中的对象提供了统一的插值动画控制能力。

1.1 系统架构分层

整个项目采用清晰的分层架构:

数据层(Babylon.js Behavior系统)

  • DriveLerpControllerEditor作为核心控制器,继承自DriveLerpBehaviorEditor

  • 通过DriveBehaviorEditorManager管理单个节点上的多个驱动行为

  • 使用DriveLerpProcessInfo连接控制器与具体行为,管理时间轴映射

  • 采用树形结构DriveLerpControllerTreeNode组织父子节点关系

命令层(撤销/重做支持)

  • 所有状态变更通过CmdPropertyCommand基类实现

  • 支持复合命令CompositeCommand批量操作

  • 每个驱动行为(位置、旋转、缩放、可见性等)都有对应的命令封装

UI层(Vue 3组件)

  • 属性面板系统:ObjectPropertiesPanelObjectBaseProperties / ObjectMatProperties / DriveBehaviorList

  • 控制器对话框:DriveLerpControllerDialog作为主容器

  • 树形视图:DC_TreeList + DC_TreeNodeItem展示层级关系

  • 轨道编辑器:DC_EditingTrack + StartDurationTrack实现可视化时间轴拖拽

通信层(事件总线)

  • 使用Observable模式替代传统Vue watch,实现跨组件通信

  • EventBus提供全局事件发射器

  • provide/inject机制实现依赖注入

1.2 核心功能模块

插值行为管理系统支持6种驱动行为:

  • DriveLerpPositionEditor:位置插值移动

  • DriveLerpRotationEditor:旋转插值(欧拉角)

  • DriveLerpScalingEditor:缩放插值

  • DriveLerpVisibilityEditor:可见性渐变(仅Mesh)

  • DriveAxisRotateEditor:轴向持续旋转

  • DriveLerpControllerEditor:统一控制器,协调多个行为

控制器核心能力:

  • 统一时间轴:将所有子行为的进度映射到全局0-100%范围

  • 播放控制:支持正向/反向播放、暂停、停止、循环

  • 速度调节:1x/2x/4x/8x多档速度

  • 可视化编辑:树形列表 + 轨道拖拽 + 时间标记

  • 实时预览:拖动进度条时实时更新场景状态

1.3 关键数据流

复制代码
3D对象 → DriveBehaviorEditorManager → DriveLerpBehaviorEditor
         ↓
DriveLerpControllerEditor → DriveLerpProcessInfo (时间映射)
         ↓
UI组件树 (provide/inject + Observable)

二、关键技巧点总结

技巧1:provide/inject的进阶用法------提供全局状态与函数

在大型组件树中,传统的props传递会导致"prop drilling"问题。本项目大量使用provide/inject不仅传递数据,更关键的是提供全局函数和响应式状态,实现真正的跨层通信。

应用实例1:在DriveLerpControllerDialog.vue中提供选中状态管理

TypeScript 复制代码
<script setup lang="ts">
// 提供全局的选中 ID,所有子组件可以注入并使用
const selectedId = ref<string | null>(null)
provide('selectedId', selectedId)

const handleGlobalClick = () => {
  selectedId.value = null  // 点击空白处清除选中
}
</script>

子组件如DriveLerpProcessInfoNodeItem.vue中注入使用:

TypeScript 复制代码
<script setup lang="ts">
// 注入全局的 selectedId,用于同步所有组件的选中状态
const selectedId = inject<Ref<string | null>>('selectedId', ref(null));

const handleClick = (event: MouseEvent) => {
  event.stopPropagation();
  if (props.info?.id && selectedId) {
    selectedId.value = props.info.id;  // 更新全局选中状态
  }
};
</script>

应用实例2:在DC_EditingTrack.vue中提供注册函数

TypeScript 复制代码
<script setup lang="ts">
// 提供注册函数给子组件
const registerTrackWidth = (id: string, width: number) => {
  trackWidthMap.value.set(id, width);
};

// 将注册函数和常量通过 provide 暴露给子组件
provide('registerTrackWidth', registerTrackWidth);
provide('unregisterTrackWidth', unregisterTrackWidth);
provide('requestAutoScroll', requestAutoScroll);
provide('PIXEL_TO_SECOND', PIXEL_TO_SECOND);
provide('SECOND_TO_PIXEL', SECOND_TO_PIXEL);
provide('MIN_LEFT_OFFSET', MIN_LEFT_OFFSET);
</script>

子组件StartDurationTrack.vue中注入调用:

TypeScript 复制代码
<script setup lang="ts">
// 注入父组件提供的注册函数
const registerTrackWidth = inject<(id: string, width: number) => void>('registerTrackWidth');
const unregisterTrackWidth = inject<(id: string) => void>('unregisterTrackWidth');

onMounted(() => {
  // 注册当前组件的 trackWidth
  if (registerTrackWidth && props.info?.id) {
    registerTrackWidth(props.info.id, trackWidth.value);
  }
});

onUnmounted(() => {
  // 取消注册 trackWidth
  if (unregisterTrackWidth && props.info?.id) {
    unregisterTrackWidth(props.info.id);
  }
});
</script>

优势:父组件无需知道子组件结构,子组件也无需知道数据来自何处,实现真正的解耦。

技巧2:事件驱动优先于watch------解决响应"迟钝"问题

在3D编辑器场景中,状态变化频繁且要求实时性。传统Vue watch在某些情况下(如深层嵌套对象、大量数据更新)会有性能延迟。本项目全面采用Observable事件模式,由Babylon.js的观察者模式驱动UI更新。

典型场景:行为列表动态更新

DriveBehaviorList.vue中,不watch props.behavManager.driveBehaviors,而是监听Observable:

TypeScript 复制代码
<script setup lang="ts">
let behaviorChangedObserver: Observer<void> | null = null;

onMounted(() => {
  // 只需监听一个统一的变化事件
  behaviorChangedObserver = props.behavManager.onDriveBehaviorsChangedObservable.add(() => {
    updateBehavior();  // 响应式更新本地状态
  });
});

onUnmounted(() => {
  if (behaviorChangedObserver) {
    props.behavManager.onDriveBehaviorsChangedObservable.remove(behaviorChangedObserver);
  }
});
</script>

对应的DriveBehaviorEditorManager.ts中:

TypeScript 复制代码
public readonly onDriveBehaviorsChangedObservable = new Observable<void>();

private _setupBehaviorListener(behavior: DriveBehaviorEditor): void {
  behavior.onSetEnabledObservable.add(() => {
    this.onDriveBehaviorsChangedObservable.notifyObservers();  // 主动通知
    this.onSetDriveBehaviorEnabledObservable.notifyObservers(behavior);
  });
}

对比优势

  • 即时性:状态变化立即触发,无延迟

  • 精确性:只通知需要的组件,避免无关rerender

  • 解耦:行为管理器无需知道谁在使用它

另一个例子:在ObjectMatProperties.vue中修复材质状态同步

TypeScript 复制代码
// 监听变化(关键修复)
originalMatKeeper.value.onMaterialChangedObservable?.add(() => {
  isUsedOriginalMaterial.value = originalMatKeeper.value!.isUsedOriginalMaterial;
});

使用Observable而非watch,确保材质状态变化时UI立即响应。

技巧3:拦截Checkbox点击事件------@click.stop的妙用

TDesign的t-checkbox组件在点击时会触发change事件,同时也会冒泡click事件。在某些场景下(如树形节点点击选中),需要阻止这种冒泡,避免父级节点误响应。

应用场景:DriveLerpProcessInfoNodeItem.vue中的复选框

TypeScript 复制代码
<template>
  <div 
    class="process-info-item-container"
    @click="handleClick"  <!-- 父级点击事件 -->
  >
    <t-checkbox 
      class="process-info-item-checkbox"
      v-model="localCtrlEnabled"
      @change="handleCtrlEnabledChange"
      @click.stop  <!-- 关键:阻止click事件冒泡到父级 -->
    />
    <!-- ... -->
  </div>
</template>

<script setup lang="ts">
const handleClick = (event: MouseEvent) => {
  // 点击容器时设置选中状态
  event.stopPropagation();
  if (props.info?.id && selectedId) {
    selectedId.value = props.info.id;
  }
};

const handleCtrlEnabledChange = (enabled: boolean) => {
  // 复选框状态变化时调用命令
  if (!props.info || isSyncing) return;
  try {
    isSyncing = true;
    if (typeof props.info.cmdSetCtrlEnabled === 'function') {
      props.info.cmdSetCtrlEnabled(enabled);
    }
  } finally {
    isSyncing = false;
  }
};
</script>

原理分析

  • 点击复选框时,会同时触发t-checkbox的change事件和click事件

  • click.stop阻止click事件向上冒泡到父级div

  • 父级div的handleClick只响应容器本身的点击,用于选中该行

  • 复选框的handleCtrlEnabledChange独立处理启用/禁用逻辑

类似应用 :在DC_TrackItem.vue等组件中也广泛使用此技巧,确保交互精确可控。

技巧4:v-bind在CSS中的灵活运用------动态样式计算

Vue 3的v-bind<style>中支持直接绑定响应式变量,避免了大量内联样式,使模板更简洁。本项目多处使用此特性实现动态布局。

应用实例1:DriveLerpControllerDialog.vue中动态宽高

TypeScript 复制代码
<template>
  <div class="custom-dialog-container" :style="dialogStyle">
    <!-- ... -->
  </div>
</template>

<script setup lang="ts">
const dialogStyle = computed(() => ({
  width: `${curDialogWidth.value}px`,
  height: `${dialogHeight}px`,
  left: `${dialogPosition.value.x}px`,
  top: `${dialogPosition.value.y}px`
}))
</script>

<style scoped>
.dc-editing-input-area {
  width: v-bind('timeInputAreaWidth + "px"');  /* 动态绑定180px */
}

.dc-editing-track-area {
  width: v-bind('timeTrackAreaWidth + "px"');  /* 动态绑定1138px */
}
</style>

应用实例2:StartDurationTrack.vue中使用注入的常量

TypeScript 复制代码
<style scoped>
.handle-left {
  left: calc(-1 * v-bind(MIN_LEFT_OFFSET) * 1px);  /* 计算为-3px */
}

.handle-right {
  left: 0;
  transform: translateX(calc(-1 * v-bind(MIN_LEFT_OFFSET) * 1px));
}
</style>

优势

  • 响应式:变量变化自动更新样式,无需手动操作DOM

  • 可读性:将样式与逻辑分离,模板更清晰

  • 性能:Vue自动优化,避免不必要的重排重绘

高级用法 :在DC_EditingTrack.vue中结合计算属性:

TypeScript 复制代码
<style scoped>
.track-content {
  width: v-bind('trackContentWidth + "px"');  /* 动态计算最大宽度 */
}
</style>

技巧5:鼠标拖拽的完整实现模式

拖拽是编辑器核心交互。本项目抽象出了一套标准的拖拽三件套:startDragonDragstopDrag,并处理边界约束和性能优化。

完整实现:DriveLerpControllerDialog.vue的对话框拖拽

TypeScript 复制代码
<script setup lang="ts">
const startDrag = (e: MouseEvent) => {
  if (!dialogRef.value) return
  
  isDragging.value = true
  dragStartPos.value = { x: e.clientX, y: e.clientY }
  dialogStartPos.value = { ...dialogPosition.value }
  
  // 在document上监听,确保鼠标移出窗口也能捕获
  document.addEventListener('mousemove', onDrag)
  document.addEventListener('mouseup', stopDrag)
  e.preventDefault()
  e.stopPropagation()
}

const onDrag = (e: MouseEvent) => {
  if (!isDragging.value) return
  
  const deltaX = e.clientX - dragStartPos.value.x
  const deltaY = e.clientY - dragStartPos.value.y
  
  const newX = dialogStartPos.value.x + deltaX
  const newY = dialogStartPos.value.y + deltaY

  clampPos(newX, newY)  // 边界约束
  
  e.preventDefault()
  e.stopPropagation()
}

const clampPos = (x: number, y: number) => {
  const minX = 180 - curDialogWidth.value
  const maxX = window.innerWidth - 180
  const maxY = window.innerHeight - 88
  
  // 确保对话框不超出视口
  dialogPosition.value = {
    x: Math.max(minX, Math.min(x, maxX)),
    y: Math.max(0, Math.min(y, maxY))
  }
}

const stopDrag = (e?: MouseEvent) => {
  isDragging.value = false
  document.removeEventListener('mousemove', onDrag)
  document.removeEventListener('mouseup', stopDrag)
  
  if (e) {
    e.preventDefault()
    e.stopPropagation()
  }
}
</script>

轨道拖拽实现:StartDurationTrack.vue的三段式拖拽

TypeScript 复制代码
<script setup lang="ts">
// 拖拽主体(移动start)
const handleDurationBodyMouseDown = (e: MouseEvent) => {
  if (e.button !== 0) return;
  e.preventDefault(); e.stopPropagation();
  
  isDraggingBody.value = true;
  dragStartX.value = e.clientX;
  dragStartStart.value = props.info.start;
  dragStartDuration.value = props.info.duration;
  
  document.addEventListener('mousemove', handleMouseMove);
  document.addEventListener('mouseup', handleMouseUp);
}

// 拖拽左侧(调整start和duration)
const handleLeftHandleMouseDown = (e: MouseEvent) => {
  isDraggingLeft.value = true;
  // ... 类似逻辑
}

// 拖拽右侧(调整duration)
const handleRightHandleMouseDown = (e: MouseEvent) => {
  isDraggingRight.value = true;
  // ... 类似逻辑
}

const handleMouseMove = (e: MouseEvent) => {
  const deltaX = e.clientX - dragStartX.value;
  const deltaTime = deltaX * PIXEL_TO_SECOND;  // 像素转时间
  
  if (isDraggingBody.value) {
    let newStart = dragStartStart.value + deltaTime;
    newStart = Math.max(0, newStart);  // 约束最小值
    props.info.setStart(newStart);
    
    // 自动滚动,确保元素可见
    if (requestAutoScroll) {
      requestAutoScroll(startWidth.value, durationWidth.value, 'body');
    }
  }
  // ... 左右拖拽逻辑
}
</script>

关键要点

  • document上监听mousemove/mouseup,避免鼠标离开元素后失效

  • 使用e.button !== 0过滤非左键点击

  • 拖拽时添加dragging类名,实现视觉反馈

  • 边界约束和自动滚动提升用户体验

技巧6:CSS filter: brightness() 实现hover效果

传统hover效果使用背景色变化,但在深色主题中效果不明显。本项目使用brightness()滤镜实现更细腻的明暗变化,无需定义多个颜色变量。

应用实例:StartDurationTrack.vue的轨道条

TypeScript 复制代码
<style scoped>
.duration-bar {
  /* 内部浮雕效果 */
  box-shadow: 
    inset 0 1px 3px rgba(255, 255, 255, 0.25),
    inset 0 -2px 2px rgba(0, 0, 0, 0.5);
}

.duration-bar:hover {
  filter: brightness(1.4) !important;  /* 悬停时提亮40% */
}

.duration-bar.dragging {
  filter: brightness(1.5) !important;  /* 拖拽时更亮 */
  cursor: grabbing;
}

.handle:hover {
  filter: brightness(1.2) !important;  /* 拖拽手柄提亮 */
}

.handle.dragging {
  filter: brightness(1.25) !important;
}
</style>

应用实例:DC_TopBar.vue的工具按钮

TypeScript 复制代码
<style scoped>
.tool-btn:hover {
  background-color: var(--td-brand-color-11);
  /* 添加白色边框效果 */
  border: 1px solid rgba(255, 255, 255, 0.2);
}

.tool-btn:hover :deep(svg) {
  filter: brightness(1.2);  /* 图标提亮 */
}
</style>

优势

  • 单一颜色源:只需定义基础色,hover效果自动计算

  • 视觉效果统一:所有元素使用相同的提亮比例,风格一致

  • 性能:CSS滤镜由GPU加速,比JS计算更高效

技巧7:CSS calc() 实现动态计算

在无法直接使用v-bind或需要复杂计算时,calc()结合CSS变量实现动态样式。本项目在拖拽手柄定位中大量使用。

应用实例:StartDurationTrack.vue的左右手柄定位

TypeScript 复制代码
<template>
  <div class="duration-bar-container" :style="{ left: startWidth + 'px' }">
    <!-- 左侧拖拽竖条 -->
    <div class="handle handle-left"
      :class="{ 'dragging': isDraggingLeft }"
      @mousedown="handleLeftHandleMouseDown">
    </div>
    
    <!-- 持续时间条主体 -->
    <div class="duration-bar" 
      :style="{ width: durationWidth + 'px' }">
    </div>
    
    <!-- 右侧拖拽竖条 -->
    <div class="handle handle-right"
      :style="{ left: durationWidth + 'px' }"
      @mousedown="handleRightHandleMouseDown">
    </div>
  </div>
</template>

<style scoped>
.handle-left {
  left: calc(-1 * v-bind(MIN_LEFT_OFFSET) * 1px);  /* 计算为-3px,确保手柄可见 */
}

.handle-right {
  left: 0;
  transform: translateX(calc(-1 * v-bind(MIN_LEFT_OFFSET) * 1px));  /* 向右偏移3px */
}

.start-duration-track {
  height: 28px;
}

.duration-bar-container {
  position: absolute;
  top: 0;
  height: 100%;
}
</style>

计算逻辑

  • MIN_LEFT_OFFSET = 3(像素)

  • 左手柄left: -3px,使手柄从轨道左侧露出

  • 右手柄使用translateX(-3px),避免改变布局流

应用实例:DriveLerpControllerDialog.vue的响应式布局

TypeScript 复制代码
<style scoped>
.dc-editing-input-area {
  width: v-bind('timeInputAreaWidth + "px"');  /* 180px */
}

.dc-editing-track-area {
  width: v-bind('timeTrackAreaWidth + "px"');  /* 1138px */
}

.main-content-area {
  height: calc(100% - 36px);  /* 减去头部高度 */
}
</style>

技巧8:计算属性实现复杂动态样式

对于需要多个变量组合计算的样式,使用computed属性集中处理,避免模板臃肿。

应用实例:DriveLerpControllerDialog.vue的完整对话框样式

TypeScript 复制代码
<script setup lang="ts">
const dialogBaseWidth = 1680
const timeInputAreaWidth = 180
const timeTrackAreaWidth = 1138
const dialogHeight = 800

const curDialogWidth = ref(dialogBaseWidth)

const dialogStyle = computed(() => ({
  width: `${curDialogWidth.value}px`,
  height: `${dialogHeight}px`,
  left: `${dialogPosition.value.x}px`,
  top: `${dialogPosition.value.y}px`
}))

const toggleInputArea = () => {
  const curCenterH = dialogPosition.value.x + curDialogWidth.value * 0.5
  showInputArea.value = !showInputArea.value
  updateDialogWidth()
  const newX = curCenterH - curDialogWidth.value * 0.5
  clampPos(newX, dialogPosition.value.y)  // 保持中心点不变
}

const updateDialogWidth = () => {
  let width = dialogBaseWidth
  
  if (!showInputArea.value) {
    width -= timeInputAreaWidth  // 隐藏时减去180px
  }
  if(!showDragBarArea.value) {
    width -= timeTrackAreaWidth  // 隐藏时减去1138px
  }
  
  curDialogWidth.value = width  // 响应式更新
}
</script>

<template>
  <div 
    ref="dialogRef"
    class="custom-dialog-container"
    :style="dialogStyle"
    @mousedown="startDrag"
  >
    <!-- 动态内容区域 -->
    <div class="dc-editing-input-area" v-if="showInputArea">
      <DC_EditingInput :rootTreeNode="props.controller.rootTreeNode" />
    </div>
    <div class="dc-editing-track-area" v-if="showDragBarArea">
      <DC_EditingTrack :controller="props.controller" />
    </div>
  </div>
</template>

优势

  • 单一数据源 :所有尺寸计算集中在updateDialogWidth

  • 响应式联动 :宽度变化自动触发dialogStyle重新计算

  • 用户体验:切换区域时保持对话框中心点不变,避免视觉跳跃

技巧9:CSS容器查询实现响应式布局

传统响应式依赖视口宽度,但在组件化开发中,需要根据组件自身宽度 调整布局。DC_TopBar.vue使用CSS容器查询,在窄面板中隐藏耗时信息。

实现:DC_TopBar.vue

TypeScript 复制代码
<template>
  <div class="top-function-show-area">
    <div class="center-section">
      <!-- 播放控制按钮 -->
      <div class="top-buttons-container">
        <!-- ... -->
      </div>
      
      <!-- 速度选择 -->
      <t-select class="multi-speed-select" v-model="multiSpeed" />
    </div>
    
    <!-- 时间信息 -->
    <div class="process-info">
      当前时间: {{ currentProcess.toFixed(2) }} 秒 / 总体时长: {{ totalProcess.toFixed(2) }} 秒
    </div>
  </div>
</template>

<style scoped>
.top-function-show-area {
  display: flex;
  align-items: center;
  gap: 16px;
  width: 100%;
  container-type: inline-size;  /* 启用容器查询 */
}

.process-info {
  position: absolute;
  right: 22px;
  color: #999 !important;
  margin-left: auto;
  user-select: none;
}

/* 当容器宽度小于800px时隐藏process-info */
@container (max-width: 799px) {
  .process-info {
    display: none;  /* 窄容器下隐藏详细信息 */
  }
}
</style>

工作原理

  • container-type: inline-size声明该元素为查询容器

  • @container (max-width: 799px)基于容器宽度而非视口宽度应用样式

  • 当顶部栏被压缩(如侧边栏展开)时,自动隐藏耗时信息,保留核心控制按钮

适用场景

  • 组件在不同父容器中有不同表现

  • 局部布局调整无需全局媒体查询

  • 更细粒度的响应式设计


三、开发心得与最佳实践

1. 架构设计原则

  • 关注点分离:数据层(Babylon Behavior)、逻辑层(Manager)、视图层(Vue)清晰分离

  • 命令模式:所有状态变更通过Command封装,天然支持撤销/重做

  • 事件驱动:使用Observable替代watch,性能更优、耦合更低

2. 性能优化技巧

  • 组件懒加载Teleport配合v-if延迟渲染对话框

  • 按需更新 :通过requestAutoScroll只在需要时滚动容器

  • 避免重复计算 :使用trackWidthMap缓存子组件宽度,减少重排

3. 可维护性建议

  • 类型安全 :全程使用TypeScript,定义清晰的接口(如DriveTypePlayState

  • 命名规范cnName属性统一中文显示名,便于UI国际化

  • 代码复用 :抽取useDriveLerpColor等composable,避免重复逻辑

4. 交互体验细节

  • 视觉反馈 :拖拽时brightness变化、添加dragging类名

  • 防误触e.button !== 0过滤非左键、e.stopPropagation()阻止冒泡

  • 智能滚动requestAutoScroll确保拖拽元素始终可见

以上技巧和架构设计不仅适用于3D编辑器,也可推广到任何复杂的交互式前端应用开发中。

相关推荐
咸鱼加辣3 小时前
【前端框架】路由配置
javascript·vue.js·前端框架
雪域迷影4 小时前
怎么将.ts文件转换成.js文件?
javascript·typescript·npm·tsc
narukeu4 小时前
聊下 rewriteRelativeImportExtensions 这个 TypeScript 配置项
前端·javascript·typescript
San30.4 小时前
从 0 到 1 打造 AI 冰球运动员:Coze 工作流与 Vue3 的深度实战
前端·vue.js·人工智能
安_4 小时前
为什么 Vue 要用 npm run dev 启动
前端·vue.js·npm
六便士的理想4 小时前
el-table实现滑窗列
前端·vue.js
武昌库里写JAVA4 小时前
Java设计模式-(创建型)抽象工厂模式
java·vue.js·spring boot·后端·sql
zew10409945886 小时前
PyCharm【2023.2.5】下使用编辑器自带的连接功能,连接MySQL数据库
数据库·mysql·pycharm·编辑器·连接mysql
Yaru1114 小时前
Vue 3.6 预览版特性
javascript·vue.js