引言在三维数字城市应用中建筑可视化不仅是展示空间结构更是传递信息、增强沉浸感的重要手段。通过 WebGL 着色器技术我们可以为 3D 建筑模型赋予丰富的视觉效果例如根据高度变化的渐变色彩、动态扫描光效等。本文分享一个基于 Vue 3 和 Cesium 的实践项目实现了两个核心效果建筑高度渐变着色根据建筑高度从底部的颜色渐变为顶部的另一种颜色直观反映建筑高度分布。动态光圈扫描在渐变基础上叠加周期性移动的光圈模拟扫描或能量波的效果。同时项目集成了天地图影像和注记图层提供了简洁的 UI 控制面板允许用户实时调整渐变颜色并切换效果模式。本文将详细解析技术实现包括 Cesium 的初始化、3D Tiles 加载、自定义着色器编写以及 Vue 3 的响应式交互。技术栈前端框架Vue 3Composition API三维地球引擎Cesium 1.x底图服务天地图影像 注记着色器语言GLSLOpenGL Shading Language构建工具Vite效果预览项目运行后会加载一个城市级 3D 建筑模型3D Tiles默认显示普通影像底图。左上角控制面板包含两个颜色选择器底部色、顶部色和两个按钮“动态光圈扫描”与“清除效果”。渐变模式建筑颜色随高度线性插值用户可以自定义起始色和终止色。扫描模式在渐变的基础上一个明亮的光环从底部向顶部周期性移动产生扫描动效。核心实现详解1. Cesium 实例化与配置项目在onMounted中调用initCesium函数创建 Viewer。为了保持整洁禁用了大部分默认控件时间轴、导航、图层选择器等仅保留地球视图。同时设置了imageryProvider: false后续通过自定义方式添加天地图图层。const newViewer new Cesium.Viewer(cesiumContainer.value, { timeline: false, animation: false, homeButton: false, sceneModePicker: false, // ... 其他禁用 imageryProvider: false, shouldAnimate: true, // 启用动画为动态扫描做准备 });2. 天地图底图集成为了提供清晰的地理底图项目分别添加了天地图影像服务和注记服务。影像服务使用UrlTemplateImageryProvider拼接瓦片 URL注记服务类似。由于需要支持未来的颜色自定义如黑白化、单色滤镜我们定义了一个ColorizedImageryProvider类继承自UrlTemplateImageryProvider虽然当前版本未实现具体颜色处理但为扩展预留了接口。class ColorizedImageryProvider extends Cesium.UrlTemplateImageryProvider { constructor(options) { super(options); this.colorMode options.colorMode || normal; this.customColorRGB options.customColorRGB || { r: 58, g: 134, b: 255 }; } // 可在此添加自定义着色器逻辑对影像进行实时颜色变换 }影像图层和注记图层分别设置zIndex确保注记始终显示在影像之上。3. 加载 3D Tiles 建筑模型通过Cesium.Cesium3DTileset.fromUrl加载本地或网络的 tileset.json 文件。加载成功后将 tileset 添加到场景并调用getTilesetMaxHeight函数获取模型的最大高度。这一高度值对于后续着色器中的高度归一化至关重要。getTilesetMaxHeight通过解析 tileset 的根节点包围盒box、region 或 sphere估算模型最大高度避免硬编码。若获取失败则回退到默认值 155 米。javascriptconst maxBuildingHeight await getTilesetMaxHeight(tileset);4. 自定义着色器实现视觉效果Cesium 提供了CustomShaderAPI允许开发者直接在渲染管线中插入 GLSL 代码修改模型的材质。我们的两个效果均通过替换tileset.customShader实现。4.1 高度渐变着色器在onColorChange中颜色选择器变化时触发我们动态生成一个新的CustomShader。核心 GLSL 代码如下glslvoid fragmentMain(FragmentInput fsInput, inout czm_modelMaterial material) { vec3 positionMC fsInput.attributes.positionMC; float height positionMC.y; // 模型坐标系中的高度 float maxHeight ${maxBuildingHeight.toFixed(2)}; float heightRatio clamp(height / maxHeight, 0.0, 1.0); vec3 baseColor vec3(${baseColor.r}, ${baseColor.g}, ${baseColor.b}); vec3 gradientColor vec3(${gradientColor.r}, ${gradientColor.g}, ${gradientColor.b}); material.diffuse mix(baseColor, gradientColor, heightRatio); }positionMC是模型坐标系下的顶点坐标Y 轴向上表示高度。通过height / maxHeight得到归一化的高度比例然后使用mix函数线性插值两种颜色。颜色值从 Vue 组件的响应式变量中读取并在每次变化时重新生成着色器字符串。4.2 动态光圈扫描着色器按钮customShader2触发更复杂的着色器。它在渐变的基础上加入了两个动态效果亮度随高度周期性波动使用fract(czm_frameNumber / 360.0)产生一个随时间线性递增的相位结合正弦函数使亮度在高度方向上有波纹效果。移动光圈定义一个随时间移动的归一化高度阈值vtxf_a13当建筑某点的高度比例接近该阈值时增加亮度形成扫描光环。核心代码片段glsl// 亮度波纹 float vtxf_a11 fract(czm_frameNumber / 360.0) * 3.14159265 * 2.0; float vtxf_a12 vtxf_height / _heightRange sin(vtxf_a11) * 0.1; material.diffuse * vec3(vtxf_a12, vtxf_a12, vtxf_a12); // 移动光圈 float vtxf_a13 fract(czm_frameNumber / 360.0); float vtxf_h clamp(vtxf_height / _glowRange, 0.0, 1.0); vtxf_a13 abs(vtxf_a13 - 0.5) * 2.0; float vtxf_diff step(0.01, abs(vtxf_h - vtxf_a13)); material.diffuse material.diffuse * (1.0 - vtxf_diff);czm_frameNumber是 Cesium 内置的帧计数器用于驱动动画。_heightRange控制波纹的波长_glowRange定义光圈移动的总高度范围。step函数用来判断当前高度是否在光圈附近1.0 - vtxf_diff表示如果高度接近光圈阈值则额外叠加亮度。5. Vue 响应式交互用户通过颜色选择器或按钮改变效果时对应的响应式变量如modelColorHex、activeShader更新触发相应的函数重新生成着色器并赋给 tileset。这充分利用了 Vue 的响应式系统使得着色器效果与 UI 实时同步。javascriptconst onColorChange () { activeShader.value 1; // 生成渐变着色器并应用 };“清除效果”按钮则将tileset.customShader设为undefined恢复模型的原始纹理。6. 资源清理与内存管理在组件卸载时destroyCesium函数负责移除 tileset、销毁 Viewer 实例避免内存泄漏。关键点与优化思考高度估算的准确性getTilesetMaxHeight依赖 boundingVolume 的解析不同数据源可能结构不同需要增加容错。实际项目中可考虑预计算并存储在元数据中。着色器性能所有效果均在 GPU 中完成性能开销很小适合大规模城市模型。天地图 token代码中引用了TD_MAP_KEY实际使用时需要替换为自己的密钥。动态动画流畅性shouldAnimate必须设置为true否则czm_frameNumber不会递增动画将停滞。运行与体验项目基于 Vue 3 和 Vite 构建。运行前请确保将 3D Tiles 数据放置在/public/data/tileset.json路径下可替换为任意符合规范的 tileset申请天地图服务 token 并配置到config.js中启动开发服务器后即可在浏览器中体验交互效果。左上角的颜色选择器可实时调整渐变色点击“动态光圈扫描”按钮建筑表面将出现缓慢移动的光环视觉效果极具科技感。总结本文展示了一个结合 Vue 3 和 Cesium 的 3D 建筑可视化项目重点介绍了如何通过自定义着色器实现动态视觉增强。这种技术可广泛应用于智慧城市、数字孪生、灾害模拟等场景帮助用户更直观地理解空间数据。未来可以扩展的方向包括支持更多着色器效果如波纹扩散、热力图、夜间灯光等允许用户自定义扫描速度、光圈宽度等参数将颜色与业务数据如人口密度、楼龄关联实现数据驱动的可视化优化天地图颜色处理实现底图单色化或叠加分析图层通过本次实践我们深刻体会到 WebGL 着色器为三维可视化带来的无限可能。希望这篇文章能为您的 Cesium 开发提供一些灵感和实用参考。完整代码template div classapp-container div classtop-left-controls div classcolor-control-group div classcolor-row label classcolor-label渐变颜色:/label input typecolor v-modelmodelColorHex classcolor-input inputonColorChange / /div div classcolor-row label classcolor-label到/label input typecolor v-modelgradientColorHex classcolor-input inputonColorChange / /div /div div classliquid-btn-group button clickcustomShader2 :class[liquid-btn, activeShader 2 ? active : ] span动态光圈扫描/span /button button clickcustomShader3 :class[liquid-btn, activeShader 3 ? active : ] span清除效果/span /button /div /div div refcesiumContainer classcesium-container/div /div /template script setup import { ref, onMounted, onUnmounted } from vue; import { TD_MAP_KEY } from ../config.js; const cesiumContainer ref(null); const activeShader ref(0); const modelColorHex ref(#00aaff); const gradientColorHex ref(#ffffff); let tileset null; let customShader null; let maxBuildingHeight 150.0; let colorizedImageryProvider null; let satelliteLayer null; let labelLayer null; let currentViewer null; class ColorizedImageryProvider extends Cesium.UrlTemplateImageryProvider { constructor(options) { super(options); this._tilingScheme new Cesium.WebMercatorTilingScheme(); this.colorMode options.colorMode || normal; this.customColorRGB options.customColorRGB || { r: 58, g: 134, b: 255 }; } get tilingScheme() { return this._tilingScheme; } } const createLabelLayer (viewer) { const layer viewer.imageryLayers.addImageryProvider(new Cesium.UrlTemplateImageryProvider({ url: https://t{s}.tianditu.gov.cn/cia_w/wmts?SERVICEWMTSREQUESTGetTileVERSION1.0.0LAYERciaSTYLEdefaultTILEMATRIXSETwFORMATtilesTILECOL{x}TILEROW{y}TILEMATRIX{z}tk${TD_MAP_KEY}, subdomains: [0, 1, 2, 3, 4, 5, 6, 7], credit: 天地图, maximumLevel: 18, minimumLevel: 0, maximumScreenSpaceError: 0, disableDepthTestAgainstTerrain: true })); layer.zIndex 1; return layer; }; const refreshSatelliteLayer () { if (!currentViewer || !colorizedImageryProvider) return; const oldLayer satelliteLayer; if (oldLayer) { currentViewer.imageryLayers.remove(oldLayer); } satelliteLayer currentViewer.imageryLayers.addImageryProvider(colorizedImageryProvider); satelliteLayer.zIndex 0; if (labelLayer) { currentViewer.imageryLayers.raiseToTop(labelLayer); } }; const initMapLayers (viewer) { currentViewer viewer; colorizedImageryProvider new ColorizedImageryProvider({ url: https://t{s}.tianditu.gov.cn/img_w/wmts?SERVICEWMTSREQUESTGetTileVERSION1.0.0LAYERimgSTYLEdefaultTILEMATRIXSETwFORMATtilesTILECOL{x}TILEROW{y}TILEMATRIX{z}tk${TD_MAP_KEY}, subdomains: [0, 1, 2, 3, 4, 5, 6, 7], colorMode: normal, customColorRGB: { r: 58, g: 134, b: 255 }, credit: 天地图, maximumLevel: 18, minimumLevel: 0 }); satelliteLayer viewer.imageryLayers.addImageryProvider(colorizedImageryProvider); satelliteLayer.zIndex 0; labelLayer createLabelLayer(viewer); }; const hexToRgb (hex) { const result /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); return result ? { r: parseInt(result[1], 16) / 255, g: parseInt(result[2], 16) / 255, b: parseInt(result[3], 16) / 255 } : { r: 0, g: 0.67, b: 1 }; }; const getTilesetMaxHeight async (tileset) { try { await tileset.readyPromise; let maxHeight 75.0 80; const root tileset.root; if (root root.boundingVolume) { const boundingVolume root.boundingVolume; if (boundingVolume.box) { const centerZ boundingVolume.box[2]; const halfZ boundingVolume.box[11]; maxHeight centerZ halfZ; } else if (boundingVolume.region) { maxHeight boundingVolume.region[5]; } else if (boundingVolume.sphere) { const centerZ boundingVolume.sphere[2]; const radius boundingVolume.sphere[3]; maxHeight centerZ radius; } if (isNaN(maxHeight) || maxHeight 75.0) { maxHeight 155.0; } } maxHeight Math.max(maxHeight 5, 95.0); console.log(模型最大高度: ${maxHeight.toFixed(2)} 米); return maxHeight; } catch (error) { console.warn(获取模型最大高度失败使用默认值:, error); return 155.0; } }; const initCesium () { try { if (!cesiumContainer.value) { console.error(Cesium容器DOM元素不存在); return; } if (window.cesiumViewer) { window.cesiumViewer.destroy(); window.cesiumViewer null; } const newViewer new Cesium.Viewer(cesiumContainer.value, { timeline: false, animation: false, homeButton: false, sceneModePicker: false, navigationHelpButton: false, baseLayerPicker: false, infoBox: false, selectionIndicator: false, navigationInstructionsInitiallyVisible: false, fullscreenButton: false, geocoder: false, imageryProvider: false, shouldAnimate: true, }); window.cesiumViewer newViewer; initMapLayers(newViewer); load3DTiles(newViewer); } catch (error) { console.error(Cesium 初始化失败, error); } }; const load3DTiles async (viewer) { try { tileset await Cesium.Cesium3DTileset.fromUrl(/data/tileset.json); viewer.scene.primitives.add(tileset); maxBuildingHeight await getTilesetMaxHeight(tileset); viewer.zoomTo(tileset); } catch (error) { console.error(加载3DTiles失败, error); } }; const onColorChange () { activeShader.value 1; const baseColor hexToRgb(modelColorHex.value); const gradientColor hexToRgb(gradientColorHex.value); const shader new Cesium.CustomShader({ fragmentShaderText: void fragmentMain(FragmentInput fsInput, inout czm_modelMaterial material) { vec3 positionMC fsInput.attributes.positionMC; float height positionMC.y; float maxHeight ${maxBuildingHeight.toFixed(2)}; float heightRatio clamp(height / maxHeight, 0.0, 1.0); vec3 baseColor vec3(${baseColor.r.toFixed(3)}, ${baseColor.g.toFixed(3)}, ${baseColor.b.toFixed(3)}); vec3 gradientColor vec3(${gradientColor.r.toFixed(3)}, ${gradientColor.g.toFixed(3)}, ${gradientColor.b.toFixed(3)}); material.diffuse mix(baseColor, gradientColor, heightRatio); } }); tileset.customShader shader; customShader shader; }; const customShader2 () { activeShader.value 2; const baseColor hexToRgb(modelColorHex.value); const gradientColor hexToRgb(gradientColorHex.value); const baseHeight 0; const heightRange 60.0; const glowRange maxBuildingHeight - baseHeight; const shader new Cesium.CustomShader({ fragmentShaderText: void fragmentMain(FragmentInput fsInput, inout czm_modelMaterial material) { vec3 positionMC fsInput.attributes.positionMC; float height positionMC.y; float maxHeight ${maxBuildingHeight.toFixed(2)}; float heightRatio clamp(height / maxHeight, 0.0, 1.0); vec3 baseColor vec3(${baseColor.r.toFixed(3)}, ${baseColor.g.toFixed(3)}, ${baseColor.b.toFixed(3)}); vec3 gradientColor vec3(${gradientColor.r.toFixed(3)}, ${gradientColor.g.toFixed(3)}, ${gradientColor.b.toFixed(3)}); material.diffuse mix(baseColor, gradientColor, heightRatio); if (height ${baseHeight}.0) { float _baseHeight ${baseHeight}.0; float _heightRange ${heightRange}.0; float _glowRange ${glowRange.toFixed(2)}; float vtxf_height height - _baseHeight; float vtxf_a11 fract(czm_frameNumber / 360.0) * 3.14159265 * 2.0; float vtxf_a12 vtxf_height / _heightRange sin(vtxf_a11) * 0.1; material.diffuse * vec3(vtxf_a12, vtxf_a12, vtxf_a12); float vtxf_a13 fract(czm_frameNumber / 360.0); float vtxf_h clamp(vtxf_height / _glowRange, 0.0, 1.0); vtxf_a13 abs(vtxf_a13 - 0.5) * 2.0; float vtxf_diff step(0.01, abs(vtxf_h - vtxf_a13)); material.diffuse material.diffuse * (1.0 - vtxf_diff); } } }); tileset.customShader shader; customShader shader; }; const customShader3 () { activeShader.value 3; clearShader(); }; const clearShader () { if (tileset) { tileset.customShader undefined; } customShader null; }; const destroyCesium () { clearShader(); if (tileset window.cesiumViewer) { window.cesiumViewer.scene.primitives.remove(tileset); tileset null; } if (window.cesiumViewer) { window.cesiumViewer.destroy(); window.cesiumViewer null; } }; onMounted(() { if (cesiumContainer.value) { initCesium(); } }); onUnmounted(() { destroyCesium(); }); /script style scoped .app-container { position: relative; width: 100vw; height: 100vh; overflow: hidden; font-family: Segoe UI, Poppins, Roboto, sans-serif; } .cesium-container { width: 100vw; height: 100vh; overflow: hidden; } .top-left-controls { position: absolute; z-index: 1000; left: 20px; top: 20px; display: flex; flex-direction: column; gap: 12px; } .color-control-group { display: flex; gap: 16px; background: rgba(15, 25, 45, 0.4); backdrop-filter: blur(8px); padding: 8px 16px; border-radius: 60px; border: 1px solid rgba(255, 255, 255, 0.2); box-shadow: 0 8px 20px rgba(0, 0, 0, 0.15); } .color-row { display: flex; align-items: center; gap: 8px; } .color-label { font-size: 12px; color: rgba(255, 255, 255, 0.8); letter-spacing: 0.5px; } .color-input { width: 32px; height: 32px; border: 1px solid rgba(255, 255, 255, 0.3); border-radius: 50%; cursor: pointer; background: transparent; padding: 0; outline: none; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); transition: transform 0.2s ease; } .color-input::-webkit-color-swatch-wrapper { padding: 0; } .color-input::-webkit-color-swatch { border: none; border-radius: 50%; } .color-input:hover { transform: scale(1.05); } .liquid-btn-group { display: flex; gap: 16px; background: rgba(15, 25, 45, 0.3); backdrop-filter: blur(8px); padding: 8px 16px; border-radius: 60px; border: 1px solid rgba(255, 255, 255, 0.15); box-shadow: 0 8px 20px rgba(0, 0, 0, 0.1); } .liquid-btn { position: relative; padding: 8px 24px; font-size: 14px; font-weight: 500; letter-spacing: 0.5px; color: rgba(255, 255, 255, 0.9); background: transparent; border: none; border-radius: 40px; cursor: pointer; overflow: hidden; transition: all 0.35s cubic-bezier(0.2, 0.9, 0.4, 1.1); backdrop-filter: blur(4px); font-family: inherit; } .liquid-btn span { position: relative; z-index: 2; } .liquid-btn::before { content: ; position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: linear-gradient(120deg, rgba(64, 128, 255, 0.2) 0%, rgba(100, 180, 255, 0.3) 30%, rgba(64, 128, 255, 0.2) 60%, rgba(40, 100, 210, 0.2) 100%); border-radius: 40px; opacity: 0.6; transition: opacity 0.4s ease; z-index: 1; } .liquid-btn:hover::before { animation: liquidFlow 1.2s ease-in-out infinite; background: linear-gradient(120deg, rgba(80, 150, 255, 0.4) 0%, rgba(120, 200, 255, 0.5) 30%, rgba(80, 150, 255, 0.4) 60%, rgba(60, 120, 230, 0.3) 100%); background-size: 200% 100%; } keyframes liquidFlow { 0% { background-position: 0% 50%; opacity: 0.5; } 50% { background-position: 100% 50%; opacity: 0.8; } 100% { background-position: 0% 50%; opacity: 0.5; } } .liquid-btn.active { color: white; background: rgba(64, 128, 255, 0.3); box-shadow: 0 0 12px rgba(64, 128, 255, 0.4), inset 0 1px 1px rgba(255, 255, 255, 0.2); border: 1px solid rgba(64, 128, 255, 0.6); } .liquid-btn.active::before { background: linear-gradient(120deg, rgba(64, 128, 255, 0.5) 0%, rgba(100, 180, 255, 0.7) 30%, rgba(64, 128, 255, 0.5) 60%, rgba(40, 100, 210, 0.4) 100%); opacity: 0.9; animation: liquidFlow 1.8s ease-in-out infinite; } .liquid-btn:not(.active):hover { background: rgba(64, 128, 255, 0.2); box-shadow: 0 6px 16px rgba(0, 0, 0, 0.2); } .liquid-btn:focus-visible { outline: none; box-shadow: 0 0 0 2px rgba(64, 128, 255, 0.5), 0 0 0 4px rgba(0, 0, 0, 0.2); } /style