@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.
- package/LICENSE +202 -0
- package/NOTICE +9 -0
- package/README.md +166 -0
- package/dist/heightfield.d.ts +69 -0
- package/dist/heightfield.js +91 -0
- package/dist/heightfieldPatch.d.ts +74 -0
- package/dist/heightfieldPatch.js +159 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +19 -0
- package/dist/terrainMaterials.d.ts +79 -0
- package/dist/terrainMaterials.js +111 -0
- package/package.json +59 -0
- package/src/heightfield.ts +134 -0
- package/src/heightfieldPatch.ts +258 -0
- package/src/index.ts +23 -0
- package/src/terrainMaterials.ts +176 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import type { MeshData } from '@driftengine/drft';
|
|
2
|
+
import type { ReadonlyVec3 } from 'gl-matrix';
|
|
3
|
+
|
|
4
|
+
import type { Terrain } from './heightfield.ts';
|
|
5
|
+
import type { TerrainMaterials } from './terrainMaterials.ts';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* One square of a heightfield, as geometry, at a level of detail — and matched to its neighbours.
|
|
9
|
+
*
|
|
10
|
+
* **A patch is the unit of detail, and the seam between two of them is the whole problem.** A
|
|
11
|
+
* heightfield drawn at one resolution is either too coarse underfoot or too fine at the horizon, so
|
|
12
|
+
* it is drawn in squares and the distant squares skip samples. Where a square that skips none meets
|
|
13
|
+
* one that skips every other, the fine edge has vertices the coarse edge does not — and those
|
|
14
|
+
* vertices sit *on the field* while the coarse edge cuts the chord beneath them. The gap between
|
|
15
|
+
* the two is a crack, and a crack in terrain is a hole through to the sky.
|
|
16
|
+
*
|
|
17
|
+
* **Matched rather than hidden.** The usual remedy is a skirt: a vertical curtain dropped from
|
|
18
|
+
* every patch edge, which fills the hole with something the wrong colour and lit the wrong way, and
|
|
19
|
+
* costs geometry along every boundary in the world for ever. What this does instead is move the
|
|
20
|
+
* fine edge's odd vertices onto the coarse edge's own straight segment, so the two edges are the
|
|
21
|
+
* same polyline and there is nothing to fill. It costs one interpolation per boundary vertex, at
|
|
22
|
+
* build time, and no geometry at all.
|
|
23
|
+
*
|
|
24
|
+
* **The cost of matching, stated because it is real**: along a matched edge the drawn surface is
|
|
25
|
+
* the coarse chord rather than the field, so `Terrain.heightAt` and the picture differ there by up
|
|
26
|
+
* to the sag of one coarse cell. That is the same error the coarse patch itself carries over its
|
|
27
|
+
* whole area, arriving one cell early — which is why it is the right trade and why it is written
|
|
28
|
+
* down rather than left to be discovered.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
export interface HeightfieldPatchOptions {
|
|
32
|
+
/** Grid index of the patch's first sample, along x. */
|
|
33
|
+
readonly x: number;
|
|
34
|
+
/** Grid index of the patch's first sample, along z. */
|
|
35
|
+
readonly z: number;
|
|
36
|
+
/** How many field cells the patch spans. */
|
|
37
|
+
readonly cells: number;
|
|
38
|
+
/**
|
|
39
|
+
* How many field cells one drawn cell spans. 1 is full resolution.
|
|
40
|
+
*
|
|
41
|
+
* Must divide `cells`, since a patch that does not come out whole would leave a strip of the
|
|
42
|
+
* field undrawn — a crack that no neighbour can match because nothing is there to match.
|
|
43
|
+
*/
|
|
44
|
+
readonly step?: number;
|
|
45
|
+
/**
|
|
46
|
+
* The step each neighbouring patch is drawn at, where it is coarser than this one.
|
|
47
|
+
*
|
|
48
|
+
* Only the coarser side needs naming: the finer patch is the one that moves. A neighbour at the
|
|
49
|
+
* same step or a finer one changes nothing, so a caller may pass what it knows and leave the
|
|
50
|
+
* rest out.
|
|
51
|
+
*/
|
|
52
|
+
readonly neighbours?: {
|
|
53
|
+
readonly minusX?: number;
|
|
54
|
+
readonly plusX?: number;
|
|
55
|
+
readonly minusZ?: number;
|
|
56
|
+
readonly plusZ?: number;
|
|
57
|
+
};
|
|
58
|
+
/**
|
|
59
|
+
* One colour for the whole patch. Ignored where `materials` is given.
|
|
60
|
+
*
|
|
61
|
+
* Two ways to say the same thing would be a mistake, and this is not one: a patch with a single
|
|
62
|
+
* colour is what a caller writes before they have a weight map, and a map is what they write once
|
|
63
|
+
* they have several materials. The second wins where both are present, said here so it is not
|
|
64
|
+
* discovered.
|
|
65
|
+
*/
|
|
66
|
+
readonly color?: ReadonlyVec3;
|
|
67
|
+
readonly emissive?: number;
|
|
68
|
+
/**
|
|
69
|
+
* Several materials blended across the *field*, rather than one colour for this patch.
|
|
70
|
+
*
|
|
71
|
+
* **Read in the field's own coordinates and never the patch's**, which is what makes two patches
|
|
72
|
+
* agree along the edge they share. Shading from a patch-local coordinate would restart the blend
|
|
73
|
+
* at every boundary and draw a grid of squares over the world.
|
|
74
|
+
*/
|
|
75
|
+
readonly materials?: TerrainMaterials;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** How far along an edge a vertex sits on the coarse lattice, and the two coarse heights round it. */
|
|
79
|
+
function chord(low: number, high: number, at: number, height: (index: number) => number): number {
|
|
80
|
+
if (high === low) return height(low);
|
|
81
|
+
const t = (at - low) / (high - low);
|
|
82
|
+
return height(low) * (1 - t) + height(high) * t;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function heightfieldPatch(terrain: Terrain, options: HeightfieldPatchOptions): MeshData {
|
|
86
|
+
const step = Math.round(options.step ?? 1);
|
|
87
|
+
const cells = Math.round(options.cells);
|
|
88
|
+
if (!(step >= 1)) throw new Error(`heightfieldPatch: step must be at least 1, got ${step}`);
|
|
89
|
+
if (cells % step !== 0) {
|
|
90
|
+
throw new Error(
|
|
91
|
+
`heightfieldPatch: a step of ${step} does not divide ${cells} cells, so a strip would go undrawn`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
if (
|
|
95
|
+
options.x < 0 ||
|
|
96
|
+
options.z < 0 ||
|
|
97
|
+
options.x + cells > terrain.width - 1 ||
|
|
98
|
+
options.z + cells > terrain.depth - 1
|
|
99
|
+
) {
|
|
100
|
+
throw new Error(
|
|
101
|
+
`heightfieldPatch: a patch at ${options.x}, ${options.z} of ${cells} cells runs off a field ` +
|
|
102
|
+
`of ${terrain.width} by ${terrain.depth}`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const neighbours = options.neighbours ?? {};
|
|
107
|
+
for (const [side, theirs] of Object.entries(neighbours)) {
|
|
108
|
+
if (theirs === undefined) continue;
|
|
109
|
+
if (theirs % step !== 0) {
|
|
110
|
+
throw new Error(
|
|
111
|
+
`heightfieldPatch: a neighbour on ${side} drawn at ${theirs} cannot be matched by a patch ` +
|
|
112
|
+
`drawn at ${step}, since one does not divide the other`,
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const across = cells / step;
|
|
118
|
+
const vertices = (across + 1) * (across + 1);
|
|
119
|
+
const spacing = terrain.spacingM;
|
|
120
|
+
const originX = (terrain.origin[0] ?? 0) + options.x * spacing;
|
|
121
|
+
const originZ = (terrain.origin[2] ?? 0) + options.z * spacing;
|
|
122
|
+
const materials = options.materials;
|
|
123
|
+
const colour = options.color ?? [0.42, 0.45, 0.36];
|
|
124
|
+
const emissive = options.emissive ?? 0;
|
|
125
|
+
|
|
126
|
+
const positions = new Float32Array(vertices * 3);
|
|
127
|
+
const normals = new Float32Array(vertices * 3);
|
|
128
|
+
const colors = new Float32Array(vertices * 3);
|
|
129
|
+
const emissives = new Float32Array(vertices);
|
|
130
|
+
/* Allocated only where something shines, which is the promise `MeshData.specular` makes: geometry
|
|
131
|
+
that does not shine produces exactly the mesh it produced before the attribute existed. */
|
|
132
|
+
const speculars = materials?.shines === true ? new Float32Array(vertices) : null;
|
|
133
|
+
const indices = new Uint32Array(across * across * 6);
|
|
134
|
+
const normal = new Float32Array(3);
|
|
135
|
+
const shade = new Float32Array(3);
|
|
136
|
+
|
|
137
|
+
for (let row = 0; row <= across; row++) {
|
|
138
|
+
for (let column = 0; column <= across; column++) {
|
|
139
|
+
const at = (row * (across + 1) + column) * 3;
|
|
140
|
+
const x = originX + column * step * spacing;
|
|
141
|
+
const z = originZ + row * step * spacing;
|
|
142
|
+
|
|
143
|
+
/*
|
|
144
|
+
* The field's own height, unless this vertex is on an edge whose neighbour is coarser — in
|
|
145
|
+
* which case it takes that neighbour's chord instead, and the two patches then share one
|
|
146
|
+
* polyline. A corner sits on two edges and is a multiple of every step, so it is never moved
|
|
147
|
+
* and the two rules cannot disagree about it.
|
|
148
|
+
*/
|
|
149
|
+
let y = terrain.heightAt(x, z);
|
|
150
|
+
const onMinusX = column === 0;
|
|
151
|
+
const onPlusX = column === across;
|
|
152
|
+
const onMinusZ = row === 0;
|
|
153
|
+
const onPlusZ = row === across;
|
|
154
|
+
|
|
155
|
+
if (onMinusX && neighbours.minusX !== undefined) {
|
|
156
|
+
y = matched(row, neighbours.minusX, (index) =>
|
|
157
|
+
terrain.heightAt(x, originZ + index * spacing),
|
|
158
|
+
);
|
|
159
|
+
} else if (onPlusX && neighbours.plusX !== undefined) {
|
|
160
|
+
y = matched(row, neighbours.plusX, (index) =>
|
|
161
|
+
terrain.heightAt(x, originZ + index * spacing),
|
|
162
|
+
);
|
|
163
|
+
} else if (onMinusZ && neighbours.minusZ !== undefined) {
|
|
164
|
+
y = matched(column, neighbours.minusZ, (index) =>
|
|
165
|
+
terrain.heightAt(originX + index * spacing, z),
|
|
166
|
+
);
|
|
167
|
+
} else if (onPlusZ && neighbours.plusZ !== undefined) {
|
|
168
|
+
y = matched(column, neighbours.plusZ, (index) =>
|
|
169
|
+
terrain.heightAt(originX + index * spacing, z),
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
positions[at] = x;
|
|
174
|
+
positions[at + 1] = y;
|
|
175
|
+
positions[at + 2] = z;
|
|
176
|
+
|
|
177
|
+
/* The field's normal and never the patch's, so two patches shade identically along the edge
|
|
178
|
+
they share. `heightfield.ts` argues that where the query is defined. */
|
|
179
|
+
terrain.normalAt(x, z, normal);
|
|
180
|
+
normals[at] = normal[0] ?? 0;
|
|
181
|
+
normals[at + 1] = normal[1] ?? 1;
|
|
182
|
+
normals[at + 2] = normal[2] ?? 0;
|
|
183
|
+
|
|
184
|
+
if (materials === undefined) {
|
|
185
|
+
colors[at] = colour[0] ?? 1;
|
|
186
|
+
colors[at + 1] = colour[1] ?? 1;
|
|
187
|
+
colors[at + 2] = colour[2] ?? 1;
|
|
188
|
+
emissives[row * (across + 1) + column] = emissive;
|
|
189
|
+
} else {
|
|
190
|
+
/* The field's own extent, so the blend is continuous across every patch boundary. */
|
|
191
|
+
const u = terrain.extentX === 0 ? 0 : (x - (terrain.origin[0] ?? 0)) / terrain.extentX;
|
|
192
|
+
const w = terrain.extentZ === 0 ? 0 : (z - (terrain.origin[2] ?? 0)) / terrain.extentZ;
|
|
193
|
+
materials.colorAt(u, w, shade);
|
|
194
|
+
colors[at] = shade[0] ?? 1;
|
|
195
|
+
colors[at + 1] = shade[1] ?? 1;
|
|
196
|
+
colors[at + 2] = shade[2] ?? 1;
|
|
197
|
+
emissives[row * (across + 1) + column] = materials.emissiveAt(u, w);
|
|
198
|
+
if (speculars !== null) {
|
|
199
|
+
speculars[row * (across + 1) + column] = materials.specularAt(u, w);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Where a vertex on a matched edge actually sits.
|
|
207
|
+
*
|
|
208
|
+
* `index` counts drawn cells along the edge and `theirs` is the neighbour's step in field cells,
|
|
209
|
+
* so `theirs / step` drawn vertices span one of the neighbour's. A vertex on that lattice keeps
|
|
210
|
+
* the field; one between two of them takes the chord.
|
|
211
|
+
*/
|
|
212
|
+
function matched(index: number, theirs: number, height: (fieldCell: number) => number): number {
|
|
213
|
+
const ratio = theirs / step;
|
|
214
|
+
const low = Math.floor(index / ratio) * ratio;
|
|
215
|
+
const high = Math.min(across, low + ratio);
|
|
216
|
+
return chord(low * step, high * step, index * step, height);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
let at = 0;
|
|
220
|
+
for (let row = 0; row < across; row++) {
|
|
221
|
+
for (let column = 0; column < across; column++) {
|
|
222
|
+
const a = row * (across + 1) + column;
|
|
223
|
+
const b = a + 1;
|
|
224
|
+
const d = a + (across + 1);
|
|
225
|
+
const c = d + 1;
|
|
226
|
+
/*
|
|
227
|
+
* **Split along `a`-`c`, which is the diagonal `Terrain.heightAt` reads.** The two surfaces
|
|
228
|
+
* are the same surface or a query answers a place the picture does not have.
|
|
229
|
+
*
|
|
230
|
+
* This said `a, d, b` and `b, d, c` until 2026-09-02, which is the *other* diagonal — the
|
|
231
|
+
* mesh was split one way and the query read the other, so the two disagreed by up to the
|
|
232
|
+
* height of the cell everywhere except along the anti-diagonal itself. **Nothing caught it
|
|
233
|
+
* for two rows**: every vertex lies on both triangulations, so a per-vertex assertion passes
|
|
234
|
+
* either way, and the test fields were of the form `f(x) + g(z)` — separable, and for a
|
|
235
|
+
* separable field both diagonals give the identical surface. It surfaced when a cross term
|
|
236
|
+
* went into the field and a ray cast against the mesh landed 11.6 mm off the query.
|
|
237
|
+
*
|
|
238
|
+
* Wound so the face normal points up either way: with x to the right and z away, `a, d, c`
|
|
239
|
+
* and `a, c, b` both cross upward.
|
|
240
|
+
*/
|
|
241
|
+
indices[at++] = a;
|
|
242
|
+
indices[at++] = d;
|
|
243
|
+
indices[at++] = c;
|
|
244
|
+
indices[at++] = a;
|
|
245
|
+
indices[at++] = c;
|
|
246
|
+
indices[at++] = b;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return {
|
|
251
|
+
positions,
|
|
252
|
+
normals,
|
|
253
|
+
colors,
|
|
254
|
+
emissive: emissives,
|
|
255
|
+
...(speculars === null ? {} : { specular: speculars }),
|
|
256
|
+
indices,
|
|
257
|
+
};
|
|
258
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
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
|
+
|
|
18
|
+
export { Terrain } from './heightfield.ts';
|
|
19
|
+
export type { HeightfieldOptions } from './heightfield.ts';
|
|
20
|
+
export { heightfieldPatch } from './heightfieldPatch.ts';
|
|
21
|
+
export type { HeightfieldPatchOptions } from './heightfieldPatch.ts';
|
|
22
|
+
export { TerrainMaterials, terrainMaterialWeights } from './terrainMaterials.ts';
|
|
23
|
+
export type { TerrainMaterial, TerrainMaterialsOptions } from './terrainMaterials.ts';
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import type { ReadonlyVec3 } from 'gl-matrix';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Several materials blended across one field, as vertex colour.
|
|
5
|
+
*
|
|
6
|
+
* **A weight map baked into vertex colour, and that is the idiomatic answer here rather than a
|
|
7
|
+
* compromise.** This engine's colour *is* vertex data — it is what makes the world one flat-shaded
|
|
8
|
+
* draw call, and the root README says so where it introduces `drawMesh`'s tint. A splat map read by
|
|
9
|
+
* a shader would mean a texture bound on every terrain draw and a branch in `flatFrag`, which Track
|
|
10
|
+
* D measured at 19.8 KB gzipped for a capability generated into sixteen fragment permutations. This
|
|
11
|
+
* costs nothing at all at runtime: the blend happens once, where the patch is built.
|
|
12
|
+
*
|
|
13
|
+
* **What that gives up, and it is the honest limit**: the blend is only as sharp as the mesh. A
|
|
14
|
+
* material boundary inside a cell is drawn as a gradient across that cell, and a patch drawn at a
|
|
15
|
+
* quarter detail blends four times as coarsely as one at full detail. A consumer who wants a sharp
|
|
16
|
+
* line — a road edge, a cliff base — draws that patch at full detail, which is the same dial the
|
|
17
|
+
* geometry already uses.
|
|
18
|
+
*
|
|
19
|
+
* **The weights are read bilinearly, which is the opposite of how a height is read**, and the
|
|
20
|
+
* contrast is the whole reason both decisions are written down. A height is read as the *triangle*
|
|
21
|
+
* because the surface is drawn as triangles and the query has to agree with the picture
|
|
22
|
+
* (`heightfield.ts` argues that at length). A weight is not drawn: it decides a vertex colour, and
|
|
23
|
+
* the vertex is wherever the mesh put it. There is no triangulation to agree with, so the smooth
|
|
24
|
+
* interpolation is simply the better one — reading a weight map as triangles would put a visible
|
|
25
|
+
* crease along every cell diagonal for nothing.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
export interface TerrainMaterial {
|
|
29
|
+
readonly color: ReadonlyVec3;
|
|
30
|
+
/** Self-illumination, revealed at night. Zero unless a material glows. */
|
|
31
|
+
readonly emissive?: number;
|
|
32
|
+
/** How sharply this material takes a sun highlight, 0 to 1. */
|
|
33
|
+
readonly specular?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface TerrainMaterialsOptions {
|
|
37
|
+
/** The materials being blended. At least one. */
|
|
38
|
+
readonly materials: readonly TerrainMaterial[];
|
|
39
|
+
/** Samples of the weight map across x. It need not match the heightfield's own resolution. */
|
|
40
|
+
readonly width: number;
|
|
41
|
+
/** Samples across z. */
|
|
42
|
+
readonly depth: number;
|
|
43
|
+
/**
|
|
44
|
+
* `width * depth * materials.length` weights, row-major with the material running fastest.
|
|
45
|
+
*
|
|
46
|
+
* They need not sum to anything: `terrainMaterialWeights` normalises what it reads, because a
|
|
47
|
+
* map is painted or generated and nothing balances its channels.
|
|
48
|
+
*/
|
|
49
|
+
readonly weights: ArrayLike<number>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Scratch for one blend, so shading a patch allocates nothing per vertex. */
|
|
53
|
+
const SCRATCH = new Float32Array(16);
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The normalised weights at a point in the field's own `0..1` extent.
|
|
57
|
+
*
|
|
58
|
+
* **Normalised, and a sample that weighs nothing falls back to the first material.** An unpainted
|
|
59
|
+
* corner of a map sums to zero, and dividing by that gives a NaN or a black patch depending on
|
|
60
|
+
* where it lands — both of which read as a rendering fault rather than as an unpainted map. Black
|
|
61
|
+
* is not a colour terrain should ever be by accident.
|
|
62
|
+
*
|
|
63
|
+
* `out` must hold one number per material; it is returned for convenience and is the caller's.
|
|
64
|
+
*/
|
|
65
|
+
export function terrainMaterialWeights(
|
|
66
|
+
map: TerrainMaterials,
|
|
67
|
+
u: number,
|
|
68
|
+
v: number,
|
|
69
|
+
out: Float32Array,
|
|
70
|
+
): Float32Array {
|
|
71
|
+
const count = map.materials.length;
|
|
72
|
+
/* Clamped rather than wrapped: a field has an edge, and a query past it should answer the edge
|
|
73
|
+
rather than the far side of the map. */
|
|
74
|
+
const x = Math.min(map.width - 1, Math.max(0, u * (map.width - 1)));
|
|
75
|
+
const z = Math.min(map.depth - 1, Math.max(0, v * (map.depth - 1)));
|
|
76
|
+
const ix = Math.min(map.width - 2, Math.floor(x));
|
|
77
|
+
const iz = Math.min(map.depth - 2, Math.floor(z));
|
|
78
|
+
const fu = map.width < 2 ? 0 : x - ix;
|
|
79
|
+
const fv = map.depth < 2 ? 0 : z - iz;
|
|
80
|
+
|
|
81
|
+
const a = (iz * map.width + ix) * count;
|
|
82
|
+
const b = (iz * map.width + Math.min(map.width - 1, ix + 1)) * count;
|
|
83
|
+
const c = (Math.min(map.depth - 1, iz + 1) * map.width + ix) * count;
|
|
84
|
+
const d = (Math.min(map.depth - 1, iz + 1) * map.width + Math.min(map.width - 1, ix + 1)) * count;
|
|
85
|
+
|
|
86
|
+
let total = 0;
|
|
87
|
+
for (let m = 0; m < count; m++) {
|
|
88
|
+
const top = (map.weights[a + m] ?? 0) * (1 - fu) + (map.weights[b + m] ?? 0) * fu;
|
|
89
|
+
const bottom = (map.weights[c + m] ?? 0) * (1 - fu) + (map.weights[d + m] ?? 0) * fu;
|
|
90
|
+
const weight = top * (1 - fv) + bottom * fv;
|
|
91
|
+
out[m] = weight;
|
|
92
|
+
total += weight;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (total <= 0) {
|
|
96
|
+
out[0] = 1;
|
|
97
|
+
for (let m = 1; m < count; m++) out[m] = 0;
|
|
98
|
+
return out;
|
|
99
|
+
}
|
|
100
|
+
for (let m = 0; m < count; m++) out[m] = (out[m] ?? 0) / total;
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export class TerrainMaterials {
|
|
105
|
+
readonly materials: readonly TerrainMaterial[];
|
|
106
|
+
readonly width: number;
|
|
107
|
+
readonly depth: number;
|
|
108
|
+
readonly weights: Float32Array;
|
|
109
|
+
/**
|
|
110
|
+
* Whether any material shines, so a patch built from this uploads a specular buffer only if one
|
|
111
|
+
* is wanted — the promise `MeshData.specular` makes and the reason it is optional.
|
|
112
|
+
*/
|
|
113
|
+
readonly shines: boolean;
|
|
114
|
+
/** The same question for emissive, and for the same reason. */
|
|
115
|
+
readonly glows: boolean;
|
|
116
|
+
|
|
117
|
+
private readonly scratch: Float32Array;
|
|
118
|
+
|
|
119
|
+
constructor(options: TerrainMaterialsOptions) {
|
|
120
|
+
const count = options.materials.length;
|
|
121
|
+
if (count < 1) throw new Error('TerrainMaterials: needs at least one material to blend');
|
|
122
|
+
const needed = options.width * options.depth * count;
|
|
123
|
+
if (options.weights.length !== needed) {
|
|
124
|
+
throw new Error(
|
|
125
|
+
`TerrainMaterials: ${options.width} by ${options.depth} over ${count} materials needs ` +
|
|
126
|
+
`${needed} weights, got ${options.weights.length}`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
this.materials = [...options.materials];
|
|
130
|
+
this.width = Math.round(options.width);
|
|
131
|
+
this.depth = Math.round(options.depth);
|
|
132
|
+
this.weights = Float32Array.from(options.weights);
|
|
133
|
+
this.shines = this.materials.some((material) => (material.specular ?? 0) !== 0);
|
|
134
|
+
this.glows = this.materials.some((material) => (material.emissive ?? 0) !== 0);
|
|
135
|
+
this.scratch = count <= SCRATCH.length ? SCRATCH : new Float32Array(count);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The blended colour at a point in the field's `0..1` extent. */
|
|
139
|
+
colorAt(u: number, v: number, out: Float32Array): Float32Array {
|
|
140
|
+
const weights = terrainMaterialWeights(this, u, v, this.scratch);
|
|
141
|
+
let r = 0;
|
|
142
|
+
let g = 0;
|
|
143
|
+
let b = 0;
|
|
144
|
+
for (let m = 0; m < this.materials.length; m++) {
|
|
145
|
+
const weight = weights[m] ?? 0;
|
|
146
|
+
const colour = this.materials[m]?.color;
|
|
147
|
+
r += (colour?.[0] ?? 0) * weight;
|
|
148
|
+
g += (colour?.[1] ?? 0) * weight;
|
|
149
|
+
b += (colour?.[2] ?? 0) * weight;
|
|
150
|
+
}
|
|
151
|
+
out[0] = r;
|
|
152
|
+
out[1] = g;
|
|
153
|
+
out[2] = b;
|
|
154
|
+
return out;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The blended self-illumination, on the same weights the colour used. */
|
|
158
|
+
emissiveAt(u: number, v: number): number {
|
|
159
|
+
const weights = terrainMaterialWeights(this, u, v, this.scratch);
|
|
160
|
+
let sum = 0;
|
|
161
|
+
for (let m = 0; m < this.materials.length; m++) {
|
|
162
|
+
sum += (this.materials[m]?.emissive ?? 0) * (weights[m] ?? 0);
|
|
163
|
+
}
|
|
164
|
+
return sum;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** The blended highlight, likewise. */
|
|
168
|
+
specularAt(u: number, v: number): number {
|
|
169
|
+
const weights = terrainMaterialWeights(this, u, v, this.scratch);
|
|
170
|
+
let sum = 0;
|
|
171
|
+
for (let m = 0; m < this.materials.length; m++) {
|
|
172
|
+
sum += (this.materials[m]?.specular ?? 0) * (weights[m] ?? 0);
|
|
173
|
+
}
|
|
174
|
+
return sum;
|
|
175
|
+
}
|
|
176
|
+
}
|