Three.js 与 ECharts-GL 的 WebGL 上下文管理最佳实践

lishihuan大约 9 分钟

Three.js 与 ECharts-GL 的 WebGL 上下文管理最佳实践

📋 目录


🔍 问题概述

问题现象

在使用 Three.js 创建 3D 模型场景的同时,使用 ECharts-GL 创建 3D 图表时,容易出现以下问题:

  1. 模型"消失"或"崩溃":Three.js 渲染的 3D 模型突然不显示
  2. 控制台警告WARNING: Too many active WebGL contexts. Oldest context will be lost.
  3. 上下文丢失错误THREE.WebGLRenderer: Context Lost.
  4. 性能下降:页面卡顿,内存占用持续增长

问题场景

  • ✅ Three.js 渲染 3D 场景(如 BIM 模型、3D 可视化)
  • ✅ ECharts-GL 渲染 3D 图表(如 3D 饼图、3D 柱状图)
  • ✅ 频繁的数据更新(如 WebSocket 推送、定时刷新)
  • ✅ 多个图表实例同时存在

🔬 问题原因

1. WebGL 上下文数量限制

浏览器对同时存在的 WebGL 上下文数量有严格限制:

  • Chrome/Edge: 通常限制为 16 个
  • Firefox: 通常限制为 16 个
  • Safari: 通常限制为 8 个

当超过限制时,浏览器会自动回收最旧的 WebGL 上下文,导致对应的渲染失效。

2. 重复创建上下文

错误做法

// ❌ 每次更新都创建新的图表实例
function updateChart(data) {
  echarts.dispose(document.getElementById('chart'));
  let chart = echarts.init(document.getElementById('chart')); // 创建新的 WebGL 上下文
  chart.setOption(option);
}

问题

  • 每次调用 echarts.init() 都会创建新的 WebGL 上下文
  • echarts.dispose() 可能不会立即释放上下文
  • 频繁更新导致上下文数量快速累积

3. 资源未正确释放

常见问题

  • 组件销毁时未清理图表实例
  • Three.js 渲染器未正确 dispose
  • 纹理、几何体等资源未释放

✅ 解决方案

方案一:图表实例复用(推荐)

核心思想:复用图表实例,只更新数据,不重新创建。

// ✅ 保存图表实例
let chartInstance = null;

function initChart(data) {
  const chartDom = document.getElementById('chart');
  if (!chartDom) return;
  
  // 如果实例已存在且未销毁,直接更新数据
  if (chartInstance && !chartInstance.isDisposed()) {
    chartInstance.setOption(getOption(data), { notMerge: true });
    return;
  }
  
  // 只有在实例不存在时才创建新的
  if (chartInstance) {
    chartInstance.dispose();
    chartInstance = null;
  }
  
  chartInstance = echarts.init(chartDom);
  chartInstance.setOption(getOption(data));
}

// 组件卸载时清理
onBeforeUnmount(() => {
  if (chartInstance) {
    chartInstance.dispose();
    chartInstance = null;
  }
});

方案二:Three.js 渲染器保护

核心思想:防止 Three.js 渲染器被重复创建,监听上下文丢失事件。

// ✅ 防止重复初始化
function initBim() {
  // 如果渲染器已存在,先清理
  if (renderer) {
    if (rafId) {
      cancelAnimationFrame(rafId);
      rafId = null;
    }
    renderer.dispose();
    if (renderer.domElement && renderer.domElement.parentNode) {
      renderer.domElement.parentNode.removeChild(renderer.domElement);
    }
    renderer = null;
  }
  
  // 创建新的渲染器
  renderer = new THREE.WebGLRenderer({
    antialias: false,
    preserveDrawingBuffer: false, // 减少内存占用
    failIfMajorPerformanceCaveat: false
  });
  
  // 监听上下文丢失事件
  renderer.domElement.addEventListener('webglcontextlost', function(event) {
    console.error('WebGL 上下文丢失!');
    event.preventDefault(); // 阻止默认行为,尝试恢复
    if (rafId) {
      cancelAnimationFrame(rafId);
      rafId = null;
    }
  }, false);
  
  // 监听上下文恢复事件
  renderer.domElement.addEventListener('webglcontextrestored', function() {
    console.log('WebGL 上下文已恢复,重新初始化场景...');
    setTimeout(() => {
      initBim();
    }, 100);
  }, false);
}

方案三:渲染循环保护

核心思想:在渲染循环中检查上下文状态,避免在上下文丢失时继续渲染。

