泛光一般用于增强图像或场景的亮度和光线效果,如灯光效果增强,绘制发光的边界线等。在一些游戏引擎以及cesium中泛光是作为一种后处理技术实现的。后处理技术简单理解就是整个画布渲染完成后的二次加工。
本篇将介绍mapbox结合three.js实现泛光效果,先看看最终效果:
泛光的处理流程通常分为以下几步:
- 设置亮度阈值。根据阈值提取发光区域
- 模糊发光区域。通过高斯模糊对发光区域处理,为了使效果更加自然,通常会根据不同半径,做多次模糊
- 将模糊后的纹理,叠加到原始图像,完成效果混合
mapbox中并没有直接提供泛光的能力,但其CustomLayerInterface提供了非常灵活的扩展能力,使得mapbox加载three.js成为可能。
three.js官方参照虚幻引擎(Unreal Engine)提供了UnrealBloom后处理器,官方示例:threejs.org/examples/?q...
本篇就是将两者结合起来,在mapbox中扩展泛光的能力
首先我们实现一个发光线效果,先思考下我们即将面临的问题:
- three.js的线只有1像素,而我们需要绘制带宽度的线
- three.js的相机如何与mapbox相机同步
- 如何按照经纬度的方式绘制three.js的线
绘制宽度的线我找到了three.js官方提供的另一个插件:Line2
相机同步及坐标转换,感谢伟大的开源社区,我找到了threebox, threebox初始版本开起来已不再维护,可以关注这个fork版本: github.com/jscastro76/...
在mapbox中添加一条three.js线
添加一个自定义图层:
lua
map.addLayer({
id: 'custom_layer',
type: 'custom',
onAdd: function (map, gl) {
},
render: function (gl, matrix) {
},
});
为方便调试,以及避免与mapbox纹理混合时出现的问题,我们先降低难度,使用独立的canvas作为three.js的容器,将three.js的容器尺寸设置与地图容器完全一样,并完美覆盖到地图容器之上。
ini
onAdd: function (map, gl) {
container = map.getCanvas();
const w = container.clientWidth;
const h = container.clientHeight;
const mapContainer = map.getContainer();
let bloomContainer = mapContainer.querySelector('#_THREE_EFFECTS_CONTAINER_');
if (!bloomContainer) {
bloomContainer = document.createElement('canvas');
bloomContainer.id = '_THREE_EFFECTS_CONTAINER_';
bloomContainer.style.position = 'absolute';
bloomContainer.style.zIndex = '99999';
bloomContainer.style.pointerEvents = 'none';
bloomContainer.style.width = '100%';
bloomContainer.style.height = '100%';
bloomContainer.width = w;
bloomContainer.height = h;
mapContainer.appendChild(bloomContainer);
}
}
在onAdd方法中初始化three.js渲染器及相机:
ini
renderer = new THREE.WebGLRenderer({
alpha: true,
antialias: true,
canvas: bloomContainer,
});
renderer.setPixelRatio(window.devicePixelRatio);
renderer.autoClear = false;
camera = new THREE.PerspectiveCamera(map.transform.fov, w / h, 0.1, 1e21);
这里需要注意的是要开启alpha通道以便于透明度的设置,并且关闭autoClear。PerspectiveCamera的后两个参数,即near和far的设置,尤其要注意,near可以设置接近无限小,但不能为0,far可以很大,但不能是infinity,否则会影响到事件的处理。事件处理将在后面段落介绍。
另外需要设置清除颜色的透明度,否则three.js场景中的背景会覆盖地图,这个问题可以说是全篇最难的一个点,后面会介绍。
ini
renderer.setClearAlpha(0.0);
接下来是相机同步:
arduino
new CameraSync(map, camera, group);
相机同步非常重要,它保证了鼠标交互时three.js相机与mapbox相机观察的范围一致。threebox提供了CameraSync方法,同时提供了坐标转换的工具方法,非常重要,但本篇不作为重点展开,感兴趣的同学可以看下threebox源码。
接下来我们创建three.js线:输入经纬度点位,及自定义样式生成对应的mesh
ini
function createLine2(obj) {
// Geometry
var straightProject = utils.lnglatsToWorld(obj.geometry);
var normalized = utils.normalizeVertices(straightProject);
var flattenedArray = utils.flattenVectors(normalized.vertices);
var geometry = new LineGeometry();
geometry.setPositions(flattenedArray);
// Material
let matLine = new LineMaterial({
color: obj.color,
linewidth: obj.width,
dashed: false,
opacity: obj.opacity,
});
matLine.resolution.set(obj.containerWidth, obj.containerHeight);
matLine.isMaterial = true;
matLine.transparent = true;
matLine.depthWrite = false;
// Mesh
let line = new Line2(geometry, matLine);
line.position.copy(normalized.position);
return line;
}
其中重要的两步是lnglatsToWorld将经纬度坐标转换成three.js的世界坐标,以及normalized归一化得到mesh的position信息
line2的写法可以参考three.js官方示例:threejs.org/examples/?q...
将线条添加到场景
less
line = createLine2({
color: 0x00bfff,
width: 4,
opacity: 1,
containerWidth: w,
containerHeight: h,
});
group.add(line);
至此,我们成功的实现了第一个目标:传入经纬度坐标的数组及样式,生成three.js的线,并将three.js线绘制到mapbox上
实现three.js线的泛光效果
这里建议大家先了解下three.js泛光的基本用法:threejs.org/examples/?q...
通过EffectComposer设置渲染通道:
ini
const renderScene = new RenderPass( scene, camera );
const bloomPass = new UnrealBloomPass(new THREE.Vector2(w, h), params.strength, params.radius, params.threshold);
const outputPass = new OutputPass();
composer = new EffectComposer( renderer );
composer.addPass( renderScene );
composer.addPass( bloomPass );
composer.addPass( outputPass );
在自定义图层的render方法中更新three.js容器及后处理的渲染
ini
composer.render();
renderer.resetState();
renderer.render(scene, camera);
但是我们很快发现,泛光效果的背景是黑色,覆盖了地图
分析了泛光相关的源码发现UnrealBloomPass.js中发现了这行代码
ini
gl_FragColor = vec4(diffuseSum/weightSum, 1.0);
着色器alpha 通道始终为1,参照一些其他资料,推荐的方式是取样时除颜色外,也将拾取alpha值,并按照权重输出,但效果看起来并不理想
最终发现需要控制alpha的最大值,会有较大改善
ini
void main() {
float weightSum = gaussianCoefficients[0];
vec3 diffuseSum = texture2D( colorTexture, vUv ).rgb * weightSum;
float alphaSum = 0.0;
for( int i = 1; i < KERNEL_RADIUS; i ++ ) {
float x = float(i);
float w = gaussianCoefficients[i];
vec2 uvOffset = direction * invSize * x;
vec4 sample1 = texture2D( colorTexture, vUv + uvOffset );
vec4 sample2 = texture2D( colorTexture, vUv - uvOffset );
diffuseSum += (sample1.rgb + sample2.rgb) * w;
alphaSum += (sample1.a + sample2.a); // Sum of alpha values
weightSum += 2.0 * w;
}
alphaSum /= weightSum; // Normalize alpha sum
alphaSum = min(alphaSum, 0.15); //Limit the value of alphaSum
gl_FragColor = vec4(diffuseSum / weightSum, alphaSum);
}`
即便如此,最终的效果仍然不能让人满意,泛光的区域边界明显,尤其在亮色系地图中,效果较差
不过我们还是基本完成了第二阶段的目标,成功实现了泛光在地图上的叠加。但除了效果不理想外,同时泛光效果是全局的,无法精确的针对单个图形控制泛光效果。 因此我们需要进一步优化
局部泛光,以及效果的进一步优化
单个图形的泛光,可以参照three.js官方提供的另一个例子:threejs.org/examples/?q...
实现的原理实际上是将图形拆分成不同的图层,拾取亮度之前将不需要泛光图层纹理置为黑色,需要泛光的图层提取亮度以及后续的处理,渲染时再将置黑的纹理还原,最后将两者混合。
按照three.js官方示例修改后,最终实现了此前同样的效果。
至于效果的优化,我找到了three.js的issues: github.com/mrdoob/thre...
alpha通道的问题看来是个难题,这个问题2018年就存在了。。在最后有位大佬给出的方案是不修改UnrealBloomPass,而是在shader中将源纹理和目标纹理混合
ini
void main() {
vec4 base_color = texture2D(baseTexture, vUv);
vec4 bloom_color = texture2D(bloomTexture, vUv);
float lum = 0.21 * bloom_color.r + 0.71 * bloom_color.g + 0.07 * bloom_color.b;
gl_FragColor = vec4(base_color.rgb + bloom_color.rgb, max(base_color.a, lum));
}
这个方案基本解决了透明度的问题,但是在个别机器(我的测试环境中大屏显示器有问题,笔记本屏幕没问题)显示会存在明显的颜色边界
所以我们需要在此基础上,将alpha的取值增加个差值,让颜色平滑过渡
ini
void main() {
vec4 base_color = texture2D(baseTexture, vUv);
vec4 bloom_color = texture2D(bloomTexture, vUv);
float lum = 0.21 * bloom_color.r + 0.71 * bloom_color.g + 0.07 * bloom_color.b;
vec3 blendedColor = base_color.rgb + bloom_color.rgb;
float alpha = max(base_color.a, lum);
alpha = mix(alpha, 0.05, 0.1);
gl_FragColor = vec4(blendedColor, alpha);
}
主要就是下面这一行,差值和混合因子可以按照不同情况调整
ini
alpha = mix(alpha, 0.05, 0.1);
问题解决:
到了这一步效果基本达到要求,可这个方案毕竟是将一个three.js的画布盖在了mapbox上面,终究会出现图层遮挡问题,也就是说mapbox图层无法覆盖泛光的图层。
实际上现有的方案我们并不完全依赖mapbox的自定义图层,我们只需要map对象就够了...
所以我们继续优化!
将泛光图层与mapbox混合
three.js容器说到底是个canvas,而canvas可以作为wengl的纹理,所以我们将three.js容器内容作为纹理,绘制到mapbox的画布中,再与原纹理混合即可。这也是mapbox自定义图层的基本用法
我们在onAdd 和render方法中加入着色器相关代码
onAdd方法
onAdd方法
const vertexShaderSource = `
attribute vec2 a_position;
attribute vec2 a_texCoord;
uniform vec2 u_resolution;
varying vec2 v_texCoord;
void main() {
vec2 zeroToOne = a_position / u_resolution;
vec2 zeroToTwo = zeroToOne * 2.0;
vec2 clipSpace = zeroToTwo - 1.0;
gl_Position = vec4(clipSpace * vec2(1, -1), 0, 1);
v_texCoord = a_texCoord;
}
`;
const fragmentShaderSource = `
#ifdef GL_ES
precision mediump float;
#endif
uniform sampler2D u_image;
varying vec2 v_texCoord;
void main() {
gl_FragColor = texture2D(u_image, v_texCoord);
}
`;
const vertexShader = gl.createShader(gl.VERTEX_SHADER);
gl.shaderSource(vertexShader, vertexShaderSource);
gl.compileShader(vertexShader);
if (!gl.getShaderParameter(vertexShader, gl.COMPILE_STATUS)) {
console.error(gl.getShaderInfoLog(vertexShader));
gl.deleteShader(vertexShader);
return;
}
const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER);
gl.shaderSource(fragmentShader, fragmentShaderSource);
gl.compileShader(fragmentShader);
if (!gl.getShaderParameter(fragmentShader, gl.COMPILE_STATUS)) {
console.error(gl.getShaderInfoLog(fragmentShader));
gl.deleteShader(fragmentShader);
return;
}
program = gl.createProgram();
gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader);
gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error(gl.getProgramInfoLog(program));
gl.deleteProgram(program);
return;
}
// attrib
positionLocation = gl.getAttribLocation(program, 'a_position');
texcoordLocation = gl.getAttribLocation(program, 'a_texCoord');
resolutionLocation = gl.getUniformLocation(program, 'u_resolution');
// buffer
positionBuffer = gl.createBuffer();
texcoordBuffer = gl.createBuffer();
// texture
texture = gl.createTexture();
render方法
render方法
gl.useProgram(program);
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
setRectangle(gl, 0, 0, container.width, container.height);
gl.bindBuffer(gl.ARRAY_BUFFER, texcoordBuffer);
gl.bufferData(
gl.ARRAY_BUFFER,
new Float32Array([0.0, 0.0, 1.0, 0.0, 0.0, 1.0, 0.0, 1.0, 1.0, 0.0, 1.0, 1.0]),
gl.STATIC_DRAW,
);
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, bloomContainer);
gl.enableVertexAttribArray(positionLocation);
gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer);
gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0);
gl.enableVertexAttribArray(texcoordLocation);
gl.bindBuffer(gl.ARRAY_BUFFER, texcoordBuffer);
gl.vertexAttribPointer(texcoordLocation, 2, gl.FLOAT, false, 0, 0);
gl.uniform2f(resolutionLocation, gl.canvas.width, gl.canvas.height);
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
gl.drawArrays(gl.TRIANGLES, 0, 6);
泛光效果太淡了
ini
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
上面这行是目前的混合方法,我们需要调整下,同时将three.js内的alpha设置为1.0
ini
void main() {
vec4 base_color = texture2D(baseTexture, vUv);
vec4 bloom_color = texture2D(bloomTexture, vUv);
vec3 blendedColor = base_color.rgb + bloom_color.rgb;
gl_FragColor = vec4(blendedColor, 1.0);
}
将blendFunc设置为简单的相加
ini
gl.blendFunc(gl.ONE, gl.ONE);
解决
测试下图层顺序,mapbox画一条红色的线,可以覆盖到泛光图形之上,说明与mapbox图层混合成功
添加事件处理
最后我们需要处理下事件,mapbox自定义图层是无法触发图层事件的,而我们的效果在three.js体系中,可以通过raycaster来处理事件
ini
var raycaster = new THREE.Raycaster();
var mouse = new THREE.Vector2();
function onMouseClick(event) {
const w = container.width / window.devicePixelRatio;
const h = container.height / window.devicePixelRatio;
mouse.x = (event.clientX / w) * 2 - 1;
mouse.y = -(event.clientY / h) * 2 + 1;
raycaster.setFromCamera(mouse, camera);
var intersects = raycaster.intersectObjects(scene.children, true);
if (intersects.length > 0) {
console.log('Object clicked!');
intersects[0].object.material.color.set(0xff0000);
}
}
window.addEventListener('click', onMouseClick, false);
看起来很简单是吧,从官网抄个demo就行了。但实际上这里我踩了很大一个坑。
首先intersectObjects始终无法命中,返回结果永远是空的,没有任何报错。为此专门看了three.js相关的源码,最后发现是设置相机的参数不正确导致的,也就是文章前面提到过的问题。
一开始我是这样设置的:
arduino
new THREE.PerspectiveCamera(28, container.innerWidth / container.innerHeight,0.000000000001, Infinity);
除了事件似乎没发现什么问题,但调试发现将 PerspectiveCamera 的 near 参数设置为 0,或将 far 参数设置为Infinity,将导致 camera.projectionMatrix 和 camera.projectionMatrixInverse 中出现 NaN 值,使 raycaster 中的 ray 的 direction 属性变为 NaN,最终导致 intersectObjects 无法成功命中对象。
问题解决了吗?
点击事件似乎可以了,但是只有部分区域可以响应...
再仔细看,fov设置为28,而为了与mapbox完全同步,我们应直接使用mapbox相机的fov
ini
camera = new THREE.PerspectiveCamera(map.transform.fov, w / h, 0.1, 1e21);
事件问题终于解决。
至此,可应用于实践的案例就算完成了,我们实现了相机同步、坐标系同步、泛光效果、局部泛光控制、mapbox图层层级控制以及事件响应。但泛光对性能是有一定影响的,在实际项目中,还需要进一步封装,如:以单例的形式来维护泛光的容器;拆分动态效果与静态效果,静态效果无需持续刷新等等
感兴趣的同学可以安装源码本地运行, github 源码:github.com/ethan-zf/ma... 如有帮助,欢迎star~