├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── bench.js ├── index.d.ts ├── index.js ├── martini.gif ├── package.json ├── rollup.config.mjs └── test ├── fixtures └── fuji.png ├── test.js └── util.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | yarn.lock 3 | *.log 4 | martini.js 5 | martini.min.js 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "14" 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ISC License 2 | 3 | Copyright (c) 2019, Mapbox 4 | 5 | Permission to use, copy, modify, and/or distribute this software for any purpose 6 | with or without fee is hereby granted, provided that the above copyright notice 7 | and this permission notice appear in all copies. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 10 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND 11 | FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 12 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS 13 | OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER 14 | TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF 15 | THIS SOFTWARE. 16 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MARTINI 2 | 3 | [![Build Status](https://travis-ci.com/mapbox/martini.svg?branch=main)](https://travis-ci.com/mapbox/martini) [![Simply Awesome](https://img.shields.io/badge/simply-awesome-brightgreen.svg)](https://github.com/mourner/projects) 4 | 5 | MARTINI stands for **Mapbox's Awesome Right-Triangulated Irregular Networks, Improved**. 6 | 7 | It's an experimental JavaScript library for **real-time terrain mesh generation** from height data. Given a (2k+1) × (2k+1) terrain grid, it generates a hierarchy of triangular meshes of varying level of detail in milliseconds. _A work in progress._ 8 | 9 | See the algorithm in action and read more about how it works in [this interactive Observable notebook](https://observablehq.com/@mourner/martin-real-time-rtin-terrain-mesh). 10 | 11 | Based on the paper ["Right-Triangulated Irregular Networks" by Will Evans et. al. (1997)](https://www.cs.ubc.ca/~will/papers/rtin.pdf). 12 | 13 | ![MARTINI terrain demo](martini.gif) 14 | 15 | ## Example 16 | 17 | ```js 18 | // set up mesh generator for a certain 2^k+1 grid size 19 | const martini = new Martini(257); 20 | 21 | // generate RTIN hierarchy from terrain data (an array of size^2 length) 22 | const tile = martini.createTile(terrain); 23 | 24 | // get a mesh (vertices and triangles indices) for a 10m error 25 | const mesh = tile.getMesh(10); 26 | ``` 27 | 28 | ## Install 29 | 30 | ```bash 31 | npm install @mapbox/martini 32 | ``` 33 | 34 | ### Ports to other languages 35 | 36 | - [pymartini](https://github.com/kylebarron/pymartini) (Python) 37 | -------------------------------------------------------------------------------- /bench.js: -------------------------------------------------------------------------------- 1 | 2 | import fs from 'fs'; 3 | import {PNG} from 'pngjs'; 4 | import Martini from './index.js'; 5 | import {mapboxTerrainToGrid} from './test/util.js'; 6 | 7 | const png = PNG.sync.read(fs.readFileSync('./test/fixtures/fuji.png')); 8 | 9 | const terrain = mapboxTerrainToGrid(png); 10 | 11 | console.time('init tileset'); 12 | const martini = new Martini(png.width + 1); 13 | console.timeEnd('init tileset'); 14 | 15 | console.time('create tile'); 16 | const tile = martini.createTile(terrain); 17 | console.timeEnd('create tile'); 18 | 19 | console.time('mesh'); 20 | const mesh = tile.getMesh(30); 21 | console.timeEnd('mesh'); 22 | 23 | console.log(`vertices: ${mesh.vertices.length / 2}, triangles: ${mesh.triangles.length / 3}`); 24 | 25 | console.time('20 meshes total'); 26 | for (let i = 0; i <= 20; i++) { 27 | console.time(`mesh ${i}`); 28 | tile.getMesh(i); 29 | console.timeEnd(`mesh ${i}`); 30 | } 31 | console.timeEnd('20 meshes total'); 32 | -------------------------------------------------------------------------------- /index.d.ts: -------------------------------------------------------------------------------- 1 | export default class Martini { 2 | constructor(gridSize?: number); 3 | createTile(terrain: ArrayLike): Tile; 4 | } 5 | 6 | export class Tile { 7 | constructor(terrain: ArrayLike, martini: Martini); 8 | update(): void; 9 | getMesh(maxError?: number): { 10 | vertices: Uint16Array; 11 | triangles: Uint32Array; 12 | }; 13 | } 14 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 2 | export default class Martini { 3 | constructor(gridSize = 257) { 4 | this.gridSize = gridSize; 5 | const tileSize = gridSize - 1; 6 | if (tileSize & (tileSize - 1)) throw new Error( 7 | `Expected grid size to be 2^n+1, got ${gridSize}.`); 8 | 9 | this.numTriangles = tileSize * tileSize * 2 - 2; 10 | this.numParentTriangles = this.numTriangles - tileSize * tileSize; 11 | 12 | this.indices = new Uint32Array(this.gridSize * this.gridSize); 13 | 14 | // coordinates for all possible triangles in an RTIN tile 15 | this.coords = new Uint16Array(this.numTriangles * 4); 16 | 17 | // get triangle coordinates from its index in an implicit binary tree 18 | for (let i = 0; i < this.numTriangles; i++) { 19 | let id = i + 2; 20 | let ax = 0, ay = 0, bx = 0, by = 0, cx = 0, cy = 0; 21 | if (id & 1) { 22 | bx = by = cx = tileSize; // bottom-left triangle 23 | } else { 24 | ax = ay = cy = tileSize; // top-right triangle 25 | } 26 | while ((id >>= 1) > 1) { 27 | const mx = (ax + bx) >> 1; 28 | const my = (ay + by) >> 1; 29 | 30 | if (id & 1) { // left half 31 | bx = ax; by = ay; 32 | ax = cx; ay = cy; 33 | } else { // right half 34 | ax = bx; ay = by; 35 | bx = cx; by = cy; 36 | } 37 | cx = mx; cy = my; 38 | } 39 | const k = i * 4; 40 | this.coords[k + 0] = ax; 41 | this.coords[k + 1] = ay; 42 | this.coords[k + 2] = bx; 43 | this.coords[k + 3] = by; 44 | } 45 | } 46 | 47 | createTile(terrain) { 48 | return new Tile(terrain, this); 49 | } 50 | } 51 | 52 | class Tile { 53 | constructor(terrain, martini) { 54 | const size = martini.gridSize; 55 | if (terrain.length !== size * size) throw new Error( 56 | `Expected terrain data of length ${size * size} (${size} x ${size}), got ${terrain.length}.`); 57 | 58 | this.terrain = terrain; 59 | this.martini = martini; 60 | this.errors = new Float32Array(terrain.length); 61 | this.update(); 62 | } 63 | 64 | update() { 65 | const {numTriangles, numParentTriangles, coords, gridSize: size} = this.martini; 66 | const {terrain, errors} = this; 67 | 68 | // iterate over all possible triangles, starting from the smallest level 69 | for (let i = numTriangles - 1; i >= 0; i--) { 70 | const k = i * 4; 71 | const ax = coords[k + 0]; 72 | const ay = coords[k + 1]; 73 | const bx = coords[k + 2]; 74 | const by = coords[k + 3]; 75 | const mx = (ax + bx) >> 1; 76 | const my = (ay + by) >> 1; 77 | const cx = mx + my - ay; 78 | const cy = my + ax - mx; 79 | 80 | // calculate error in the middle of the long edge of the triangle 81 | const interpolatedHeight = (terrain[ay * size + ax] + terrain[by * size + bx]) / 2; 82 | const middleIndex = my * size + mx; 83 | const middleError = Math.abs(interpolatedHeight - terrain[middleIndex]); 84 | 85 | errors[middleIndex] = Math.max(errors[middleIndex], middleError); 86 | 87 | if (i < numParentTriangles) { // bigger triangles; accumulate error with children 88 | const leftChildIndex = ((ay + cy) >> 1) * size + ((ax + cx) >> 1); 89 | const rightChildIndex = ((by + cy) >> 1) * size + ((bx + cx) >> 1); 90 | errors[middleIndex] = Math.max(errors[middleIndex], errors[leftChildIndex], errors[rightChildIndex]); 91 | } 92 | } 93 | } 94 | 95 | getMesh(maxError = 0) { 96 | const {gridSize: size, indices} = this.martini; 97 | const {errors} = this; 98 | let numVertices = 0; 99 | let numTriangles = 0; 100 | const max = size - 1; 101 | 102 | // use an index grid to keep track of vertices that were already used to avoid duplication 103 | indices.fill(0); 104 | 105 | // retrieve mesh in two stages that both traverse the error map: 106 | // - countElements: find used vertices (and assign each an index), and count triangles (for minimum allocation) 107 | // - processTriangle: fill the allocated vertices & triangles typed arrays 108 | 109 | function countElements(ax, ay, bx, by, cx, cy) { 110 | const mx = (ax + bx) >> 1; 111 | const my = (ay + by) >> 1; 112 | 113 | if (Math.abs(ax - cx) + Math.abs(ay - cy) > 1 && errors[my * size + mx] > maxError) { 114 | countElements(cx, cy, ax, ay, mx, my); 115 | countElements(bx, by, cx, cy, mx, my); 116 | } else { 117 | indices[ay * size + ax] = indices[ay * size + ax] || ++numVertices; 118 | indices[by * size + bx] = indices[by * size + bx] || ++numVertices; 119 | indices[cy * size + cx] = indices[cy * size + cx] || ++numVertices; 120 | numTriangles++; 121 | } 122 | } 123 | countElements(0, 0, max, max, max, 0); 124 | countElements(max, max, 0, 0, 0, max); 125 | 126 | const vertices = new Uint16Array(numVertices * 2); 127 | const triangles = new Uint32Array(numTriangles * 3); 128 | let triIndex = 0; 129 | 130 | function processTriangle(ax, ay, bx, by, cx, cy) { 131 | const mx = (ax + bx) >> 1; 132 | const my = (ay + by) >> 1; 133 | 134 | if (Math.abs(ax - cx) + Math.abs(ay - cy) > 1 && errors[my * size + mx] > maxError) { 135 | // triangle doesn't approximate the surface well enough; drill down further 136 | processTriangle(cx, cy, ax, ay, mx, my); 137 | processTriangle(bx, by, cx, cy, mx, my); 138 | 139 | } else { 140 | // add a triangle 141 | const a = indices[ay * size + ax] - 1; 142 | const b = indices[by * size + bx] - 1; 143 | const c = indices[cy * size + cx] - 1; 144 | 145 | vertices[2 * a] = ax; 146 | vertices[2 * a + 1] = ay; 147 | 148 | vertices[2 * b] = bx; 149 | vertices[2 * b + 1] = by; 150 | 151 | vertices[2 * c] = cx; 152 | vertices[2 * c + 1] = cy; 153 | 154 | triangles[triIndex++] = a; 155 | triangles[triIndex++] = b; 156 | triangles[triIndex++] = c; 157 | } 158 | } 159 | processTriangle(0, 0, max, max, max, 0); 160 | processTriangle(max, max, 0, 0, 0, max); 161 | 162 | return {vertices, triangles}; 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /martini.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mapbox/martini/f0d6dcaeb656b4d256c0da5bfe53de9262c4a8f1/martini.gif -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@mapbox/martini", 3 | "version": "0.2.0", 4 | "description": "A JavaScript library for real-time terrain mesh generation", 5 | "main": "martini.js", 6 | "unpkg": "martini.min.js", 7 | "module": "index.js", 8 | "types": "index.d.ts", 9 | "scripts": { 10 | "pretest": "eslint index.js bench.js test", 11 | "test": "node -r esm test/test.js", 12 | "bench": "node -r esm bench.js", 13 | "build": "rollup -c", 14 | "prepublishOnly": "npm run test && npm run build" 15 | }, 16 | "keywords": [ 17 | "terrain", 18 | "rtin", 19 | "mesh", 20 | "3d", 21 | "webgl" 22 | ], 23 | "author": "Vladimir Agafonkin", 24 | "license": "ISC", 25 | "devDependencies": { 26 | "@rollup/plugin-terser": "^0.1.0", 27 | "eslint": "^8.26.0", 28 | "eslint-config-mourner": "^3.0.0", 29 | "esm": "^3.2.25", 30 | "pngjs": "^6.0.0", 31 | "rollup": "^3.2.3", 32 | "tape": "^5.6.1" 33 | }, 34 | "files": [ 35 | "index.js", 36 | "martini.js", 37 | "martini.min.js" 38 | ], 39 | "eslintConfig": { 40 | "extends": "mourner", 41 | "rules": { 42 | "no-use-before-define": 0 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /rollup.config.mjs: -------------------------------------------------------------------------------- 1 | import terser from '@rollup/plugin-terser'; 2 | 3 | const config = (file, plugins = []) => ({ 4 | input: 'index.js', 5 | output: { 6 | name: 'Martini', 7 | format: 'umd', 8 | indent: false, 9 | file 10 | }, 11 | plugins 12 | }); 13 | 14 | export default [ 15 | config('martini.js'), 16 | config('martini.min.js', [terser()]) 17 | ]; 18 | -------------------------------------------------------------------------------- /test/fixtures/fuji.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mapbox/martini/f0d6dcaeb656b4d256c0da5bfe53de9262c4a8f1/test/fixtures/fuji.png -------------------------------------------------------------------------------- /test/test.js: -------------------------------------------------------------------------------- 1 | 2 | import fs from 'fs'; 3 | import {PNG} from 'pngjs'; 4 | import test from 'tape'; 5 | import Martini from '../index.js'; 6 | import {mapboxTerrainToGrid} from './util.js'; 7 | 8 | const fuji = PNG.sync.read(fs.readFileSync('./test/fixtures/fuji.png')); 9 | const terrain = mapboxTerrainToGrid(fuji); 10 | 11 | test('generates a mesh', (t) => { 12 | const martini = new Martini(fuji.width + 1); 13 | const tile = martini.createTile(terrain); 14 | const mesh = tile.getMesh(500); 15 | 16 | t.same([ 17 | 320, 64, 256, 128, 320, 128, 384, 128, 256, 0, 288, 160, 256, 192, 288, 192, 320, 192, 304, 176, 256, 256, 288, 18 | 224, 352, 160, 320, 160, 512, 0, 384, 0, 128, 128, 128, 0, 64, 64, 64, 0, 0, 0, 32, 32, 192, 192, 384, 384, 512, 19 | 256, 384, 256, 320, 320, 320, 256, 512, 512, 512, 128, 448, 192, 384, 192, 128, 384, 256, 512, 256, 384, 0, 20 | 512, 128, 256, 64, 192, 0, 256, 64, 128, 32, 96, 0, 128, 32, 64, 16, 48, 0, 64, 0, 32 21 | ], Array.from(mesh.vertices), 'correct vertices'); 22 | 23 | t.same([ 24 | 0, 1, 2, 3, 0, 2, 4, 1, 0, 5, 6, 7, 7, 8, 9, 5, 7, 9, 1, 6, 5, 6, 10, 11, 11, 8, 7, 6, 11, 7, 12, 2, 13, 8, 12, 25 | 13, 3, 2, 12, 2, 1, 5, 13, 5, 9, 8, 13, 9, 2, 5, 13, 3, 14, 15, 15, 4, 0, 3, 15, 0, 16, 4, 17, 18, 17, 19, 19, 26 | 20, 21, 18, 19, 21, 16, 17, 18, 1, 16, 22, 22, 10, 6, 1, 22, 6, 4, 16, 1, 23, 24, 25, 26, 25, 27, 10, 26, 27, 27 | 23, 25, 26, 28, 24, 23, 29, 3, 30, 24, 29, 30, 14, 3, 29, 8, 25, 31, 31, 3, 12, 8, 31, 12, 27, 8, 11, 10, 27, 28 | 11, 25, 8, 27, 25, 24, 30, 30, 3, 31, 25, 30, 31, 32, 33, 34, 10, 32, 34, 35, 33, 32, 33, 28, 23, 34, 23, 26, 29 | 10, 34, 26, 33, 23, 34, 36, 16, 37, 38, 36, 37, 36, 10, 22, 16, 36, 22, 39, 18, 40, 41, 39, 40, 16, 18, 39, 42, 30 | 21, 43, 44, 42, 43, 18, 21, 42, 21, 20, 45, 45, 44, 43, 21, 45, 43, 44, 41, 40, 40, 18, 42, 44, 40, 42, 41, 38, 31 | 37, 37, 16, 39, 41, 37, 39, 38, 35, 32, 32, 10, 36, 38, 32, 36 32 | ], Array.from(mesh.triangles), 'correct triangles'); 33 | 34 | t.end(); 35 | }); 36 | -------------------------------------------------------------------------------- /test/util.js: -------------------------------------------------------------------------------- 1 | 2 | export function mapboxTerrainToGrid(png) { 3 | const gridSize = png.width + 1; 4 | const terrain = new Float32Array(gridSize * gridSize); 5 | 6 | const tileSize = png.width; 7 | 8 | // decode terrain values 9 | for (let y = 0; y < tileSize; y++) { 10 | for (let x = 0; x < tileSize; x++) { 11 | const k = (y * tileSize + x) * 4; 12 | const r = png.data[k + 0]; 13 | const g = png.data[k + 1]; 14 | const b = png.data[k + 2]; 15 | terrain[y * gridSize + x] = (r * 256 * 256 + g * 256.0 + b) / 10.0 - 10000.0; 16 | } 17 | } 18 | // backfill right and bottom borders 19 | for (let x = 0; x < gridSize - 1; x++) { 20 | terrain[gridSize * (gridSize - 1) + x] = terrain[gridSize * (gridSize - 2) + x]; 21 | } 22 | for (let y = 0; y < gridSize; y++) { 23 | terrain[gridSize * y + gridSize - 1] = terrain[gridSize * y + gridSize - 2]; 24 | } 25 | 26 | return terrain; 27 | } 28 | --------------------------------------------------------------------------------