function renderScene() {
  // 检查渲染器和场景是否有效
  if (!renderer || !scene || !camera) {
    rafId = requestAnimationFrame(renderScene);
    return;
  }
  
  // 检查 WebGL 上下文是否丢失
  try {
    const gl = renderer.getContext();
    if (gl && gl.isContextLost && gl.isContextLost()) {
      console.error('WebGL 上下文已丢失,停止渲染');
      if (rafId) {
        cancelAnimationFrame(rafId);
        rafId = null;
      }
      return;
    }
  } catch (e) {
    console.error('检查 WebGL 上下文时出错:', e);
    if (rafId) {
      cancelAnimationFrame(rafId);
      rafId = null;
    }
    return;
  }
  
  // 执行渲染
  try {
    if (composer) {
      composer.render();
    } else {
      renderer.render(scene, camera);
    }
  } catch (e) {
    console.error('渲染时出错:', e);
    if (rafId) {
      cancelAnimationFrame(rafId);
      rafId = null;
    }
    return;
  }
  
  rafId = requestAnimationFrame(renderScene);
}

🎯 最佳实践

1. 图表实例管理

✅ 推荐做法

// 1. 使用变量保存实例
let chartInstances = {
  chart1: null,
  chart2: null
};

// 2. 创建图表时检查实例是否存在
function initChart(chartId, data) {
  const chartDom = document.getElementById(chartId);
  if (!chartDom) return;
  
  let chartInstance = chartInstances[chartId];
  
  // 如果实例存在且未销毁,直接更新
  if (chartInstance && !chartInstance.isDisposed()) {
    chartInstance.setOption(getOption(data), { notMerge: true });
    return;
  }
  
  // 清理旧实例
  if (chartInstance) {
    chartInstance.dispose();
  }
  
  // 创建新实例
  chartInstances[chartId] = echarts.init(chartDom);
  chartInstances[chartId].setOption(getOption(data));
}

// 3. 组件卸载时清理所有实例
onBeforeUnmount(() => {
  Object.keys(chartInstances).forEach(key => {
    if (chartInstances[key]) {
      try {
        chartInstances[key].dispose();
      } catch (e) {
        console.warn(`清理图表实例 ${key} 时出错:`, e);
      }
      chartInstances[key] = null;
    }
  });
});

❌ 避免的做法

// ❌ 每次都创建新实例
function updateChart(data) {
  echarts.dispose(document.getElementById('chart'));
  let chart = echarts.init(document.getElementById('chart'));
  chart.setOption(option);
}

// ❌ 不保存实例引用
function initChart(data) {
  let chart = echarts.init(document.getElementById('chart'));
  chart.setOption(option);
  // 实例丢失,无法复用
}

2. Three.js 渲染器管理

✅ 推荐做法

// 1. 全局变量保存渲染器
let renderer = null;
let scene = null;
let camera = null;
let rafId = null;

// 2. 初始化时检查是否已存在
function initBim() {
  // 防止重复初始化
  if (renderer) {
    console.warn('渲染器已存在,跳过初始化');
    return;
  }
  
  // 创建场景
  scene = new THREE.Scene();
  
  // 创建相机
  camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
  
  // 创建渲染器
  renderer = new THREE.WebGLRenderer({
    antialias: false,
    preserveDrawingBuffer: false,
    powerPreference: "high-performance"
  });
  
  // 监听上下文事件
  setupWebGLContextListeners();
  
  // 开始渲染循环
  renderScene();
}

// 3. 设置上下文监听
function setupWebGLContextListeners() {
  renderer.domElement.addEventListener('webglcontextlost', handleContextLost, false);
  renderer.domElement.addEventListener('webglcontextrestored', handleContextRestored, false);
}

function handleContextLost(event) {
  event.preventDefault();
  console.error('WebGL 上下文丢失!');
  if (rafId) {
    cancelAnimationFrame(rafId);
    rafId = null;
  }
}

function handleContextRestored() {
  console.log('WebGL 上下文已恢复');
  // 重新初始化场景
  setTimeout(() => {
    initBim();
  }, 100);
}

// 4. 清理资源
function disposeBim() {
  // 停止渲染循环
  if (rafId) {
    cancelAnimationFrame(rafId);
    rafId = null;
  }
  
  // 清理场景
  if (scene) {
    scene.traverse(function(object) {
      if (object.geometry) object.geometry.dispose();
      if (object.material) {
        if (Array.isArray(object.material)) {
          object.material.forEach(material => {
            if (material.map) material.map.dispose();
            material.dispose();
          });
        } else {
          if (object.material.map) object.material.map.dispose();
          object.material.dispose();
        }
      }
    });
  }
  
  // 清理渲染器
  if (renderer) {
    renderer.dispose();
    if (renderer.domElement && renderer.domElement.parentNode) {
      renderer.domElement.parentNode.removeChild(renderer.domElement);
    }
    renderer = null;
  }
}

3. 防抖和节流

频繁更新时使用防抖/节流

