@driftengine/terrain 3.61.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,159 @@
1
+ /** How far along an edge a vertex sits on the coarse lattice, and the two coarse heights round it. */
2
+ function chord(low, high, at, height) {
3
+ if (high === low)
4
+ return height(low);
5
+ const t = (at - low) / (high - low);
6
+ return height(low) * (1 - t) + height(high) * t;
7
+ }
8
+ export function heightfieldPatch(terrain, options) {
9
+ const step = Math.round(options.step ?? 1);
10
+ const cells = Math.round(options.cells);
11
+ if (!(step >= 1))
12
+ throw new Error(`heightfieldPatch: step must be at least 1, got ${step}`);
13
+ if (cells % step !== 0) {
14
+ throw new Error(`heightfieldPatch: a step of ${step} does not divide ${cells} cells, so a strip would go undrawn`);
15
+ }
16
+ if (options.x < 0 ||
17
+ options.z < 0 ||
18
+ options.x + cells > terrain.width - 1 ||
19
+ options.z + cells > terrain.depth - 1) {
20
+ throw new Error(`heightfieldPatch: a patch at ${options.x}, ${options.z} of ${cells} cells runs off a field ` +
21
+ `of ${terrain.width} by ${terrain.depth}`);
22
+ }
23
+ const neighbours = options.neighbours ?? {};
24
+ for (const [side, theirs] of Object.entries(neighbours)) {
25
+ if (theirs === undefined)
26
+ continue;
27
+ if (theirs % step !== 0) {
28
+ throw new Error(`heightfieldPatch: a neighbour on ${side} drawn at ${theirs} cannot be matched by a patch ` +
29
+ `drawn at ${step}, since one does not divide the other`);
30
+ }
31
+ }
32
+ const across = cells / step;
33
+ const vertices = (across + 1) * (across + 1);
34
+ const spacing = terrain.spacingM;
35
+ const originX = (terrain.origin[0] ?? 0) + options.x * spacing;
36
+ const originZ = (terrain.origin[2] ?? 0) + options.z * spacing;
37
+ const materials = options.materials;
38
+ const colour = options.color ?? [0.42, 0.45, 0.36];
39
+ const emissive = options.emissive ?? 0;
40
+ const positions = new Float32Array(vertices * 3);
41
+ const normals = new Float32Array(vertices * 3);
42
+ const colors = new Float32Array(vertices * 3);
43
+ const emissives = new Float32Array(vertices);
44
+ /* Allocated only where something shines, which is the promise `MeshData.specular` makes: geometry
45
+ that does not shine produces exactly the mesh it produced before the attribute existed. */
46
+ const speculars = materials?.shines === true ? new Float32Array(vertices) : null;
47
+ const indices = new Uint32Array(across * across * 6);
48
+ const normal = new Float32Array(3);
49
+ const shade = new Float32Array(3);
50
+ for (let row = 0; row <= across; row++) {
51
+ for (let column = 0; column <= across; column++) {
52
+ const at = (row * (across + 1) + column) * 3;
53
+ const x = originX + column * step * spacing;
54
+ const z = originZ + row * step * spacing;
55
+ /*
56
+ * The field's own height, unless this vertex is on an edge whose neighbour is coarser — in
57
+ * which case it takes that neighbour's chord instead, and the two patches then share one
58
+ * polyline. A corner sits on two edges and is a multiple of every step, so it is never moved
59
+ * and the two rules cannot disagree about it.
60
+ */
61
+ let y = terrain.heightAt(x, z);
62
+ const onMinusX = column === 0;
63
+ const onPlusX = column === across;
64
+ const onMinusZ = row === 0;
65
+ const onPlusZ = row === across;
66
+ if (onMinusX && neighbours.minusX !== undefined) {
67
+ y = matched(row, neighbours.minusX, (index) => terrain.heightAt(x, originZ + index * spacing));
68
+ }
69
+ else if (onPlusX && neighbours.plusX !== undefined) {
70
+ y = matched(row, neighbours.plusX, (index) => terrain.heightAt(x, originZ + index * spacing));
71
+ }
72
+ else if (onMinusZ && neighbours.minusZ !== undefined) {
73
+ y = matched(column, neighbours.minusZ, (index) => terrain.heightAt(originX + index * spacing, z));
74
+ }
75
+ else if (onPlusZ && neighbours.plusZ !== undefined) {
76
+ y = matched(column, neighbours.plusZ, (index) => terrain.heightAt(originX + index * spacing, z));
77
+ }
78
+ positions[at] = x;
79
+ positions[at + 1] = y;
80
+ positions[at + 2] = z;
81
+ /* The field's normal and never the patch's, so two patches shade identically along the edge
82
+ they share. `heightfield.ts` argues that where the query is defined. */
83
+ terrain.normalAt(x, z, normal);
84
+ normals[at] = normal[0] ?? 0;
85
+ normals[at + 1] = normal[1] ?? 1;
86
+ normals[at + 2] = normal[2] ?? 0;
87
+ if (materials === undefined) {
88
+ colors[at] = colour[0] ?? 1;
89
+ colors[at + 1] = colour[1] ?? 1;
90
+ colors[at + 2] = colour[2] ?? 1;
91
+ emissives[row * (across + 1) + column] = emissive;
92
+ }
93
+ else {
94
+ /* The field's own extent, so the blend is continuous across every patch boundary. */
95
+ const u = terrain.extentX === 0 ? 0 : (x - (terrain.origin[0] ?? 0)) / terrain.extentX;
96
+ const w = terrain.extentZ === 0 ? 0 : (z - (terrain.origin[2] ?? 0)) / terrain.extentZ;
97
+ materials.colorAt(u, w, shade);
98
+ colors[at] = shade[0] ?? 1;
99
+ colors[at + 1] = shade[1] ?? 1;
100
+ colors[at + 2] = shade[2] ?? 1;
101
+ emissives[row * (across + 1) + column] = materials.emissiveAt(u, w);
102
+ if (speculars !== null) {
103
+ speculars[row * (across + 1) + column] = materials.specularAt(u, w);
104
+ }
105
+ }
106
+ }
107
+ }
108
+ /**
109
+ * Where a vertex on a matched edge actually sits.
110
+ *
111
+ * `index` counts drawn cells along the edge and `theirs` is the neighbour's step in field cells,
112
+ * so `theirs / step` drawn vertices span one of the neighbour's. A vertex on that lattice keeps
113
+ * the field; one between two of them takes the chord.
114
+ */
115
+ function matched(index, theirs, height) {
116
+ const ratio = theirs / step;
117
+ const low = Math.floor(index / ratio) * ratio;
118
+ const high = Math.min(across, low + ratio);
119
+ return chord(low * step, high * step, index * step, height);
120
+ }
121
+ let at = 0;
122
+ for (let row = 0; row < across; row++) {
123
+ for (let column = 0; column < across; column++) {
124
+ const a = row * (across + 1) + column;
125
+ const b = a + 1;
126
+ const d = a + (across + 1);
127
+ const c = d + 1;
128
+ /*
129
+ * **Split along `a`-`c`, which is the diagonal `Terrain.heightAt` reads.** The two surfaces
130
+ * are the same surface or a query answers a place the picture does not have.
131
+ *
132
+ * This said `a, d, b` and `b, d, c` until 2026-09-02, which is the *other* diagonal — the
133
+ * mesh was split one way and the query read the other, so the two disagreed by up to the
134
+ * height of the cell everywhere except along the anti-diagonal itself. **Nothing caught it
135
+ * for two rows**: every vertex lies on both triangulations, so a per-vertex assertion passes
136
+ * either way, and the test fields were of the form `f(x) + g(z)` — separable, and for a
137
+ * separable field both diagonals give the identical surface. It surfaced when a cross term
138
+ * went into the field and a ray cast against the mesh landed 11.6 mm off the query.
139
+ *
140
+ * Wound so the face normal points up either way: with x to the right and z away, `a, d, c`
141
+ * and `a, c, b` both cross upward.
142
+ */
143
+ indices[at++] = a;
144
+ indices[at++] = d;
145
+ indices[at++] = c;
146
+ indices[at++] = a;
147
+ indices[at++] = c;
148
+ indices[at++] = b;
149
+ }
150
+ }
151
+ return {
152
+ positions,
153
+ normals,
154
+ colors,
155
+ emissive: emissives,
156
+ ...(speculars === null ? {} : { specular: speculars }),
157
+ indices,
158
+ };
159
+ }
@@ -0,0 +1,22 @@
1
+ /*! DriftEngine | Copyright 2026 Drift Technologies | Apache-2.0 | https://github.com/drftrun/driftengine */
2
+ /**
3
+ * `@driftengine/terrain` — a heightfield, the surface it draws, and the same answer to both.
4
+ *
5
+ * **It imports no renderer.** What it hands back is `MeshData`, which a consumer gives to
6
+ * `createMesh` like any other geometry, so terrain is drawn by the pass that already exists rather
7
+ * than by one of its own. That is why this is a package and not a part of core: a world with no
8
+ * terrain in it pays nothing, and a world with terrain pays for arithmetic rather than for a
9
+ * second renderer.
10
+ *
11
+ * The three things it is careful about are written where they are decided — `heightfield.ts` on why
12
+ * a query reads the triangle rather than the bilinear patch, `heightfieldPatch.ts` on why a seam
13
+ * between two levels of detail is matched rather than covered with a skirt, and
14
+ * `terrainMaterials.ts` on why a weight map is read the *other* way, bilinearly, and baked into
15
+ * vertex colour rather than sampled by a shader.
16
+ */
17
+ export { Terrain } from './heightfield.ts';
18
+ export type { HeightfieldOptions } from './heightfield.ts';
19
+ export { heightfieldPatch } from './heightfieldPatch.ts';
20
+ export type { HeightfieldPatchOptions } from './heightfieldPatch.ts';
21
+ export { TerrainMaterials, terrainMaterialWeights } from './terrainMaterials.ts';
22
+ export type { TerrainMaterial, TerrainMaterialsOptions } from './terrainMaterials.ts';
package/dist/index.js ADDED
@@ -0,0 +1,19 @@
1
+ /*! DriftEngine | Copyright 2026 Drift Technologies | Apache-2.0 | https://github.com/drftrun/driftengine */
2
+ /**
3
+ * `@driftengine/terrain` — a heightfield, the surface it draws, and the same answer to both.
4
+ *
5
+ * **It imports no renderer.** What it hands back is `MeshData`, which a consumer gives to
6
+ * `createMesh` like any other geometry, so terrain is drawn by the pass that already exists rather
7
+ * than by one of its own. That is why this is a package and not a part of core: a world with no
8
+ * terrain in it pays nothing, and a world with terrain pays for arithmetic rather than for a
9
+ * second renderer.
10
+ *
11
+ * The three things it is careful about are written where they are decided — `heightfield.ts` on why
12
+ * a query reads the triangle rather than the bilinear patch, `heightfieldPatch.ts` on why a seam
13
+ * between two levels of detail is matched rather than covered with a skirt, and
14
+ * `terrainMaterials.ts` on why a weight map is read the *other* way, bilinearly, and baked into
15
+ * vertex colour rather than sampled by a shader.
16
+ */
17
+ export { Terrain } from './heightfield.js';
18
+ export { heightfieldPatch } from './heightfieldPatch.js';
19
+ export { TerrainMaterials, terrainMaterialWeights } from './terrainMaterials.js';
@@ -0,0 +1,79 @@
1
+ import type { ReadonlyVec3 } from 'gl-matrix';
2
+ /**
3
+ * Several materials blended across one field, as vertex colour.
4
+ *
5
+ * **A weight map baked into vertex colour, and that is the idiomatic answer here rather than a
6
+ * compromise.** This engine's colour *is* vertex data — it is what makes the world one flat-shaded
7
+ * draw call, and the root README says so where it introduces `drawMesh`'s tint. A splat map read by
8
+ * a shader would mean a texture bound on every terrain draw and a branch in `flatFrag`, which Track
9
+ * D measured at 19.8 KB gzipped for a capability generated into sixteen fragment permutations. This
10
+ * costs nothing at all at runtime: the blend happens once, where the patch is built.
11
+ *
12
+ * **What that gives up, and it is the honest limit**: the blend is only as sharp as the mesh. A
13
+ * material boundary inside a cell is drawn as a gradient across that cell, and a patch drawn at a
14
+ * quarter detail blends four times as coarsely as one at full detail. A consumer who wants a sharp
15
+ * line — a road edge, a cliff base — draws that patch at full detail, which is the same dial the
16
+ * geometry already uses.
17
+ *
18
+ * **The weights are read bilinearly, which is the opposite of how a height is read**, and the
19
+ * contrast is the whole reason both decisions are written down. A height is read as the *triangle*
20
+ * because the surface is drawn as triangles and the query has to agree with the picture
21
+ * (`heightfield.ts` argues that at length). A weight is not drawn: it decides a vertex colour, and
22
+ * the vertex is wherever the mesh put it. There is no triangulation to agree with, so the smooth
23
+ * interpolation is simply the better one — reading a weight map as triangles would put a visible
24
+ * crease along every cell diagonal for nothing.
25
+ */
26
+ export interface TerrainMaterial {
27
+ readonly color: ReadonlyVec3;
28
+ /** Self-illumination, revealed at night. Zero unless a material glows. */
29
+ readonly emissive?: number;
30
+ /** How sharply this material takes a sun highlight, 0 to 1. */
31
+ readonly specular?: number;
32
+ }
33
+ export interface TerrainMaterialsOptions {
34
+ /** The materials being blended. At least one. */
35
+ readonly materials: readonly TerrainMaterial[];
36
+ /** Samples of the weight map across x. It need not match the heightfield's own resolution. */
37
+ readonly width: number;
38
+ /** Samples across z. */
39
+ readonly depth: number;
40
+ /**
41
+ * `width * depth * materials.length` weights, row-major with the material running fastest.
42
+ *
43
+ * They need not sum to anything: `terrainMaterialWeights` normalises what it reads, because a
44
+ * map is painted or generated and nothing balances its channels.
45
+ */
46
+ readonly weights: ArrayLike<number>;
47
+ }
48
+ /**
49
+ * The normalised weights at a point in the field's own `0..1` extent.
50
+ *
51
+ * **Normalised, and a sample that weighs nothing falls back to the first material.** An unpainted
52
+ * corner of a map sums to zero, and dividing by that gives a NaN or a black patch depending on
53
+ * where it lands — both of which read as a rendering fault rather than as an unpainted map. Black
54
+ * is not a colour terrain should ever be by accident.
55
+ *
56
+ * `out` must hold one number per material; it is returned for convenience and is the caller's.
57
+ */
58
+ export declare function terrainMaterialWeights(map: TerrainMaterials, u: number, v: number, out: Float32Array): Float32Array;
59
+ export declare class TerrainMaterials {
60
+ readonly materials: readonly TerrainMaterial[];
61
+ readonly width: number;
62
+ readonly depth: number;
63
+ readonly weights: Float32Array;
64
+ /**
65
+ * Whether any material shines, so a patch built from this uploads a specular buffer only if one
66
+ * is wanted — the promise `MeshData.specular` makes and the reason it is optional.
67
+ */
68
+ readonly shines: boolean;
69
+ /** The same question for emissive, and for the same reason. */
70
+ readonly glows: boolean;
71
+ private readonly scratch;
72
+ constructor(options: TerrainMaterialsOptions);
73
+ /** The blended colour at a point in the field's `0..1` extent. */
74
+ colorAt(u: number, v: number, out: Float32Array): Float32Array;
75
+ /** The blended self-illumination, on the same weights the colour used. */
76
+ emissiveAt(u: number, v: number): number;
77
+ /** The blended highlight, likewise. */
78
+ specularAt(u: number, v: number): number;
79
+ }
@@ -0,0 +1,111 @@
1
+ /** Scratch for one blend, so shading a patch allocates nothing per vertex. */
2
+ const SCRATCH = new Float32Array(16);
3
+ /**
4
+ * The normalised weights at a point in the field's own `0..1` extent.
5
+ *
6
+ * **Normalised, and a sample that weighs nothing falls back to the first material.** An unpainted
7
+ * corner of a map sums to zero, and dividing by that gives a NaN or a black patch depending on
8
+ * where it lands — both of which read as a rendering fault rather than as an unpainted map. Black
9
+ * is not a colour terrain should ever be by accident.
10
+ *
11
+ * `out` must hold one number per material; it is returned for convenience and is the caller's.
12
+ */
13
+ export function terrainMaterialWeights(map, u, v, out) {
14
+ const count = map.materials.length;
15
+ /* Clamped rather than wrapped: a field has an edge, and a query past it should answer the edge
16
+ rather than the far side of the map. */
17
+ const x = Math.min(map.width - 1, Math.max(0, u * (map.width - 1)));
18
+ const z = Math.min(map.depth - 1, Math.max(0, v * (map.depth - 1)));
19
+ const ix = Math.min(map.width - 2, Math.floor(x));
20
+ const iz = Math.min(map.depth - 2, Math.floor(z));
21
+ const fu = map.width < 2 ? 0 : x - ix;
22
+ const fv = map.depth < 2 ? 0 : z - iz;
23
+ const a = (iz * map.width + ix) * count;
24
+ const b = (iz * map.width + Math.min(map.width - 1, ix + 1)) * count;
25
+ const c = (Math.min(map.depth - 1, iz + 1) * map.width + ix) * count;
26
+ const d = (Math.min(map.depth - 1, iz + 1) * map.width + Math.min(map.width - 1, ix + 1)) * count;
27
+ let total = 0;
28
+ for (let m = 0; m < count; m++) {
29
+ const top = (map.weights[a + m] ?? 0) * (1 - fu) + (map.weights[b + m] ?? 0) * fu;
30
+ const bottom = (map.weights[c + m] ?? 0) * (1 - fu) + (map.weights[d + m] ?? 0) * fu;
31
+ const weight = top * (1 - fv) + bottom * fv;
32
+ out[m] = weight;
33
+ total += weight;
34
+ }
35
+ if (total <= 0) {
36
+ out[0] = 1;
37
+ for (let m = 1; m < count; m++)
38
+ out[m] = 0;
39
+ return out;
40
+ }
41
+ for (let m = 0; m < count; m++)
42
+ out[m] = (out[m] ?? 0) / total;
43
+ return out;
44
+ }
45
+ export class TerrainMaterials {
46
+ materials;
47
+ width;
48
+ depth;
49
+ weights;
50
+ /**
51
+ * Whether any material shines, so a patch built from this uploads a specular buffer only if one
52
+ * is wanted — the promise `MeshData.specular` makes and the reason it is optional.
53
+ */
54
+ shines;
55
+ /** The same question for emissive, and for the same reason. */
56
+ glows;
57
+ scratch;
58
+ constructor(options) {
59
+ const count = options.materials.length;
60
+ if (count < 1)
61
+ throw new Error('TerrainMaterials: needs at least one material to blend');
62
+ const needed = options.width * options.depth * count;
63
+ if (options.weights.length !== needed) {
64
+ throw new Error(`TerrainMaterials: ${options.width} by ${options.depth} over ${count} materials needs ` +
65
+ `${needed} weights, got ${options.weights.length}`);
66
+ }
67
+ this.materials = [...options.materials];
68
+ this.width = Math.round(options.width);
69
+ this.depth = Math.round(options.depth);
70
+ this.weights = Float32Array.from(options.weights);
71
+ this.shines = this.materials.some((material) => (material.specular ?? 0) !== 0);
72
+ this.glows = this.materials.some((material) => (material.emissive ?? 0) !== 0);
73
+ this.scratch = count <= SCRATCH.length ? SCRATCH : new Float32Array(count);
74
+ }
75
+ /** The blended colour at a point in the field's `0..1` extent. */
76
+ colorAt(u, v, out) {
77
+ const weights = terrainMaterialWeights(this, u, v, this.scratch);
78
+ let r = 0;
79
+ let g = 0;
80
+ let b = 0;
81
+ for (let m = 0; m < this.materials.length; m++) {
82
+ const weight = weights[m] ?? 0;
83
+ const colour = this.materials[m]?.color;
84
+ r += (colour?.[0] ?? 0) * weight;
85
+ g += (colour?.[1] ?? 0) * weight;
86
+ b += (colour?.[2] ?? 0) * weight;
87
+ }
88
+ out[0] = r;
89
+ out[1] = g;
90
+ out[2] = b;
91
+ return out;
92
+ }
93
+ /** The blended self-illumination, on the same weights the colour used. */
94
+ emissiveAt(u, v) {
95
+ const weights = terrainMaterialWeights(this, u, v, this.scratch);
96
+ let sum = 0;
97
+ for (let m = 0; m < this.materials.length; m++) {
98
+ sum += (this.materials[m]?.emissive ?? 0) * (weights[m] ?? 0);
99
+ }
100
+ return sum;
101
+ }
102
+ /** The blended highlight, likewise. */
103
+ specularAt(u, v) {
104
+ const weights = terrainMaterialWeights(this, u, v, this.scratch);
105
+ let sum = 0;
106
+ for (let m = 0; m < this.materials.length; m++) {
107
+ sum += (this.materials[m]?.specular ?? 0) * (weights[m] ?? 0);
108
+ }
109
+ return sum;
110
+ }
111
+ }
package/package.json ADDED
@@ -0,0 +1,59 @@
1
+ {
2
+ "name": "@driftengine/terrain",
3
+ "version": "3.61.0",
4
+ "description": "Heightfields: a query that answers the surface that is drawn, and patches that meet without cracks",
5
+ "license": "Apache-2.0",
6
+ "type": "module",
7
+ "main": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "drift-source": "./src/index.ts",
12
+ "types": "./dist/index.d.ts",
13
+ "default": "./dist/index.js"
14
+ },
15
+ "./package.json": "./package.json",
16
+ "./*": "./*"
17
+ },
18
+ "files": [
19
+ "dist",
20
+ "src",
21
+ "!src/**/*.test.ts",
22
+ "!src/**/*.test.mjs",
23
+ "!src/**/__snapshots__",
24
+ "README.md",
25
+ "LICENSE",
26
+ "NOTICE"
27
+ ],
28
+ "sideEffects": false,
29
+ "peerDependencies": {
30
+ "@driftengine/drft": "3.61.0"
31
+ },
32
+ "devDependencies": {
33
+ "@driftengine/physics": "3.61.0"
34
+ },
35
+ "author": "Drift Technologies",
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "git+https://github.com/drftrun/driftengine.git",
39
+ "directory": "packages/terrain"
40
+ },
41
+ "homepage": "https://github.com/drftrun/driftengine#readme",
42
+ "bugs": "https://github.com/drftrun/driftengine/issues",
43
+ "keywords": [
44
+ "driftengine",
45
+ "3d",
46
+ "webgl",
47
+ "webgpu",
48
+ "typescript",
49
+ "heightfield",
50
+ "terrain",
51
+ "level-of-detail"
52
+ ],
53
+ "engines": {
54
+ "node": ">=22.12.0"
55
+ },
56
+ "publishConfig": {
57
+ "access": "public"
58
+ }
59
+ }
@@ -0,0 +1,134 @@
1
+ import type { ReadonlyVec3 } from 'gl-matrix';
2
+
3
+ /**
4
+ * A heightfield, and the two questions everything else asks it.
5
+ *
6
+ * **The whole design of this file is one rule: the answer a query gives is the surface that is
7
+ * drawn.** A heightfield is stored as a lattice of samples and drawn as triangles, and those are
8
+ * not the same surface — a bilinear patch through four corners and the two triangles that span
9
+ * them agree only along their shared diagonal and at the corners. Interpolating bilinearly because
10
+ * it is the obvious thing gives a character that floats over half of every cell and sinks into the
11
+ * other half, by up to a quarter of the cell's height range, everywhere, for ever. It is a defect
12
+ * with no symptom a screenshot can show and no test that fails, which is exactly the kind this
13
+ * repository writes down.
14
+ *
15
+ * So `heightAt` reads the *triangle*, and `heightfieldPatch` builds those same triangles, and a
16
+ * test asserts each vertex against the query.
17
+ *
18
+ * **The normal is the other way round on purpose.** A face normal is constant over a triangle and
19
+ * jumps at every edge, so a field built from them is faceted, and — worse — two patches meeting at
20
+ * a boundary shade differently along it, which reads as a crack that is not there. `normalAt` is a
21
+ * central difference over the samples, continuous by construction, and it is what the mesh carries
22
+ * as its vertex normals. A caller who wants the plane a wheel actually rests on takes three
23
+ * `heightAt` samples; a caller who wants to orient a character takes this.
24
+ */
25
+
26
+ export interface HeightfieldOptions {
27
+ /** Samples across x. At least two, since one sample is not a cell. */
28
+ readonly width: number;
29
+ /** Samples across z. */
30
+ readonly depth: number;
31
+ /** Metres between samples, the same both ways. */
32
+ readonly spacingM: number;
33
+ /** `width * depth` heights in metres, row-major with x running fastest. */
34
+ readonly heights: ArrayLike<number>;
35
+ /** World position of sample `(0, 0)`. The origin's own y is added to every height. */
36
+ readonly origin?: ReadonlyVec3;
37
+ }
38
+
39
+ export class Terrain {
40
+ readonly width: number;
41
+ readonly depth: number;
42
+ readonly spacingM: number;
43
+ /** World position of sample `(0, 0)`, copied because a caller reuses its vectors. */
44
+ readonly origin = new Float32Array(3);
45
+ /** The samples, copied for the same reason and because this is read every frame. */
46
+ readonly heights: Float32Array;
47
+
48
+ constructor(options: HeightfieldOptions) {
49
+ const { width, depth, spacingM } = options;
50
+ if (!(width >= 2)) throw new Error(`Terrain: width must be at least 2, got ${width}`);
51
+ if (!(depth >= 2)) throw new Error(`Terrain: depth must be at least 2, got ${depth}`);
52
+ if (!(spacingM > 0)) throw new Error(`Terrain: spacing must be above zero, got ${spacingM}`);
53
+ if (options.heights.length !== width * depth) {
54
+ throw new Error(
55
+ `Terrain: ${width} by ${depth} needs ${width * depth} heights, got ${options.heights.length}`,
56
+ );
57
+ }
58
+ this.width = Math.round(width);
59
+ this.depth = Math.round(depth);
60
+ this.spacingM = spacingM;
61
+ this.heights = Float32Array.from(options.heights);
62
+ if (options.origin !== undefined) this.origin.set(options.origin as ArrayLike<number>);
63
+ }
64
+
65
+ /** How far the field reaches along x, metres. One less cell than it has samples. */
66
+ get extentX(): number {
67
+ return (this.width - 1) * this.spacingM;
68
+ }
69
+
70
+ get extentZ(): number {
71
+ return (this.depth - 1) * this.spacingM;
72
+ }
73
+
74
+ /** One sample, by grid index, clamped to the field rather than wrapping or answering NaN. */
75
+ sample(ix: number, iz: number): number {
76
+ const x = Math.min(this.width - 1, Math.max(0, ix));
77
+ const z = Math.min(this.depth - 1, Math.max(0, iz));
78
+ return (this.origin[1] ?? 0) + (this.heights[z * this.width + x] ?? 0);
79
+ }
80
+
81
+ /**
82
+ * The height of the drawn surface at a world position.
83
+ *
84
+ * **The triangle and not the bilinear patch**, for the reason at the top of this file. The cell
85
+ * is split along its `a`-`c` diagonal — from the sample at `(ix, iz)` to the one at
86
+ * `(ix + 1, iz + 1)` — and `heightfieldPatch` emits exactly that split, so the two cannot
87
+ * disagree about where the ground is.
88
+ *
89
+ * Clamped outside the field. A world has an edge and a query past it should answer the edge
90
+ * rather than extrapolate a slope into a place that has no samples.
91
+ */
92
+ heightAt(x: number, z: number): number {
93
+ const u = (x - (this.origin[0] ?? 0)) / this.spacingM;
94
+ const v = (z - (this.origin[2] ?? 0)) / this.spacingM;
95
+ const clampedU = Math.min(this.width - 1, Math.max(0, u));
96
+ const clampedV = Math.min(this.depth - 1, Math.max(0, v));
97
+ /* One short of the last sample, so `ix + 1` is always a sample and the cell is always whole. */
98
+ const ix = Math.min(this.width - 2, Math.floor(clampedU));
99
+ const iz = Math.min(this.depth - 2, Math.floor(clampedV));
100
+ const fu = clampedU - ix;
101
+ const fv = clampedV - iz;
102
+
103
+ const a = this.sample(ix, iz);
104
+ const c = this.sample(ix + 1, iz + 1);
105
+ if (fu >= fv) {
106
+ /* Below the diagonal: the triangle a, b, c. */
107
+ const b = this.sample(ix + 1, iz);
108
+ return a + (b - a) * fu + (c - b) * fv;
109
+ }
110
+ /* Above it: the triangle a, c, d. */
111
+ const d = this.sample(ix, iz + 1);
112
+ return a + (d - a) * fv + (c - d) * fu;
113
+ }
114
+
115
+ /**
116
+ * The surface normal at a world position, as a central difference over the samples.
117
+ *
118
+ * Continuous across every cell boundary, which is what stops two patches shading differently
119
+ * along the edge they share — the failure that reads as a crack in geometry that has none.
120
+ */
121
+ normalAt(x: number, z: number, out: Float32Array): Float32Array {
122
+ const step = this.spacingM;
123
+ const dx = this.heightAt(x + step, z) - this.heightAt(x - step, z);
124
+ const dz = this.heightAt(x, z + step) - this.heightAt(x, z - step);
125
+ /* The gradient of a height field, normalised: (-dh/dx, 1, -dh/dz) over twice the step. */
126
+ const nx = -dx / (2 * step);
127
+ const nz = -dz / (2 * step);
128
+ const length = Math.sqrt(nx * nx + 1 + nz * nz);
129
+ out[0] = nx / length;
130
+ out[1] = 1 / length;
131
+ out[2] = nz / length;
132
+ return out;
133
+ }
134
+ }