Skip to content

Commit

Permalink
New feature: support globe raster tiles from a slippy map engine
Browse files Browse the repository at this point in the history
  • Loading branch information
vasturiano committed Dec 30, 2024
1 parent 672c990 commit 1568786
Show file tree
Hide file tree
Showing 4 changed files with 194 additions and 4 deletions.
3 changes: 3 additions & 0 deletions src/globe-kapsule.js
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ const bindGlobeLayer = linkKapsule('globeLayer', GlobeLayerKapsule);
const linkedGlobeLayerProps = Object.assign(...[
'globeImageUrl',
'bumpImageUrl',
'globeTileEngineUrl',
'globeTileEngineImgSize',
'globeTileEngineThresholds',
'showGlobe',
'showGraticules',
'showAtmosphere',
Expand Down
6 changes: 6 additions & 0 deletions src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ export declare class ThreeGlobeGeneric<ChainableInstance> extends Object3D {
atmosphereColor(color: string): ChainableInstance;
atmosphereAltitude(): number;
atmosphereAltitude(alt: number): ChainableInstance;
globeTileEngineUrl(): (x: number, y: number, level: number) => string;
globeTileEngineUrl(urlFn: (x: number, y: number, level: number) => string): ChainableInstance;
globeTileEngineImgSize(): number;
globeTileEngineImgSize(size: number): ChainableInstance;
globeTileEngineThresholds(): number[];
globeTileEngineThresholds(thresholds: number[]): ChainableInstance;
globeMaterial(): Material;
globeMaterial(globeMaterial: Material): ChainableInstance;
onGlobeReady(callback: (() => void)): ChainableInstance;
Expand Down
32 changes: 28 additions & 4 deletions src/layers/globe.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import GlowMesh from '../utils/GlowMesh.js';
import Kapsule from 'kapsule';
import { geoGraticule10 } from 'd3-geo';

import TileEngine, { convertMercatorUV } from '../utils/tile-engine';
import { emptyObject } from '../utils/gc';
import { GLOBE_RADIUS } from '../constants';

Expand All @@ -42,6 +43,24 @@ export default Kapsule({
showAtmosphere: { default: true, onChange(showAtmosphere, state) { state.atmosphereObj && (state.atmosphereObj.visible = !!showAtmosphere) }, triggerUpdate: false },
atmosphereColor: { default: 'lightskyblue' },
atmosphereAltitude: { default: 0.15 },
globeTileEngineUrl: { onChange(v, state, prevV) {
// Distort or reset globe UVs as tile engine is being enabled/disabled
const uvs = state.globeObj.geometry.attributes.uv;
if (v && !prevV) {
state.linearUVs = uvs.array.slice(); // in case they need to be put back
convertMercatorUV(uvs);
}
if (!v && prevV) {
uvs.array = state.linearUVs;
uvs.needsUpdate = true;
}

state.tileEngine.url(v);
}},
globeTileEngineImgSize: { default: 256, onChange(v, state) { state.tileEngine.imgSize(v) }, triggerUpdate: false },
globeTileEngineThresholds: { default: [5, 2, 3/4, 1/4, 1/8, 1/16], onChange(v, state) { state.tileEngine.thresholds(v) }, triggerUpdate: false },
cameraDistance: { onChange(v, state) { state.tileEngine.cameraDistance(v) }, triggerUpdate: false },
isInView: { onChange(v, state) { state.tileEngine.isInView(v) }, triggerUpdate: false },
onReady: { default: () => {}, triggerUpdate: false }
},
methods: {
Expand All @@ -53,6 +72,7 @@ export default Kapsule({
return state.globeObj.material;
},
_destructor: function(state) {
state.tileEngine._destructor();
emptyObject(state.globeObj);
emptyObject(state.graticulesObj);
}
Expand All @@ -72,10 +92,14 @@ export default Kapsule({
new THREE.LineBasicMaterial({ color: 'lightgrey', transparent: true, opacity: 0.1 })
);

// Bind tile engine to material
const tileEngine = new TileEngine(globeObj.material);

return {
globeObj,
graticulesObj,
defaultGlobeMaterial
defaultGlobeMaterial,
tileEngine
}
},

Expand All @@ -95,9 +119,9 @@ export default Kapsule({
update(state, changedProps) {
const globeMaterial = state.globeObj.material;

if (changedProps.hasOwnProperty('globeImageUrl')) {
if (!state.globeTileEngineUrl && ['globeImageUrl', 'globeTileEngineUrl'].some(p => changedProps.hasOwnProperty(p))) {
if (!state.globeImageUrl) {
// Black globe if no image
// Black globe if no image nor tiles
!globeMaterial.color && (globeMaterial.color = new THREE.Color(0x000000));
} else {
new THREE.TextureLoader().load(state.globeImageUrl, texture => {
Expand Down Expand Up @@ -145,7 +169,7 @@ export default Kapsule({
}
}

if (!state.ready && !state.globeImageUrl) {
if (!state.ready && (!state.globeImageUrl || state.globeTileEngineUrl)) {
// ready immediately if there's no globe image
state.ready = true;
state.onReady();
Expand Down
157 changes: 157 additions & 0 deletions src/utils/tile-engine.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import {
CanvasTexture,
SRGBColorSpace,
Vector3
} from 'three';

const THREE = window.THREE
? window.THREE // Prefer consumption from global THREE, if exists
: {
CanvasTexture,
SRGBColorSpace,
Vector3
};

import Kapsule from 'kapsule';
import { geoMercatorRaw } from 'd3-geo';

import { polar2Cartesian } from './coordTranslate.js';

export const yMercatorScale = y => 1 - (geoMercatorRaw(0, (0.5 - y) * Math.PI)[1] / Math.PI + 1) / 2;
export const yMercatorScaleInvert = y => 0.5 - geoMercatorRaw.invert(0, (2 * (1 - y) - 1) * Math.PI)[1] / Math.PI;
export const convertMercatorUV = (uvs, revert = false) => {
const scale = revert ? yMercatorScaleInvert : yMercatorScale;
const arr = uvs.array;
for (let i = 0, len = arr.length; i < len; i+=2) {
arr[i+1] = Math.max(0, Math.min(1, scale(arr[i+1])));
}
uvs.needsUpdate = true;
}

export default Kapsule({
props: {
url: {}, // (x,y,level) => str
imgSize: { default: 256 }, // px (square)
thresholds: { default: [5, 2, 3/4, 1/4, 1/8, 1/16] }, // in globe radius units
cameraDistance: {},
isInView: { onChange() { this.fetchNeededTiles() }, triggerUpdate: false }
},

methods: {
fetchNeededTiles(state) {
if (!state.url) return;

// Safety if can't check in view tiles for higher levels
if (!state.isInView && state.level > 3) return;

let ctx;
state.tilesMeta
.filter(d => !d.fetched)
.forEach((d) => {
if (!state.isInView || d.hullPnts.some(state.isInView)) {
// Fetch tile
d.fetched = true;

const { x, y } = d;
const imgSize = state.imgSize;
const img = document.createElement('img');
img.src = state.url(x, y, state.level);
img.crossOrigin = 'anonymous';
img.width = imgSize;
img.height = imgSize;
img.onload = () => {
!ctx && (ctx = state.canvas.getContext('2d'));
ctx.drawImage(img, x * imgSize, y * imgSize, imgSize, imgSize);
state.texture.needsUpdate = true;
};
}
});
},
_destructor: function(state) {
state.material.map = undefined;
}
},

stateInit: () => ({
tilesMeta: [],
level: 0
}),

init(material, state, { mercatorProjection = true }= {}) {
// Globe wrapping material to manipulate
state.material = material;
state.isMercator = mercatorProjection;
},

update(state, changedProps) {
if (!state.url) return;

let levelChanged = false;
if (state.cameraDistance !== undefined) {
let level;
if (!state.url || state.cameraDistance <= 0) {
level = 0;
} else {
const idx = state.thresholds.findIndex(t => t && t <= state.cameraDistance);
level = idx < 0 ? state.thresholds.length : idx;
}

if(state.level !== level) {
levelChanged = true;
state.level = level;
}
}

if (levelChanged || ['url', 'imgSize'].some(p => changedProps.hasOwnProperty(p))) {
const gridSize = 2**state.level;
const canvasSize = state.imgSize * gridSize;

// Rebuild canvas
const newCanvas = new OffscreenCanvas(canvasSize, canvasSize);
state.canvas && newCanvas.getContext('2d').drawImage(state.canvas, 0, 0, canvasSize, canvasSize);
state.canvas = newCanvas;
state.texture = state.material.map = new THREE.CanvasTexture(state.canvas);
state.texture.colorSpace = THREE.SRGBColorSpace;
if (state.material.color) {
state.material.color = null;
state.material.needsUpdate = true;
}

// Rebuild tiles meta
state.tilesMeta = [];
const tileLngLen = 360 / gridSize;
const regTileLatLen = 180 / gridSize;
for (let x = 0; x < gridSize; x++) {
for (let y = 0; y < gridSize; y++) {
let reproY = y,
tileLatLen = regTileLatLen;
if (state.isMercator) {
// lat needs reprojection
reproY = yMercatorScaleInvert(y / gridSize) * gridSize;
const reproYEnd = yMercatorScaleInvert((y + 1) / gridSize) * gridSize;
tileLatLen = (reproYEnd - reproY) * 180 / gridSize;
}

const lng0 = -180 + x * tileLngLen;
const lat0 = 90 - (reproY * 180 / gridSize);
const hullPnts = [
[lat0, lng0],
[lat0 - tileLatLen, lng0],
[lat0, lng0 + tileLngLen],
[lat0 - tileLatLen, lng0 + tileLngLen],
[lat0 - tileLatLen / 2, lng0 + tileLngLen / 2],
].map(c => polar2Cartesian(...c)).map(({ x, y, z }) => new THREE.Vector3(x, y, z));

state.tilesMeta.push({
x,
y,
hullPnts,
fetched: false
});
}
}

this.fetchNeededTiles();
}
}
});

0 comments on commit 1568786

Please sign in to comment.