// 防抖:延迟执行,只执行最后一次
let updateTimer = null;
function debouncedUpdateChart(data) {
  if (updateTimer) {
    clearTimeout(updateTimer);
  }
  updateTimer = setTimeout(() => {
    updateChart(data);
    updateTimer = null;
  }, 300);
}

// 节流:限制执行频率
let lastUpdateTime = 0;
const UPDATE_INTERVAL = 500; // 500ms
function throttledUpdateChart(data) {
  const now = Date.now();
  if (now - lastUpdateTime >= UPDATE_INTERVAL) {
    updateChart(data);
    lastUpdateTime = now;
  }
}

💻 代码示例

完整示例:Vue 3 + Three.js + ECharts-GL

<template>
  <div>
    <!-- Three.js 3D 场景 -->
    <div id="canvasBox"></div>
    
    <!-- ECharts-GL 图表 -->
    <div id="chart1"></div>
    <div id="chart2"></div>
  </div>
</template>

<script>
import { onMounted, onBeforeUnmount } from 'vue';
import * as THREE from 'three';
import * as echarts from 'echarts';
import 'echarts-gl';

export default {
  name: 'ThreeEChartsDemo',
  setup() {
    // Three.js 相关变量
    let renderer = null;
    let scene = null;
    let camera = null;
    let rafId = null;
    
    // ECharts 实例
    let chartInstances = {
      chart1: null,
      chart2: null
    };
    
    // 初始化 Three.js
    function initThree() {
      const canvasBox = document.getElementById('canvasBox');
      if (!canvasBox) return;
      
      // 防止重复初始化
      if (renderer) {
        console.warn('Three.js 渲染器已存在');
        return;
      }
      
      // 创建场景
      scene = new THREE.Scene();
      camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);
      
      // 创建渲染器
      renderer = new THREE.WebGLRenderer({
        antialias: false,
        preserveDrawingBuffer: false
      });
      renderer.setSize(canvasBox.clientWidth, canvasBox.clientHeight);
      canvasBox.appendChild(renderer.domElement);
      
      // 监听上下文事件
      renderer.domElement.addEventListener('webglcontextlost', (event) => {
        event.preventDefault();
        console.error('Three.js WebGL 上下文丢失!');
        if (rafId) {
          cancelAnimationFrame(rafId);
          rafId = null;
        }
      });
      
      renderer.domElement.addEventListener('webglcontextrestored', () => {
        console.log('Three.js WebGL 上下文已恢复');
        setTimeout(() => initThree(), 100);
      });
      
      // 添加测试对象
      const geometry = new THREE.BoxGeometry();
      const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 });
      const cube = new THREE.Mesh(geometry, material);
      scene.add(cube);
      camera.position.z = 5;
      
      // 开始渲染
      renderScene();
    }
    
    // 渲染循环
    function renderScene() {
      if (!renderer || !scene || !camera) {
        rafId = requestAnimationFrame(renderScene);
        return;
      }
      
      // 检查上下文
      try {
        const gl = renderer.getContext();
        if (gl && gl.isContextLost && gl.isContextLost()) {
          console.error('WebGL 上下文已丢失');
          if (rafId) {
            cancelAnimationFrame(rafId);
            rafId = null;
          }
          return;
        }
      } catch (e) {
        console.error('检查上下文时出错:', e);
        return;
      }
      
      try {
        renderer.render(scene, camera);
      } catch (e) {
        console.error('渲染时出错:', e);
        return;
      }
      
      rafId = requestAnimationFrame(renderScene);
    }
    
    // 初始化 ECharts 图表
    function initChart(chartId, data) {
      const chartDom = document.getElementById(chartId);
      if (!chartDom) return;
      
      let chartInstance = chartInstances[chartId];
      
      // 如果实例存在且未销毁,直接更新
      if (chartInstance && !chartInstance.isDisposed()) {
        chartInstance.setOption(getChartOption(data), { notMerge: true });
        return;
      }
      
      // 清理旧实例
      if (chartInstance) {
        try {
          chartInstance.dispose();
        } catch (e) {
          console.warn(`清理图表 ${chartId} 时出错:`, e);
        }
      }
      
      // 创建新实例
      chartInstances[chartId] = echarts.init(chartDom);
      chartInstances[chartId].setOption(getChartOption(data));
    }
    
    // 获取图表配置
    function getChartOption(data) {
      return {
        // ECharts-GL 3D 图表配置
        // ...
      };
    }
    
    // 更新图表数据
    function updateChart(chartId, data) {
      initChart(chartId, data);
    }
    
    // 清理 Three.js 资源
    function disposeThree() {
      if (rafId) {
        cancelAnimationFrame(rafId);
        rafId = null;
      }
      
      if (scene) {
        scene.traverse(function(object) {
          if (object.geometry) object.geometry.dispose();
          if (object.material) {
            if (Array.isArray(object.material)) {
              object.material.forEach(m => m.dispose());
            } else {
              object.material.dispose();
            }
          }
        });
      }
      
      if (renderer) {
        renderer.dispose();
        if (renderer.domElement && renderer.domElement.parentNode) {
          renderer.domElement.parentNode.removeChild(renderer.domElement);
        }
        renderer = null;
      }
    }
    
    // 清理 ECharts 资源
    function disposeCharts() {
      Object.keys(chartInstances).forEach(key => {
        if (chartInstances[key]) {
          try {
            chartInstances[key].dispose();
          } catch (e) {
            console.warn(`清理图表 ${key} 时出错:`, e);
          }
          chartInstances[key] = null;
        }
      });
    }
    
    // 生命周期
    onMounted(() => {
      initThree();
      initChart('chart1', { /* 数据 */ });
      initChart('chart2', { /* 数据 */ });
    });
    
    onBeforeUnmount(() => {
      disposeThree();
      disposeCharts();
    });
    
    return {
      updateChart
    };
  }
};
</script>

