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编辑器,也可推广到任何复杂的交互式前端应用开发中。

相关推荐
jonjia5 小时前
模块、脚本与声明文件
typescript
jonjia5 小时前
配置 TypeScript
typescript
jonjia5 小时前
TypeScript 工具函数开发
typescript
jonjia5 小时前
注解与断言
typescript
jonjia5 小时前
IDE 超能力
typescript
jonjia5 小时前
对象类型
typescript
jonjia5 小时前
快速搭建 TypeScript 开发环境
typescript
jonjia5 小时前
TypeScript 的奇怪之处
typescript
jonjia5 小时前
类型派生
typescript
jonjia5 小时前
开发流程中的 TypeScript
typescript