diff --git a/example/three/pmtiles.html b/example/three/pmtiles.html
new file mode 100644
index 000000000..6a380bc5a
--- /dev/null
+++ b/example/three/pmtiles.html
@@ -0,0 +1,21 @@
+
+
+
+
+
+ PMTiles Globe Example
+
+
+
+
+
+
+
diff --git a/example/three/pmtiles.js b/example/three/pmtiles.js
new file mode 100644
index 000000000..09656a43a
--- /dev/null
+++ b/example/three/pmtiles.js
@@ -0,0 +1,176 @@
+import {
+ Scene,
+ WebGLRenderer,
+ PerspectiveCamera,
+ AmbientLight,
+ DirectionalLight,
+} from 'three';
+import {
+ TilesRenderer,
+ GlobeControls,
+} from '3d-tiles-renderer';
+import {
+ UpdateOnChangePlugin,
+ PMTilesPlugin,
+} from '3d-tiles-renderer/plugins';
+import { GUI } from 'three/examples/jsm/libs/lil-gui.module.min.js';
+
+let scene, renderer, camera, controls, tiles, gui;
+
+// Layer configuration for Protomaps v4 basemap
+const LAYERS = {
+ water: { enabled: true, color: '#4a90d9' },
+ earth: { enabled: true, color: '#f2efe9' },
+ landuse: { enabled: false, color: '#e8e4d8' },
+ landcover: { enabled: false, color: '#d4e8c2' },
+ natural: { enabled: false, color: '#c8d9af' },
+ roads: { enabled: false, color: '#ffffff' },
+ buildings: { enabled: false, color: '#d9d0c9' },
+ transit: { enabled: false, color: '#888888' },
+ boundaries: { enabled: true, color: '#ff6b6b' },
+ places: { enabled: true, color: '#333333' },
+ pois: { enabled: false, color: '#7d4e24' },
+};
+
+// Application state
+const state = {
+ layers: {},
+ colors: {},
+};
+
+// Initialize state from layer config
+for ( const key in LAYERS ) {
+
+ state.layers[ key ] = LAYERS[ key ].enabled;
+ state.colors[ key ] = LAYERS[ key ].color;
+
+}
+
+state.colors.default = '#cccccc';
+
+init();
+setupGUI();
+createTiles();
+
+function init() {
+
+ renderer = new WebGLRenderer( { antialias: true } );
+ renderer.setAnimationLoop( render );
+ renderer.setPixelRatio( window.devicePixelRatio );
+ renderer.setSize( window.innerWidth, window.innerHeight );
+ renderer.setClearColor( 0x111111 );
+ document.body.appendChild( renderer.domElement );
+
+ scene = new Scene();
+ camera = new PerspectiveCamera( 60, window.innerWidth / window.innerHeight, 100, 1e8 );
+
+ const dirLight = new DirectionalLight( 0xffffff );
+ dirLight.position.set( 1, 1, 1 );
+ scene.add( dirLight );
+ scene.add( new AmbientLight( 0x444444 ) );
+
+ controls = new GlobeControls( scene, camera, renderer.domElement );
+ controls.enableDamping = true;
+ controls.camera.position.set( 0, 0, 1.5 * 1e7 );
+
+ window.addEventListener( 'resize', onWindowResize, false );
+
+}
+
+function createFilter() {
+
+ return function ( feature, layerName ) {
+
+ if ( layerName in state.layers ) {
+
+ return state.layers[ layerName ] === true;
+
+ }
+
+ // Unknown layers: hide by default
+ return false;
+
+ };
+
+}
+
+function createTiles() {
+
+ if ( tiles ) {
+
+ scene.remove( tiles.group );
+ tiles.dispose();
+
+ }
+
+ tiles = new TilesRenderer();
+ tiles.registerPlugin( new UpdateOnChangePlugin() );
+ tiles.registerPlugin( new PMTilesPlugin( {
+ url: 'https://demo-bucket.protomaps.com/v4.pmtiles',
+ center: true,
+ shape: 'ellipsoid',
+ levels: 15,
+ tileDimension: 512,
+ styles: state.colors,
+ filter: createFilter()
+ } ) );
+
+ tiles.group.rotation.x = - Math.PI / 2;
+ tiles.setCamera( camera );
+ scene.add( tiles.group );
+
+ if ( controls ) controls.setEllipsoid( tiles.ellipsoid, tiles.group );
+
+}
+
+function setupGUI() {
+
+ gui = new GUI();
+
+ // Layers folder
+ const layersFolder = gui.addFolder( 'Layers' );
+ for ( const key in LAYERS ) {
+
+ layersFolder.add( state.layers, key )
+ .name( key.charAt( 0 ).toUpperCase() + key.slice( 1 ) )
+ .onChange( createTiles );
+
+ }
+
+ // Colors folder
+ const colorsFolder = gui.addFolder( 'Colors' );
+ for ( const key in LAYERS ) {
+
+ colorsFolder.addColor( state.colors, key )
+ .name( key.charAt( 0 ).toUpperCase() + key.slice( 1 ) )
+ .onChange( createTiles );
+
+ }
+
+ colorsFolder.close();
+
+}
+
+function onWindowResize() {
+
+ camera.aspect = window.innerWidth / window.innerHeight;
+ camera.updateProjectionMatrix();
+ renderer.setSize( window.innerWidth, window.innerHeight );
+
+}
+
+function render() {
+
+ controls.update();
+ if ( tiles ) {
+
+ camera.updateMatrixWorld();
+ tiles.setCamera( camera );
+ tiles.setResolutionFromRenderer( camera, renderer );
+ tiles.update();
+
+ }
+
+ renderer.render( scene, camera );
+
+}
diff --git a/package-lock.json b/package-lock.json
index 27955296c..98abcc6fa 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,6 +8,11 @@
"name": "3d-tiles-renderer",
"version": "0.4.19",
"license": "Apache-2.0",
+ "dependencies": {
+ "@mapbox/vector-tile": "^2.0.3",
+ "pbf": "^4.0.1",
+ "pmtiles": "^4.3.2"
+ },
"devDependencies": {
"@babel/preset-modules": "^0.1.6",
"@babel/preset-react": "^7.26.3",
@@ -99,6 +104,7 @@
"integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/code-frame": "^7.28.6",
"@babel/generator": "^7.28.6",
@@ -708,7 +714,8 @@
"resolved": "https://registry.npmjs.org/@babylonjs/core/-/core-8.48.0.tgz",
"integrity": "sha512-6iJk72ufSdU+ui6BtHPMTyfhrS5EMbKz3568mmOu5Tn85BOdRbwLcZu30EJpSCxZ7tdFs5goBTMcEv19eYuTCQ==",
"dev": true,
- "license": "Apache-2.0"
+ "license": "Apache-2.0",
+ "peer": true
},
"node_modules/@babylonjs/loaders": {
"version": "8.48.0",
@@ -1550,6 +1557,23 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@mapbox/point-geometry": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/@mapbox/point-geometry/-/point-geometry-1.1.0.tgz",
+ "integrity": "sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==",
+ "license": "ISC"
+ },
+ "node_modules/@mapbox/vector-tile": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.4.tgz",
+ "integrity": "sha512-AkOLcbgGTdXScosBWwmmD7cDlvOjkg/DetGva26pIRiZPdeJYjYKarIlb4uxVzi6bwHO6EWH82eZ5Nuv4T5DUg==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "@mapbox/point-geometry": "~1.1.0",
+ "@types/geojson": "^7946.0.16",
+ "pbf": "^4.0.1"
+ }
+ },
"node_modules/@mediapipe/tasks-vision": {
"version": "0.10.17",
"resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.17.tgz",
@@ -2361,6 +2385,7 @@
"integrity": "sha512-FiUzfYW4wB1+PpmsE47UM+mCads7j2+giRBltfwH7SNhah95rqJs3ltEs9V3pP8rYdS0QlNne+9Aj8dS/SiaIA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@babel/runtime": "^7.17.8",
"@types/webxr": "*",
@@ -2859,6 +2884,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/geojson": {
+ "version": "7946.0.16",
+ "resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
+ "integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
+ "license": "MIT"
+ },
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -2889,6 +2920,7 @@
"integrity": "sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -2899,6 +2931,7 @@
"integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -2926,6 +2959,7 @@
"integrity": "sha512-CUm2uckq+zkCY7ZbFpviRttY+6f9fvwm6YqSqPfA5K22s9w7R4VnA3rzJse8kHVvuzLcTx+CjNCs2NYe0QFAyg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@tweenjs/tween.js": "~23.1.3",
"@types/stats.js": "*",
@@ -2995,6 +3029,7 @@
"integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@typescript-eslint/scope-manager": "8.54.0",
"@typescript-eslint/types": "8.54.0",
@@ -3410,6 +3445,7 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
+ "peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -3777,6 +3813,7 @@
}
],
"license": "MIT",
+ "peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759",
@@ -4584,6 +4621,7 @@
"integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.8.0",
"@eslint-community/regexpp": "^4.12.1",
@@ -4945,7 +4983,6 @@
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
- "dev": true,
"license": "MIT"
},
"node_modules/file-entry-cache": {
@@ -6568,6 +6605,18 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/pbf": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.1.tgz",
+ "integrity": "sha512-SuLdBvS42z33m8ejRbInMapQe8n0D3vN/Xd5fmWM3tufNgRQFBpaW2YVJxQZV4iPNqb0vEFvssMEo5w9c6BTIA==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "resolve-protobuf-schema": "^2.1.0"
+ },
+ "bin": {
+ "pbf": "bin/pbf"
+ }
+ },
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -6581,6 +6630,7 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
+ "peer": true,
"engines": {
"node": ">=12"
},
@@ -6588,6 +6638,15 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
+ "node_modules/pmtiles": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/pmtiles/-/pmtiles-4.3.2.tgz",
+ "integrity": "sha512-Ath2F2U2E37QyNXjN1HOF+oLiNIbdrDYrk/K3C9K4Pgw2anwQX10y4WYWEH9O75vPiu0gBbSWIAbSG19svyvZg==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "fflate": "^0.8.2"
+ }
+ },
"node_modules/possible-typed-array-names": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@@ -6702,6 +6761,12 @@
"node": ">=12.0.0"
}
},
+ "node_modules/protocol-buffers-schema": {
+ "version": "3.6.0",
+ "resolved": "https://registry.npmjs.org/protocol-buffers-schema/-/protocol-buffers-schema-3.6.0.tgz",
+ "integrity": "sha512-TdDRD+/QNdrCGCE7v8340QyuXd4kIWIgapsE2+n/SaGiSSbomYl4TjHlvIoCWRpE7wFt02EpB35VVA2ImcBVqw==",
+ "license": "MIT"
+ },
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -6966,6 +7031,15 @@
"node": ">=4"
}
},
+ "node_modules/resolve-protobuf-schema": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/resolve-protobuf-schema/-/resolve-protobuf-schema-2.1.0.tgz",
+ "integrity": "sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==",
+ "license": "MIT",
+ "dependencies": {
+ "protocol-buffers-schema": "^3.3.1"
+ }
+ },
"node_modules/rollup": {
"version": "4.57.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.0.tgz",
@@ -7574,7 +7648,8 @@
"resolved": "https://registry.npmjs.org/three/-/three-0.170.0.tgz",
"integrity": "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==",
"dev": true,
- "license": "MIT"
+ "license": "MIT",
+ "peer": true
},
"node_modules/three-mesh-bvh": {
"version": "0.8.3",
@@ -7869,6 +7944,7 @@
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
+ "peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -8052,6 +8128,7 @@
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"esbuild": "^0.25.0",
"fdir": "^6.4.4",
@@ -8127,6 +8204,7 @@
"integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
"dev": true,
"license": "MIT",
+ "peer": true,
"dependencies": {
"@vitest/expect": "4.0.18",
"@vitest/mocker": "4.0.18",
diff --git a/package.json b/package.json
index 49ea21e67..d8d976009 100644
--- a/package.json
+++ b/package.json
@@ -80,6 +80,7 @@
"license": "Apache-2.0",
"devDependencies": {
"@babel/preset-modules": "^0.1.6",
+ "@mapbox/vector-tile": "^2.0.3",
"@babel/preset-react": "^7.26.3",
"@babel/preset-typescript": "^7.26.0",
"@eslint/js": "^9.0.0",
@@ -102,6 +103,8 @@
"globals": "^16.5.0",
"leva": "^0.10.0",
"lil-gui": "^0.21.0",
+ "pbf": "^4.0.1",
+ "pmtiles": "^4.3.2",
"postprocessing": "^6.36.4",
"three": "^0.170.0",
"typescript": "^5.6.0",
@@ -110,14 +113,20 @@
"vitest": "^4.0.15"
},
"peerDependencies": {
+ "@mapbox/vector-tile": "^2.0.3",
"@react-three/fiber": "^8.17.9 || ^9.0.0",
"@babylonjs/core": ">=8.0.0",
"@babylonjs/loaders": ">=8.0.0",
+ "pbf": "^4.0.1",
+ "pmtiles": "^4.3.2",
"react": "^18.3.1 || ^19.0.0",
"react-dom": "^18.3.1 || ^19.0.0",
"three": ">=0.167.0"
},
"peerDependenciesMeta": {
+ "@mapbox/vector-tile": {
+ "optional": true
+ },
"@react-three/fiber": {
"optional": true
},
@@ -127,6 +136,12 @@
"@babylonjs/loaders": {
"optional": true
},
+ "pbf": {
+ "optional": true
+ },
+ "pmtiles": {
+ "optional": true
+ },
"react": {
"optional": true
},
diff --git a/src/core/renderer/index.js b/src/core/renderer/index.js
index e175b9654..872b78b38 100644
--- a/src/core/renderer/index.js
+++ b/src/core/renderer/index.js
@@ -4,6 +4,8 @@ export { LoaderBase } from './loaders/LoaderBase.js';
export * from './loaders/B3DMLoaderBase.js';
export * from './loaders/I3DMLoaderBase.js';
export * from './loaders/PNTSLoaderBase.js';
+export * from './loaders/MVTLoaderBase.js';
+export * from './loaders/PMTilesLoaderBase.js';
export * from './loaders/CMPTLoaderBase.js';
export * from './constants.js';
diff --git a/src/core/renderer/loaders/MVTLoaderBase.js b/src/core/renderer/loaders/MVTLoaderBase.js
new file mode 100644
index 000000000..d95a0d785
--- /dev/null
+++ b/src/core/renderer/loaders/MVTLoaderBase.js
@@ -0,0 +1,19 @@
+// MVT File Format
+// https://github.com/mapbox/vector-tile-spec/blob/master/2.1/README.md
+
+import { LoaderBase } from './LoaderBase.js';
+import { VectorTile } from '@mapbox/vector-tile';
+import Protobuf from 'pbf';
+
+export class MVTLoaderBase extends LoaderBase {
+
+ parse( buffer ) {
+
+ const pbf = new Protobuf( buffer );
+ const vectorTile = new VectorTile( pbf );
+
+ return Promise.resolve( { vectorTile } );
+
+ }
+
+}
diff --git a/src/core/renderer/loaders/PMTilesLoaderBase.js b/src/core/renderer/loaders/PMTilesLoaderBase.js
new file mode 100644
index 000000000..80b951e24
--- /dev/null
+++ b/src/core/renderer/loaders/PMTilesLoaderBase.js
@@ -0,0 +1,70 @@
+// PMTiles Archive Format
+// https://github.com/protomaps/PMTiles
+
+import { PMTiles } from 'pmtiles';
+
+export class PMTilesLoaderBase {
+
+ constructor() {
+
+ this.instance = null;
+ this.header = null;
+ this.url = null;
+
+ }
+
+ // Initialize the PMTiles archive and load header
+ async init( url ) {
+
+ this.url = url;
+ this.instance = new PMTiles( url );
+ this.header = await this.instance.getHeader();
+
+ return this.header;
+
+ }
+
+ // Fetch a tile from the archive
+ async getTile( z, x, y, signal ) {
+
+ if ( ! this.instance ) {
+
+ throw new Error( 'PMTilesLoaderBase: Archive not initialized. Call init() first.' );
+
+ }
+
+ const res = await this.instance.getZxy( z, x, y, signal );
+
+ if ( ! res || ! res.data ) {
+
+ return null;
+
+ }
+
+ return res.data;
+
+ }
+
+ // Generate a virtual URL for a tile (used by tiling scheme)
+ getUrl( z, x, y ) {
+
+ return `pmtiles://${z}/${x}/${y}`;
+
+ }
+
+ // Parse tile coordinates from a virtual URL (pmtiles://z/x/y)
+ static parseUrl( url ) {
+
+ const i2 = url.lastIndexOf( '/' );
+ const i1 = url.lastIndexOf( '/', i2 - 1 );
+ const i0 = url.lastIndexOf( '/', i1 - 1 );
+
+ return {
+ z: parseInt( url.slice( i0 + 1, i1 ) ),
+ x: parseInt( url.slice( i1 + 1, i2 ) ),
+ y: parseInt( url.slice( i2 + 1 ) ),
+ };
+
+ }
+
+}
diff --git a/src/three/plugins/images/PMTilesPlugin.js b/src/three/plugins/images/PMTilesPlugin.js
new file mode 100644
index 000000000..a15229753
--- /dev/null
+++ b/src/three/plugins/images/PMTilesPlugin.js
@@ -0,0 +1,32 @@
+import { EllipsoidProjectionTilesPlugin } from './EllipsoidProjectionTilesPlugin.js';
+import { PMTilesImageSource } from './sources/PMTilesImageSource.js';
+import { PMTilesLoaderBase } from '../../../core/renderer/loaders/PMTilesLoaderBase.js';
+
+export class PMTilesPlugin extends EllipsoidProjectionTilesPlugin {
+
+ constructor( options = {} ) {
+
+ super( options );
+
+ this.name = 'PMTILES_PLUGIN';
+ this.imageSource = new PMTilesImageSource( options );
+
+ }
+
+ // Intercept pmtiles:// URLs and fetch from the PMTiles archive
+ fetchData( url, options ) {
+
+ if ( url.startsWith( 'pmtiles://' ) ) {
+
+ const { z, x, y } = PMTilesLoaderBase.parseUrl( url );
+
+ return this.imageSource.pmtilesLoader.getTile( z, x, y, options?.signal )
+ .then( buffer => buffer || new ArrayBuffer( 0 ) );
+
+ }
+
+ return null;
+
+ }
+
+}
diff --git a/src/three/plugins/images/sources/MVTImageSource.js b/src/three/plugins/images/sources/MVTImageSource.js
new file mode 100644
index 000000000..6dcb08e3d
--- /dev/null
+++ b/src/three/plugins/images/sources/MVTImageSource.js
@@ -0,0 +1,33 @@
+import { XYZImageSource } from './XYZImageSource.js';
+import { MVTLoaderBase } from '../../../../core/renderer/loaders/MVTLoaderBase.js';
+import { VectorTileStyler } from '../../../renderer/utils/VectorTileStyler.js';
+import { VectorTileCanvasRenderer } from '../../../renderer/utils/VectorTileCanvasRenderer.js';
+
+export class MVTImageSource extends XYZImageSource {
+
+ constructor( options = {} ) {
+
+ super( options );
+
+ this.loader = new MVTLoaderBase();
+ this.tileDimension = options.tileDimension || 512;
+
+ this._styler = new VectorTileStyler( {
+ filter: options.filter,
+ styles: options.styles
+ } );
+
+ this._renderer = new VectorTileCanvasRenderer( this._styler, {
+ tileDimension: this.tileDimension
+ } );
+
+ }
+
+ async processBufferToTexture( buffer ) {
+
+ const { vectorTile } = await this.loader.parse( buffer );
+ return this._renderer.render( vectorTile );
+
+ }
+
+}
diff --git a/src/three/plugins/images/sources/PMTilesImageSource.js b/src/three/plugins/images/sources/PMTilesImageSource.js
new file mode 100644
index 000000000..97efe679d
--- /dev/null
+++ b/src/three/plugins/images/sources/PMTilesImageSource.js
@@ -0,0 +1,33 @@
+import { MVTImageSource } from './MVTImageSource.js';
+import { ProjectionScheme } from '../utils/ProjectionScheme.js';
+import { PMTilesLoaderBase } from '../../../../core/renderer/loaders/PMTilesLoaderBase.js';
+
+export class PMTilesImageSource extends MVTImageSource {
+
+ constructor( options = {} ) {
+
+ super( options );
+
+ this.pmtilesLoader = new PMTilesLoaderBase();
+ this.tiling.flipY = true;
+
+ }
+
+ getUrl( x, y, level ) {
+
+ return this.pmtilesLoader.getUrl( level, x, y );
+
+ }
+
+ async init() {
+
+ const header = await this.pmtilesLoader.init( this.url );
+ this.tiling.setProjection( new ProjectionScheme( 'EPSG:3857' ) );
+ this.tiling.generateLevels( header.maxZoom, this.tiling.projection.tileCountX, this.tiling.projection.tileCountY, {
+ tilePixelWidth: this.tileDimension,
+ tilePixelHeight: this.tileDimension,
+ } );
+
+ }
+
+}
diff --git a/src/three/plugins/index.js b/src/three/plugins/index.js
index 329725c6f..1a4970d59 100644
--- a/src/three/plugins/index.js
+++ b/src/three/plugins/index.js
@@ -16,6 +16,7 @@ export * from './DebugTilesPlugin.js';
// other formats
export * from './images/DeepZoomImagePlugin.js';
export * from './images/EPSGTilesPlugin.js';
+export * from './images/PMTilesPlugin.js';
// gltf extensions
export * from './gltf/GLTFCesiumRTCExtension.js';
diff --git a/src/three/renderer/utils/VectorTileCanvasRenderer.js b/src/three/renderer/utils/VectorTileCanvasRenderer.js
new file mode 100644
index 000000000..c4cdd24c0
--- /dev/null
+++ b/src/three/renderer/utils/VectorTileCanvasRenderer.js
@@ -0,0 +1,176 @@
+import { CanvasTexture, SRGBColorSpace } from 'three';
+
+const MVT_EXTENT = 4096;
+
+export class VectorTileCanvasRenderer {
+
+ constructor( styler, options = {} ) {
+
+ this.styler = styler;
+ this.tileDimension = options.tileDimension || 512;
+
+ }
+
+ render( vectorTile ) {
+
+ const canvas = this._createCanvas( this.tileDimension, this.tileDimension );
+ const ctx = canvas.getContext( '2d' );
+ const scale = this.tileDimension / MVT_EXTENT;
+
+ for ( const { layerName, geometry, type } of this._getFeatures( vectorTile ) ) {
+
+ const color = this.styler.getColor( layerName, 'css' );
+ ctx.fillStyle = color;
+ ctx.strokeStyle = color;
+ ctx.lineWidth = 1;
+
+ if ( type === 1 ) {
+
+ this._renderPoints( ctx, geometry, layerName, scale );
+
+ } else if ( type === 2 ) {
+
+ this._renderLines( ctx, geometry, scale );
+
+ } else if ( type === 3 ) {
+
+ this._renderPolygons( ctx, geometry, scale );
+
+ }
+
+ }
+
+ return this._createTexture( canvas );
+
+ }
+
+ _getFeatures( vectorTile ) {
+
+ const results = [];
+ const layerNames = Object.keys( vectorTile.layers );
+ const sortedLayers = this.styler.sortLayers( layerNames );
+
+ for ( const layerName of sortedLayers ) {
+
+ const layer = vectorTile.layers[ layerName ];
+
+ for ( let i = 0; i < layer.length; i ++ ) {
+
+ const feature = layer.feature( i );
+
+ if ( this.styler.shouldIncludeFeature( feature, layerName ) ) {
+
+ results.push( {
+ layerName,
+ geometry: feature.loadGeometry(),
+ type: feature.type,
+ } );
+
+ }
+
+ }
+
+ }
+
+ return results;
+
+ }
+
+ _createCanvas( width, height ) {
+
+ if ( typeof OffscreenCanvas !== 'undefined' ) {
+
+ return new OffscreenCanvas( width, height );
+
+ } else {
+
+ const canvas = document.createElement( 'canvas' );
+ canvas.width = width;
+ canvas.height = height;
+ return canvas;
+
+ }
+
+ }
+
+ _createTexture( canvas ) {
+
+ const tex = new CanvasTexture( canvas );
+ tex.colorSpace = SRGBColorSpace;
+ tex.generateMipmaps = false;
+ tex.needsUpdate = true;
+ return tex;
+
+ }
+
+ _renderPoints( ctx, geometry, layerName, scale ) {
+
+ const isLabelLayer = ( layerName === 'place_label' );
+
+ for ( const multiPoint of geometry ) {
+
+ for ( const p of multiPoint ) {
+
+ const x = p.x * scale;
+ const y = p.y * scale;
+
+ if ( ! isLabelLayer ) {
+
+ const radius = ( layerName === 'poi' ) ? 3 : 2;
+
+ ctx.beginPath();
+ ctx.moveTo( x + radius, y );
+ ctx.arc( x, y, radius, 0, Math.PI * 2 );
+ ctx.fill();
+
+ }
+
+ }
+
+ }
+
+ }
+
+ _renderLines( ctx, geometry, scale ) {
+
+ ctx.beginPath();
+
+ for ( const ring of geometry ) {
+
+ for ( let k = 0; k < ring.length; k ++ ) {
+
+ const p = ring[ k ];
+ if ( k === 0 ) ctx.moveTo( p.x * scale, p.y * scale );
+ else ctx.lineTo( p.x * scale, p.y * scale );
+
+ }
+
+ }
+
+ ctx.stroke();
+
+ }
+
+ _renderPolygons( ctx, geometry, scale ) {
+
+ ctx.beginPath();
+
+ for ( const ring of geometry ) {
+
+ for ( let k = 0; k < ring.length; k ++ ) {
+
+ const p = ring[ k ];
+ if ( k === 0 ) ctx.moveTo( p.x * scale, p.y * scale );
+ else ctx.lineTo( p.x * scale, p.y * scale );
+
+ }
+
+ ctx.closePath();
+
+ }
+
+ ctx.fill();
+
+ }
+
+}
diff --git a/src/three/renderer/utils/VectorTileStyler.js b/src/three/renderer/utils/VectorTileStyler.js
new file mode 100644
index 000000000..4f4e76f6d
--- /dev/null
+++ b/src/three/renderer/utils/VectorTileStyler.js
@@ -0,0 +1,54 @@
+import { Color } from 'three';
+import { LAYER_COLORS, DEFAULT_LAYER_ORDER } from './layerColors.js';
+
+const _color = /* @__PURE__ */ new Color();
+
+export class VectorTileStyler {
+
+ constructor( options = {} ) {
+
+ this.filter = options.filter || ( () => true );
+ this._layerOrder = options.layerOrder || DEFAULT_LAYER_ORDER;
+ this._styles = {};
+
+ const colorsToSet = Object.assign( {}, LAYER_COLORS, options.styles || {} );
+ for ( const key in colorsToSet ) {
+
+ _color.set( colorsToSet[ key ] );
+ this._styles[ key ] = {
+ hex: _color.getHex(),
+ css: _color.getStyle()
+ };
+
+ }
+
+ }
+
+ getColor( layerName, format = 'hex' ) {
+
+ const style = this._styles[ layerName ] || this._styles[ 'default' ];
+ return format === 'css' ? style.css : style.hex;
+
+ }
+
+ sortLayers( layerNames ) {
+
+ return [ ...layerNames ].sort( ( a, b ) => {
+
+ let idxA = this._layerOrder.indexOf( a );
+ let idxB = this._layerOrder.indexOf( b );
+ if ( idxA === - 1 ) idxA = 0;
+ if ( idxB === - 1 ) idxB = 0;
+ return idxA - idxB;
+
+ } );
+
+ }
+
+ shouldIncludeFeature( feature, layerName ) {
+
+ return this.filter( feature, layerName );
+
+ }
+
+}
diff --git a/src/three/renderer/utils/layerColors.js b/src/three/renderer/utils/layerColors.js
new file mode 100644
index 000000000..795a87ad9
--- /dev/null
+++ b/src/three/renderer/utils/layerColors.js
@@ -0,0 +1,34 @@
+/* non exhaustive list of layer colors */
+export const LAYER_COLORS = {
+ // Nature & Water
+ 'water': 0x201f20,
+ 'waterway': 0x201f20,
+ 'landuse': 0xcaedc1,
+ 'landuse_overlay': 0xcaedc1,
+ 'park': 0x5da859,
+
+ // Infrastructure
+ 'building': 0xeeeeee,
+ 'road': 0x444444,
+ 'transportation': 0x444444,
+
+ // Boundaries & Background
+ 'boundaries': 0x444545,
+ 'background': 0x111111,
+ 'default': 0x222222
+};
+
+/* Default layer ordering for vector tiles (bottom to top) */
+export const DEFAULT_LAYER_ORDER = [
+ 'landuse',
+ 'landuse_overlay',
+ 'park',
+ 'water',
+ 'waterway',
+ 'transportation',
+ 'road',
+ 'building',
+ 'boundaries',
+ 'poi',
+ 'place_label'
+];