❓ 常见问题

Q1: 为什么图表更新后 Three.js 模型消失了?

A: 可能是因为每次更新都创建了新的 ECharts 实例,导致 WebGL 上下文数量超过限制,浏览器回收了 Three.js 的上下文。

解决方案:复用图表实例,只更新数据不重新创建。

Q2: 如何检查当前有多少个 WebGL 上下文?

A: 可以在浏览器控制台执行:

// 检查所有 canvas 元素
document.querySelectorAll('canvas').forEach((canvas, index) => {
  const gl = canvas.getContext('webgl') || canvas.getContext('webgl2');
  if (gl) {
    console.log(`Canvas ${index}:`, canvas, gl);
  }
});

Q3: echarts.dispose() 后上下文会立即释放吗?

A: 不一定。dispose() 会标记资源为可回收,但实际释放可能由浏览器垃圾回收机制决定。建议:

  • 保存实例引用,避免重复创建
  • 在组件卸载时统一清理
  • 使用 isDisposed() 检查实例状态

Q4: 如何调试 WebGL 上下文丢失问题?

A:

  1. 监听上下文丢失事件
  2. 记录上下文创建和销毁的时机
  3. 使用 Chrome DevTools 的 Performance 面板监控内存
  4. 检查控制台的警告信息
// 全局监听所有 canvas 的上下文事件
document.querySelectorAll('canvas').forEach(canvas => {
  canvas.addEventListener('webglcontextlost', (e) => {
    console.error('WebGL 上下文丢失:', canvas);
    e.preventDefault();
  });
  canvas.addEventListener('webglcontextrestored', () => {
    console.log('WebGL 上下文恢复:', canvas);
  });
});

🚀 性能优化建议

1. 减少 WebGL 上下文数量

  • ✅ 复用图表实例
  • ✅ 合并多个图表到一个实例(如果可能)
  • ✅ 使用 2D 图表替代 3D 图表(如果不需要 3D 效果)

2. 优化 Three.js 渲染

// 限制像素比
const pixelRatio = Math.min(window.devicePixelRatio, 2);
renderer.setPixelRatio(pixelRatio);

// 关闭不必要的特性
renderer = new THREE.WebGLRenderer({
  antialias: false, // 关闭抗锯齿
  preserveDrawingBuffer: false, // 不保留绘制缓冲区
  powerPreference: "high-performance" // 优先性能
});

// 页面隐藏时暂停渲染
document.addEventListener('visibilitychange', () => {
  if (document.hidden) {
    if (rafId) {
      cancelAnimationFrame(rafId);
      rafId = null;
    }
  } else {
    renderScene();
  }
});

3. 优化 ECharts 渲染

// 使用 notMerge 选项,避免配置合并
chart.setOption(option, { notMerge: true });

// 关闭动画(如果不需要)
chart.setOption({
  animation: false
});

// 使用 canvas 渲染器(如果不需要 WebGL)
const chart = echarts.init(dom, null, {
  renderer: 'canvas' // 使用 canvas 而不是 webgl
});

4. 内存管理

// 及时清理未使用的资源
function cleanupResources() {
  // 清理纹理
  textures.forEach(texture => texture.dispose());
  textures = [];
  
  // 清理几何体
  geometries.forEach(geometry => geometry.dispose());
  geometries = [];
  
  // 清理材质
  materials.forEach(material => material.dispose());
  materials = [];
}

📚 参考资源


📝 总结

核心原则

  1. 复用实例:图表和渲染器实例应该复用,而不是频繁创建和销毁
  2. 及时清理:组件卸载时必须清理所有资源
  3. 监听事件:监听 WebGL 上下文丢失和恢复事件
  4. 状态检查:在关键操作前检查上下文状态
  5. 错误处理:添加完善的错误处理和日志记录

检查清单

在开发 Three.js + ECharts-GL 项目时,请检查: