├── .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 | [](https://travis-ci.com/mapbox/martini) [](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 | 
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 |
--------------------------------------------------------------------------------