第一次看到antv L7那个酷炫的城市扫光效果时,我就被这种动态视觉表现吸引了。光波从城市中心向外扩散,掠过的高楼和地面会呈现渐变高亮,这种效果不仅美观,还能直观展示数据变化。后来在实际项目中,我发现这套技术完全可以迁移到热力图场景,特别是需要展示数据密度和扩散趋势的场景。
传统热力图通常用颜色深浅表示数据密度,但缺乏动态交互。而基于shader实现的扩散效果,能模拟热量从中心向外辐射的过程,用户一眼就能看出"热点区域"和"影响范围"。比如在疫情地图中,可以用扩散圈表示病毒传播范围;在商业分析中,可以展示门店客流的辐射半径。
核心原理其实很简单:在片元着色器中计算当前像素到热源中心的距离,根据距离值进行颜色插值。这个思路和城市扫光的实现如出一辙,只是把"光波"变成了"热力扩散"。不过要做出真实的热力图效果,还需要解决几个关键问题:如何控制扩散强度衰减曲线?如何实现多热源叠加?怎样让交互更自然?
热力图的核心是距离衰减函数。在片元着色器中,我们首先计算当前片元到热源中心的距离:
glsl复制float dis = length(v_position - center);
这个dis值就是热力衰减的基础。接下来需要设计一个衰减函数,通常我会用指数衰减或者高斯衰减。比如指数衰减可以这样实现:
glsl复制float intensity = exp(-dis * dis / (2.0 * radius * radius));
这里radius控制热力影响范围,dis越大intensity越小,形成自然的衰减效果。实际使用时,我更喜欢用平滑步长函数(smoothstep)来做过渡:
glsl复制float intensity = 1.0 - smoothstep(0.0, radius, dis);
这样能在边缘产生更柔和的过渡,避免出现明显的锯齿。实测下来,这种效果最适合表现热力扩散。
真实场景往往需要同时显示多个热源。在shader中处理这个需求,我的做法是:
glsl复制uniform vec3 centers[10]; // 最多支持10个热源
uniform float strengths[10];
float totalIntensity = 0.0;
for(int i=0; i<10; i++){
float dis = length(v_position - centers[i]);
totalIntensity += strengths[i] * exp(-dis*dis/(2.0*radius*radius));
}
这里要注意两点:一是GLSL数组长度必须明确,所以需要预设最大热源数;二是强度累加可能导致值过大,最后需要做归一化处理。
让热力图能响应鼠标交互,可以大大提升用户体验。实现步骤是:
javascript复制renderer.domElement.addEventListener('mousemove', (event) => {
// 获取标准化设备坐标
const x = (event.clientX / window.innerWidth) * 2 - 1;
const y = -(event.clientY / window.innerHeight) * 2 + 1;
// 转换为世界坐标
const mouse = new THREE.Vector3(x, y, 0.5);
mouse.unproject(camera);
// 更新shader
material.uniforms.center.value.copy(mouse);
});
我通常会加一个衰减动画,让热源移动时产生拖尾效果。做法是在animate函数中让热源强度随时间衰减:
javascript复制function animate() {
requestAnimationFrame(animate);
material.uniforms.strength.value *= 0.95; // 每帧衰减5%
renderer.render(scene, camera);
}
在实际项目中,热力强度往往需要绑定真实数据。比如展示人口密度,可以将每个区域的人口数映射到热力强度。我的经验做法是:
javascript复制fetch('data.geojson')
.then(response => response.json())
.then(data => {
const centers = [];
const strengths = [];
data.features.forEach(feature => {
const center = getFeatureCenter(feature);
const strength = feature.properties.population / 1000; // 标准化
centers.push(new THREE.Vector3(center.x, center.y, 0));
strengths.push(strength);
});
material.uniforms.centers.value = centers;
material.uniforms.strengths.value = strengths;
});
这样就能实现数据驱动的热力图效果,当数据更新时只需要重新计算strengths数组即可。
当热源数量很多时(比如超过100个),直接在片元着色器中遍历计算会严重影响性能。经过多次实践,我总结出几个优化方案:
这里分享一个简单的LOD实现:
javascript复制function updateLOD() {
const zoomLevel = camera.position.z;
let detailLevel;
if(zoomLevel > 1000) detailLevel = 'low';
else if(zoomLevel > 500) detailLevel = 'medium';
else detailLevel = 'high';
material.uniforms.detailLevel.value = detailLevel;
}
在shader中根据detailLevel调整计算精度:
glsl复制if(detailLevel == 'high') {
// 完整计算
} else if(detailLevel == 'medium') {
// 简化计算,如减少采样点
} else {
// 最低精度,可能只显示热源中心
}
着色器性能对热力图流畅度影响很大。几个关键优化点:
比如将指数衰减改为线性衰减:
glsl复制// 优化前
float intensity = exp(-dis*dis/(2.0*radius*radius));
// 优化后
float intensity = max(0.0, 1.0 - dis/radius);
虽然视觉效果略有差异,但在移动设备上性能提升明显。另一个技巧是使用距离平方代替实际距离,避免开方计算:
glsl复制float disSq = dot(v_position - center, v_position - center);
float intensity = max(0.0, 1.0 - disSq/(radius*radius));
基础热力图是2D效果,但在某些场景下需要3D热力扩散,比如展示建筑内的人群分布。实现思路是:
3D距离计算只需修改dis的计算方式:
glsl复制float dis = length(vec3(v_position.x, v_position.y, height) - center);
可以添加高度衰减系数:
glsl复制float verticalAtten = 1.0 - smoothstep(0.0, maxHeight, abs(height - center.z));
float intensity = verticalAtten * exp(-dis*dis/(2.0*radius*radius));
为了让3D效果更明显,我通常会添加雾效:
glsl复制float fogFactor = smoothstep(fogNear, fogFar, dis);
gl_FragColor = mix(heatColor, fogColor, fogFactor);
在智慧园区项目中,这套3D热力扩散方案成功展示了不同楼层的人员密度分布,客户反馈非常直观。
初期实现的热力图在边缘经常出现明显锯齿,特别是当热源移动时。解决方案是:
我推荐在渲染器初始化时开启MSAA:
javascript复制const renderer = new THREE.WebGLRenderer({
antialias: true,
powerPreference: "high-performance"
});
同时在着色器中添加平滑处理:
glsl复制float intensity = smoothstep(0.0, radius*0.1, radius - dis);
在低端移动设备上,热力图可能出现卡顿。经过多次测试,我总结出移动端优化方案:
可以通过检测设备类型自动调整设置:
javascript复制const isMobile = /Mobi|Android/i.test(navigator.userAgent);
if(isMobile) {
renderer.setPixelRatio(window.devicePixelRatio * 0.5);
material.uniforms.maxSources.value = 5; // 移动端只显示5个主要热源
}
下面是一个简化但完整的热力图实现代码,包含核心功能:
html复制<!DOCTYPE html>
<html>
<head>
<title>Three.js热力图</title>
<style>body { margin: 0; }</style>
<script src="three.min.js"></script>
</head>
<body>
<script>
// 初始化场景
const scene = new THREE.Scene();
const camera = new THREE.PerspectiveCamera(75, window.innerWidth/window.innerHeight, 0.1, 1000);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
// 创建热力图平面
const geometry = new THREE.PlaneGeometry(10, 10, 100, 100);
const material = new THREE.ShaderMaterial({
uniforms: {
center: { value: new THREE.Vector3(0, 0, 0) },
radius: { value: 2.0 },
color: { value: new THREE.Color(1, 0, 0) }
},
vertexShader: `
varying vec2 vUv;
varying vec3 vPosition;
void main() {
vUv = uv;
vPosition = position;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform vec3 center;
uniform float radius;
uniform vec3 color;
varying vec3 vPosition;
void main() {
float dis = distance(vPosition.xy, center.xy);
float intensity = exp(-dis*dis/(2.0*radius*radius));
gl_FragColor = vec4(color, intensity);
}
`,
transparent: true
});
const plane = new THREE.Mesh(geometry, material);
scene.add(plane);
camera.position.z = 5;
// 鼠标交互
renderer.domElement.addEventListener('mousemove', (event) => {
const mouse = new THREE.Vector3(
(event.clientX / window.innerWidth) * 2 - 1,
-(event.clientY / window.innerHeight) * 2 + 1,
0.5
);
mouse.unproject(camera);
material.uniforms.center.value.copy(mouse);
});
// 动画循环
function animate() {
requestAnimationFrame(animate);
renderer.render(scene, camera);
}
animate();
</script>
</body>
</html>
这个示例包含了热力图的核心实现,可以直接运行查看效果。实际项目中,我会在此基础上添加更多功能,比如多热源支持、数据绑定、性能优化等。