@driftengine/terrain 3.62.0 → 4.0.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/NOTICE +20 -0
- package/dist/clipmap.d.ts +112 -0
- package/dist/clipmap.js +154 -0
- package/dist/heightfield.d.ts +7 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +12 -0
- package/dist/terrainTexture.d.ts +113 -0
- package/dist/terrainTexture.js +215 -0
- package/package.json +7 -3
- package/src/clipmap.ts +265 -0
- package/src/heightfield.ts +7 -0
- package/src/index.ts +35 -0
- package/src/terrainTexture.ts +270 -0
package/NOTICE
CHANGED
|
@@ -7,3 +7,23 @@ This product includes software developed at Drift Technologies
|
|
|
7
7
|
Licensed under the Apache License, Version 2.0. Section 4(d) of that licence
|
|
8
8
|
requires this NOTICE to be reproduced in any derivative work you distribute,
|
|
9
9
|
in the source, the documentation, or a display generated by the work.
|
|
10
|
+
|
|
11
|
+
Models run by DriftCapture
|
|
12
|
+
--------------------------
|
|
13
|
+
DriftCapture runs the models below. Their weights are not in this repository
|
|
14
|
+
and are not distributed with it: tools/capture-weights/ fetches each at the
|
|
15
|
+
revision its manifest pins, verifies it, and converts it, and a game that
|
|
16
|
+
ships one ships it under that model's own licence. Each model's definition in
|
|
17
|
+
this engine is written from its project's published code.
|
|
18
|
+
|
|
19
|
+
- Depth Anything 3 Small and Depth Anything 3 Base. Apache License 2.0.
|
|
20
|
+
Copyright (c) 2025 ByteDance Ltd. and/or its affiliates.
|
|
21
|
+
- Depth Anything V2 Small. Apache License 2.0. The University of Hong Kong
|
|
22
|
+
and TikTok; converted to the Transformers layout by The HuggingFace Team.
|
|
23
|
+
- MobileSAM. Apache License 2.0. Its image encoder is TinyViT, Copyright (c)
|
|
24
|
+
2022 Microsoft; its prompt encoder and mask decoder are Segment Anything's,
|
|
25
|
+
Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
26
|
+
- SAM 2.1 tiny. Apache License 2.0. Copyright (c) Meta Platforms, Inc. and
|
|
27
|
+
affiliates.
|
|
28
|
+
- OWLv2 base patch16. Apache License 2.0. Copyright 2023 Google AI and The
|
|
29
|
+
HuggingFace Team.
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which squares of the field to draw, at what detail, for a camera standing somewhere.
|
|
3
|
+
*
|
|
4
|
+
* **A clipmap is rings of patches whose step doubles outward**, so the ground under the camera is
|
|
5
|
+
* drawn at full resolution and the horizon at a sixteenth of it, and the vertex count stays flat
|
|
6
|
+
* however large the world is. The hard part is not the rings; it is that they have to meet.
|
|
7
|
+
*
|
|
8
|
+
* **Two failures decide the whole design and neither is visible in a list of patches.** A field
|
|
9
|
+
* cell drawn by two patches is z-fighting on the ground. A field cell drawn by none is a hole
|
|
10
|
+
* through to the sky. Both come out of the arithmetic that snaps each level's block to a lattice,
|
|
11
|
+
* so that is what the tests assert directly: every cell in the footprint, counted.
|
|
12
|
+
*
|
|
13
|
+
* **Each level's block is snapped to an even patch index**, which is what makes the levels nest:
|
|
14
|
+
* an origin at an even multiple of a level's patch span is a multiple of the next level's patch
|
|
15
|
+
* span, so a finer block's edge always falls on a coarser patch boundary and never through the
|
|
16
|
+
* middle of one. A coarse patch is therefore covered by the finer block entirely or not at all,
|
|
17
|
+
* and culling it when it is covered leaves no gap and no overlap. The block being an even number
|
|
18
|
+
* of patches on each side of that index is why `patchesAcross` must be a multiple of four.
|
|
19
|
+
*
|
|
20
|
+
* **The hole a finer level leaves is not fixed at the centre**, which is the part that looks wrong
|
|
21
|
+
* and is right. Levels snap to their own lattices and re-centre at different moments, so the finer
|
|
22
|
+
* block sits a patch off-centre in the coarser one about half the time. Culling by *coverage*
|
|
23
|
+
* rather than by a fixed central hole is what makes that harmless — and a fixed hole is what the
|
|
24
|
+
* usual implementation does, which is why it needs an L-shaped strip to take up the slack.
|
|
25
|
+
*
|
|
26
|
+
* **The selection is one decision per patch**, taking the frame and an index and reading nothing
|
|
27
|
+
* else. That is what lets it move into a compute pass beside the cluster cut rather than be
|
|
28
|
+
* rewritten there: `clipmapPatchAt` is the body of that shader, and the test that asks for the
|
|
29
|
+
* patches in a scrambled order is what holds it.
|
|
30
|
+
*
|
|
31
|
+
* What it does *not* do is build geometry. `heightfieldPatch` does that, including moving a fine
|
|
32
|
+
* edge's odd vertices onto its coarse neighbour's chord — this module's job is to tell it which
|
|
33
|
+
* neighbour is coarser, because a patch that is not told draws an edge that follows the field
|
|
34
|
+
* while the patch beside it cuts the chord beneath.
|
|
35
|
+
*/
|
|
36
|
+
import type { Terrain } from './heightfield.ts';
|
|
37
|
+
import type { HeightfieldPatchOptions } from './heightfieldPatch.ts';
|
|
38
|
+
/** Drawn cells along one edge of every patch, at every level. */
|
|
39
|
+
export declare const CLIPMAP_PATCH_CELLS = 8;
|
|
40
|
+
/** Patches along one edge of a level's block. A multiple of four, for the reason above. */
|
|
41
|
+
export declare const CLIPMAP_PATCHES_ACROSS = 8;
|
|
42
|
+
/** Levels a frame builds when the caller does not say. */
|
|
43
|
+
export declare const CLIPMAP_LEVELS = 4;
|
|
44
|
+
export interface ClipmapOptions {
|
|
45
|
+
readonly levels?: number;
|
|
46
|
+
readonly patchCells?: number;
|
|
47
|
+
readonly patchesAcross?: number;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Everything a per-patch decision needs, computed once for a camera.
|
|
51
|
+
*
|
|
52
|
+
* Deliberately plain data: an `Int32Array` of origins and four numbers. What a compute pass would
|
|
53
|
+
* be handed as a uniform block, and nothing a shader could not hold.
|
|
54
|
+
*/
|
|
55
|
+
export interface ClipmapFrame {
|
|
56
|
+
readonly levels: number;
|
|
57
|
+
readonly patchCells: number;
|
|
58
|
+
readonly patchesAcross: number;
|
|
59
|
+
/** Two entries a level: the field-cell index of the block's first cell, x then z. */
|
|
60
|
+
readonly origins: Int32Array;
|
|
61
|
+
/** Cells the field has, which is one fewer than its samples. */
|
|
62
|
+
readonly fieldCellsX: number;
|
|
63
|
+
readonly fieldCellsZ: number;
|
|
64
|
+
}
|
|
65
|
+
/** A selected patch, in exactly the terms `heightfieldPatch` takes. */
|
|
66
|
+
export interface ClipmapPatch {
|
|
67
|
+
level: number;
|
|
68
|
+
/** Grid index of the patch's first sample, along x. */
|
|
69
|
+
x: number;
|
|
70
|
+
z: number;
|
|
71
|
+
/** Field cells the patch spans: `patchCells * step`. */
|
|
72
|
+
cells: number;
|
|
73
|
+
/** Field cells per drawn cell: `1 << level`. */
|
|
74
|
+
step: number;
|
|
75
|
+
/** The step of each neighbour that is *coarser* than this patch. The finer side is the one that
|
|
76
|
+
moves, so nothing is said about a neighbour at the same level or a finer one. */
|
|
77
|
+
neighbours: {
|
|
78
|
+
minusX?: number;
|
|
79
|
+
plusX?: number;
|
|
80
|
+
minusZ?: number;
|
|
81
|
+
plusZ?: number;
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/** A patch to write into, so a caller looping over indices allocates nothing. */
|
|
85
|
+
export declare function emptyClipmapPatch(): ClipmapPatch;
|
|
86
|
+
/**
|
|
87
|
+
* Snap a camera to each level's block, once, for a frame.
|
|
88
|
+
*
|
|
89
|
+
* The camera is in world coordinates and the blocks are in field cells, because that is what
|
|
90
|
+
* `heightfieldPatch` indexes by and converting in one place is one place to be wrong.
|
|
91
|
+
*/
|
|
92
|
+
export declare function clipmapFrame(terrain: Terrain, cameraX: number, cameraZ: number, options?: ClipmapOptions): ClipmapFrame;
|
|
93
|
+
/** Patches a frame decides about: every index a compute pass would launch a thread for. */
|
|
94
|
+
export declare function clipmapPatchCount(frame: ClipmapFrame): number;
|
|
95
|
+
/**
|
|
96
|
+
* The level whose drawn patch covers a field cell, or `-1` where nothing draws it.
|
|
97
|
+
*
|
|
98
|
+
* The finest level whose block contains the cell and whose patch there fits on the field. Finest
|
|
99
|
+
* rather than any, because a coarser patch over the same ground is exactly what gets culled.
|
|
100
|
+
*/
|
|
101
|
+
export declare function clipmapLevelAt(frame: ClipmapFrame, cellX: number, cellZ: number): number;
|
|
102
|
+
/**
|
|
103
|
+
* Decide one patch, by index, writing it into `out`.
|
|
104
|
+
*
|
|
105
|
+
* Returns whether it is drawn. Reads the frame and the index and nothing else, which is what makes
|
|
106
|
+
* this the body of a compute shader rather than a loop that has to be rewritten as one.
|
|
107
|
+
*/
|
|
108
|
+
export declare function clipmapPatchAt(frame: ClipmapFrame, index: number, out: ClipmapPatch): boolean;
|
|
109
|
+
/** Every drawn patch, as a list. The convenience over `clipmapPatchAt`, not a second rule. */
|
|
110
|
+
export declare function selectClipmap(frame: ClipmapFrame): ClipmapPatch[];
|
|
111
|
+
/** A selected patch as the options `heightfieldPatch` takes, with nothing added or renamed. */
|
|
112
|
+
export declare function clipmapPatchOptions(patch: ClipmapPatch): HeightfieldPatchOptions;
|
package/dist/clipmap.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/** Drawn cells along one edge of every patch, at every level. */
|
|
2
|
+
export const CLIPMAP_PATCH_CELLS = 8;
|
|
3
|
+
/** Patches along one edge of a level's block. A multiple of four, for the reason above. */
|
|
4
|
+
export const CLIPMAP_PATCHES_ACROSS = 8;
|
|
5
|
+
/** Levels a frame builds when the caller does not say. */
|
|
6
|
+
export const CLIPMAP_LEVELS = 4;
|
|
7
|
+
/** A patch to write into, so a caller looping over indices allocates nothing. */
|
|
8
|
+
export function emptyClipmapPatch() {
|
|
9
|
+
return { level: 0, x: 0, z: 0, cells: 0, step: 1, neighbours: {} };
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Snap a camera to each level's block, once, for a frame.
|
|
13
|
+
*
|
|
14
|
+
* The camera is in world coordinates and the blocks are in field cells, because that is what
|
|
15
|
+
* `heightfieldPatch` indexes by and converting in one place is one place to be wrong.
|
|
16
|
+
*/
|
|
17
|
+
export function clipmapFrame(terrain, cameraX, cameraZ, options = {}) {
|
|
18
|
+
const levels = Math.max(1, Math.round(options.levels ?? CLIPMAP_LEVELS));
|
|
19
|
+
const patchCells = Math.max(1, Math.round(options.patchCells ?? CLIPMAP_PATCH_CELLS));
|
|
20
|
+
const patchesAcross = Math.round(options.patchesAcross ?? CLIPMAP_PATCHES_ACROSS);
|
|
21
|
+
if (patchesAcross % 4 !== 0) {
|
|
22
|
+
throw new Error(`clipmapFrame: patchesAcross must be a multiple of four so the levels nest, got ${patchesAcross}`);
|
|
23
|
+
}
|
|
24
|
+
const spacing = terrain.spacingM;
|
|
25
|
+
const cellX = Math.floor((cameraX - (terrain.origin[0] ?? 0)) / spacing);
|
|
26
|
+
const cellZ = Math.floor((cameraZ - (terrain.origin[2] ?? 0)) / spacing);
|
|
27
|
+
const origins = new Int32Array(levels * 2);
|
|
28
|
+
for (let level = 0; level < levels; level += 1) {
|
|
29
|
+
const span = patchCells << level;
|
|
30
|
+
origins[level * 2] = blockOrigin(cellX, span, patchesAcross);
|
|
31
|
+
origins[level * 2 + 1] = blockOrigin(cellZ, span, patchesAcross);
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
levels,
|
|
35
|
+
patchCells,
|
|
36
|
+
patchesAcross,
|
|
37
|
+
origins,
|
|
38
|
+
fieldCellsX: terrain.width - 1,
|
|
39
|
+
fieldCellsZ: terrain.depth - 1,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* The first field cell of a level's block along one axis.
|
|
44
|
+
*
|
|
45
|
+
* The camera's patch index rounded *down to an even one*, then half the block subtracted. Even is
|
|
46
|
+
* what makes the origin a multiple of twice the span, which is the next level's span — the one
|
|
47
|
+
* property everything else rests on.
|
|
48
|
+
*/
|
|
49
|
+
function blockOrigin(cameraCell, span, patchesAcross) {
|
|
50
|
+
const patch = Math.floor(cameraCell / span);
|
|
51
|
+
const even = 2 * Math.floor(patch / 2);
|
|
52
|
+
return (even - patchesAcross / 2) * span;
|
|
53
|
+
}
|
|
54
|
+
/** Patches a frame decides about: every index a compute pass would launch a thread for. */
|
|
55
|
+
export function clipmapPatchCount(frame) {
|
|
56
|
+
return frame.levels * frame.patchesAcross * frame.patchesAcross;
|
|
57
|
+
}
|
|
58
|
+
/** Whether `[x, x + span)` on both axes lies inside level `level`'s block. */
|
|
59
|
+
function insideBlock(frame, level, x, z, span) {
|
|
60
|
+
const width = frame.patchesAcross * (frame.patchCells << level);
|
|
61
|
+
const x0 = frame.origins[level * 2];
|
|
62
|
+
const z0 = frame.origins[level * 2 + 1];
|
|
63
|
+
return x >= x0 && z >= z0 && x + span <= x0 + width && z + span <= z0 + width;
|
|
64
|
+
}
|
|
65
|
+
function onField(frame, x, z, span) {
|
|
66
|
+
return x >= 0 && z >= 0 && x + span <= frame.fieldCellsX && z + span <= frame.fieldCellsZ;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* The level whose drawn patch covers a field cell, or `-1` where nothing draws it.
|
|
70
|
+
*
|
|
71
|
+
* The finest level whose block contains the cell and whose patch there fits on the field. Finest
|
|
72
|
+
* rather than any, because a coarser patch over the same ground is exactly what gets culled.
|
|
73
|
+
*/
|
|
74
|
+
export function clipmapLevelAt(frame, cellX, cellZ) {
|
|
75
|
+
for (let level = 0; level < frame.levels; level += 1) {
|
|
76
|
+
const span = frame.patchCells << level;
|
|
77
|
+
const x0 = frame.origins[level * 2];
|
|
78
|
+
const z0 = frame.origins[level * 2 + 1];
|
|
79
|
+
const width = frame.patchesAcross * span;
|
|
80
|
+
if (cellX < x0 || cellZ < z0 || cellX >= x0 + width || cellZ >= z0 + width)
|
|
81
|
+
continue;
|
|
82
|
+
const px = x0 + Math.floor((cellX - x0) / span) * span;
|
|
83
|
+
const pz = z0 + Math.floor((cellZ - z0) / span) * span;
|
|
84
|
+
if (!onField(frame, px, pz, span))
|
|
85
|
+
continue;
|
|
86
|
+
return level;
|
|
87
|
+
}
|
|
88
|
+
return -1;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Decide one patch, by index, writing it into `out`.
|
|
92
|
+
*
|
|
93
|
+
* Returns whether it is drawn. Reads the frame and the index and nothing else, which is what makes
|
|
94
|
+
* this the body of a compute shader rather than a loop that has to be rewritten as one.
|
|
95
|
+
*/
|
|
96
|
+
export function clipmapPatchAt(frame, index, out) {
|
|
97
|
+
const perLevel = frame.patchesAcross * frame.patchesAcross;
|
|
98
|
+
const level = Math.floor(index / perLevel);
|
|
99
|
+
if (level < 0 || level >= frame.levels)
|
|
100
|
+
return false;
|
|
101
|
+
const within = index - level * perLevel;
|
|
102
|
+
const i = within % frame.patchesAcross;
|
|
103
|
+
const j = Math.floor(within / frame.patchesAcross);
|
|
104
|
+
const step = 1 << level;
|
|
105
|
+
const span = frame.patchCells * step;
|
|
106
|
+
const x = frame.origins[level * 2] + i * span;
|
|
107
|
+
const z = frame.origins[level * 2 + 1] + j * span;
|
|
108
|
+
if (!onField(frame, x, z, span))
|
|
109
|
+
return false;
|
|
110
|
+
/* Covered by the level inside this one, whole — which is the only way it can be covered, because
|
|
111
|
+
a finer block's edge falls on this level's patch boundaries. */
|
|
112
|
+
if (level > 0 && insideBlock(frame, level - 1, x, z, span))
|
|
113
|
+
return false;
|
|
114
|
+
out.level = level;
|
|
115
|
+
out.x = x;
|
|
116
|
+
out.z = z;
|
|
117
|
+
out.cells = span;
|
|
118
|
+
out.step = step;
|
|
119
|
+
out.neighbours = {};
|
|
120
|
+
declareCoarser(frame, out, 'minusX', x - 1, z);
|
|
121
|
+
declareCoarser(frame, out, 'plusX', x + span, z);
|
|
122
|
+
declareCoarser(frame, out, 'minusZ', x, z - 1);
|
|
123
|
+
declareCoarser(frame, out, 'plusZ', x, z + span);
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
/** Name a neighbour's step where it is coarser than this patch, and say nothing otherwise. */
|
|
127
|
+
function declareCoarser(frame, patch, side, probeX, probeZ) {
|
|
128
|
+
const level = clipmapLevelAt(frame, probeX, probeZ);
|
|
129
|
+
if (level <= patch.level)
|
|
130
|
+
return;
|
|
131
|
+
patch.neighbours[side] = 1 << level;
|
|
132
|
+
}
|
|
133
|
+
/** Every drawn patch, as a list. The convenience over `clipmapPatchAt`, not a second rule. */
|
|
134
|
+
export function selectClipmap(frame) {
|
|
135
|
+
const patches = [];
|
|
136
|
+
const total = clipmapPatchCount(frame);
|
|
137
|
+
const scratch = emptyClipmapPatch();
|
|
138
|
+
for (let index = 0; index < total; index += 1) {
|
|
139
|
+
if (!clipmapPatchAt(frame, index, scratch))
|
|
140
|
+
continue;
|
|
141
|
+
patches.push({ ...scratch, neighbours: { ...scratch.neighbours } });
|
|
142
|
+
}
|
|
143
|
+
return patches;
|
|
144
|
+
}
|
|
145
|
+
/** A selected patch as the options `heightfieldPatch` takes, with nothing added or renamed. */
|
|
146
|
+
export function clipmapPatchOptions(patch) {
|
|
147
|
+
return {
|
|
148
|
+
x: patch.x,
|
|
149
|
+
z: patch.z,
|
|
150
|
+
cells: patch.cells,
|
|
151
|
+
step: patch.step,
|
|
152
|
+
neighbours: patch.neighbours,
|
|
153
|
+
};
|
|
154
|
+
}
|
package/dist/heightfield.d.ts
CHANGED
|
@@ -14,6 +14,13 @@ import type { ReadonlyVec3 } from 'gl-matrix';
|
|
|
14
14
|
* So `heightAt` reads the *triangle*, and `heightfieldPatch` builds those same triangles, and a
|
|
15
15
|
* test asserts each vertex against the query.
|
|
16
16
|
*
|
|
17
|
+
* **And there is now a fourth surface, so the rule extends rather than changes.** Wave 4B stores
|
|
18
|
+
* heights as a `DTEX` layer, which quantises them — so a renderer reading the layer while a query
|
|
19
|
+
* reads the samples this class was built from differ by the format's tolerance, everywhere,
|
|
20
|
+
* permanently. `terrainTexture.ts` answers it the only way that holds: `terrainFromHeightLayer`
|
|
21
|
+
* builds a `Terrain` from the *decoded* samples, and everything reads that one object. The rule is
|
|
22
|
+
* unchanged — there is one surface — and what changed is where the numbers come from.
|
|
23
|
+
*
|
|
17
24
|
* **The normal is the other way round on purpose.** A face normal is constant over a triangle and
|
|
18
25
|
* jumps at every edge, so a field built from them is faceted, and — worse — two patches meeting at
|
|
19
26
|
* a boundary shade differently along it, which reads as a crack that is not there. `normalAt` is a
|
package/dist/index.d.ts
CHANGED
|
@@ -20,3 +20,7 @@ export { heightfieldPatch } from './heightfieldPatch.ts';
|
|
|
20
20
|
export type { HeightfieldPatchOptions } from './heightfieldPatch.ts';
|
|
21
21
|
export { TerrainMaterials, terrainMaterialWeights } from './terrainMaterials.ts';
|
|
22
22
|
export type { TerrainMaterial, TerrainMaterialsOptions } from './terrainMaterials.ts';
|
|
23
|
+
export { CLIPMAP_LEVELS, CLIPMAP_PATCHES_ACROSS, CLIPMAP_PATCH_CELLS, clipmapFrame, clipmapLevelAt, clipmapPatchAt, clipmapPatchCount, clipmapPatchOptions, emptyClipmapPatch, selectClipmap, } from './clipmap.ts';
|
|
24
|
+
export type { ClipmapFrame, ClipmapOptions, ClipmapPatch } from './clipmap.ts';
|
|
25
|
+
export { TERRAIN_HEIGHT_LEVELS, TERRAIN_SPLAT_CHANNELS, TERRAIN_SPLAT_LEVELS, decodeTerrainHeights, decodeTerrainSplat, encodeTerrainHeights, encodeTerrainSplat, terrainFromHeightLayer, terrainHeightTolerance, } from './terrainTexture.ts';
|
|
26
|
+
export type { TerrainHeightLayer, TerrainSplatLayer } from './terrainTexture.ts';
|
package/dist/index.js
CHANGED
|
@@ -17,3 +17,15 @@
|
|
|
17
17
|
export { Terrain } from './heightfield.js';
|
|
18
18
|
export { heightfieldPatch } from './heightfieldPatch.js';
|
|
19
19
|
export { TerrainMaterials, terrainMaterialWeights } from './terrainMaterials.js';
|
|
20
|
+
/*
|
|
21
|
+
* The clipmap: which squares of the field to draw at what detail, as one decision per patch so it
|
|
22
|
+
* can move into a compute pass beside the cluster cut rather than be rewritten there. It builds no
|
|
23
|
+
* geometry — `heightfieldPatch` does that, and matches the seam.
|
|
24
|
+
*/
|
|
25
|
+
export { CLIPMAP_LEVELS, CLIPMAP_PATCHES_ACROSS, CLIPMAP_PATCH_CELLS, clipmapFrame, clipmapLevelAt, clipmapPatchAt, clipmapPatchCount, clipmapPatchOptions, emptyClipmapPatch, selectClipmap, } from './clipmap.js';
|
|
26
|
+
/*
|
|
27
|
+
* Heights and material weights as `DTEX` layers, and the one rule that makes it safe: a terrain
|
|
28
|
+
* rebuilt from a layer is built from the decoded samples, so collision and rendering cannot read
|
|
29
|
+
* different numbers. Opt-in — a consumer who never names these imports none of `@driftengine/texture`.
|
|
30
|
+
*/
|
|
31
|
+
export { TERRAIN_HEIGHT_LEVELS, TERRAIN_SPLAT_CHANNELS, TERRAIN_SPLAT_LEVELS, decodeTerrainHeights, decodeTerrainSplat, encodeTerrainHeights, encodeTerrainSplat, terrainFromHeightLayer, terrainHeightTolerance, } from './terrainTexture.js';
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A heightfield and its material weights, held as `DTEX` layers.
|
|
3
|
+
*
|
|
4
|
+
* **What a layer is here is what a texture is everywhere in this engine: a small decode program
|
|
5
|
+
* over a latent image**, run by `@driftengine/texture`'s reference decoder. So terrain gets the
|
|
6
|
+
* whole format for free — the residency, the streaming, the tolerance, the determinism — instead of
|
|
7
|
+
* a second image path of its own, and a terrain layer is a texture a material graph could have
|
|
8
|
+
* produced.
|
|
9
|
+
*
|
|
10
|
+
* **The decode program produces a normalised channel and the range turns it into metres**, because
|
|
11
|
+
* the vocabulary has no affine operation and should not grow one for this: a height map in any
|
|
12
|
+
* format is a normalised value plus a range, and putting the range in the layer keeps the two
|
|
13
|
+
* together. `height-linear` is the semantic that says so.
|
|
14
|
+
*
|
|
15
|
+
* ---
|
|
16
|
+
*
|
|
17
|
+
* **The rule this file exists to hold, and the plan had it the other way round.** The plan asked
|
|
18
|
+
* that heights decoded from a layer match the source heightfield within the format's tolerance.
|
|
19
|
+
* They do. That is not enough, and believing it is is how the most reported terrain defect
|
|
20
|
+
* anywhere gets shipped: if the renderer reads the layer and collision reads the source, the two
|
|
21
|
+
* surfaces differ by exactly that tolerance, everywhere, permanently — a character floating or
|
|
22
|
+
* sinking by a fraction of a millimetre with nothing to attribute it to.
|
|
23
|
+
*
|
|
24
|
+
* So `terrainFromHeightLayer` builds a `Terrain` from the **decoded** samples, and everything —
|
|
25
|
+
* the query, the patch mesh, the collider `heightfieldPatch` feeds — reads that one. The two
|
|
26
|
+
* cannot disagree because there is only one set of numbers. `heightfield.ts` states the same rule
|
|
27
|
+
* one level down, for the triangle against the bilinear patch.
|
|
28
|
+
*
|
|
29
|
+
* **A consumer that never touches this path pays nothing for it.** The imports are named, the
|
|
30
|
+
* package is `sideEffects: false`, and `core-and-terrain`'s floor is what proves it stayed that
|
|
31
|
+
* way.
|
|
32
|
+
*/
|
|
33
|
+
import { type DecodeGraph, type DecodeResources } from '@driftengine/texture';
|
|
34
|
+
import { Terrain } from './heightfield.ts';
|
|
35
|
+
/** Quantisation levels a height layer carries: sixteen bits. */
|
|
36
|
+
export declare const TERRAIN_HEIGHT_LEVELS = 65536;
|
|
37
|
+
/** Quantisation levels one splat weight carries: eight bits, four of them to a texel. */
|
|
38
|
+
export declare const TERRAIN_SPLAT_LEVELS = 256;
|
|
39
|
+
/** Materials a splat layer blends. Four, because that is one texel. */
|
|
40
|
+
export declare const TERRAIN_SPLAT_CHANNELS = 4;
|
|
41
|
+
export interface TerrainHeightLayer {
|
|
42
|
+
readonly graph: DecodeGraph;
|
|
43
|
+
readonly resources: DecodeResources;
|
|
44
|
+
/** Samples across x and z, which is also the latent's size in texels. */
|
|
45
|
+
readonly width: number;
|
|
46
|
+
readonly depth: number;
|
|
47
|
+
/** Metres at the bottom of the encoded range. */
|
|
48
|
+
readonly minM: number;
|
|
49
|
+
/** Metres the encoded range spans. Zero for a flat field. */
|
|
50
|
+
readonly rangeM: number;
|
|
51
|
+
/** Metres between samples, carried so a rebuilt terrain cannot be given a different one. */
|
|
52
|
+
readonly spacingM: number;
|
|
53
|
+
/** World position of sample `(0, 0)`, carried for the same reason. */
|
|
54
|
+
readonly origin: Float32Array;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Encode a terrain's samples into a height layer.
|
|
58
|
+
*
|
|
59
|
+
* The range is the field's own, not a fixed world range: a valley two metres deep quantised
|
|
60
|
+
* against a range of four kilometres is a staircase, and terrain is where that is most visible
|
|
61
|
+
* because a shallow slope magnifies a step in height into a wide flat shelf.
|
|
62
|
+
*/
|
|
63
|
+
export declare function encodeTerrainHeights(terrain: Terrain): TerrainHeightLayer;
|
|
64
|
+
/**
|
|
65
|
+
* The most a decoded height can differ from the one encoded.
|
|
66
|
+
*
|
|
67
|
+
* **Half a quantisation step plus one float32 step of the range, and the second term is not
|
|
68
|
+
* padding.** The normalised value is stored in the latent as a float32, so the code is exact but
|
|
69
|
+
* `code / 65535` is not — and the decode multiplies that representation error back up by the whole
|
|
70
|
+
* range. Written as half a step alone, this function is wrong by about a part in a thousand, which
|
|
71
|
+
* is exactly enough for a tolerance assertion to fail on a field somebody did not choose
|
|
72
|
+
* carefully: measured 4.6372e-5 against a half-step of 4.6314e-5 over a range of six metres.
|
|
73
|
+
*
|
|
74
|
+
* Stated as a function of the layer rather than as a constant, since it is the field's own range
|
|
75
|
+
* that sets both terms.
|
|
76
|
+
*/
|
|
77
|
+
export declare function terrainHeightTolerance(layer: TerrainHeightLayer): number;
|
|
78
|
+
/** Decode every sample of a height layer into `out`, in metres, row-major with x fastest. */
|
|
79
|
+
export declare function decodeTerrainHeights(layer: TerrainHeightLayer, out: Float32Array): void;
|
|
80
|
+
/**
|
|
81
|
+
* The terrain a layer describes: the same class every consumer already queries, built from the
|
|
82
|
+
* decoded samples.
|
|
83
|
+
*
|
|
84
|
+
* **This is the whole answer to "collision and rendering read the same height".** They read the
|
|
85
|
+
* same object, because the layer produces exactly one.
|
|
86
|
+
*/
|
|
87
|
+
export declare function terrainFromHeightLayer(layer: TerrainHeightLayer): Terrain;
|
|
88
|
+
export interface TerrainSplatLayer {
|
|
89
|
+
readonly graph: DecodeGraph;
|
|
90
|
+
readonly resources: DecodeResources;
|
|
91
|
+
readonly width: number;
|
|
92
|
+
readonly depth: number;
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Encode four material weights per sample.
|
|
96
|
+
*
|
|
97
|
+
* Refuses a map whose weights do not already sum to one, rather than normalising it on the way in.
|
|
98
|
+
* A weight map nobody filled in sums to zero at every texel, and a normalisation applied quietly
|
|
99
|
+
* turns that into "material zero, everywhere" — a whole world of one texture, shipped, with no
|
|
100
|
+
* error anywhere. The caller's own weights are a statement about the ground and this does not
|
|
101
|
+
* edit it.
|
|
102
|
+
*/
|
|
103
|
+
export declare function encodeTerrainSplat(width: number, depth: number, weights: ArrayLike<number>): TerrainSplatLayer;
|
|
104
|
+
/**
|
|
105
|
+
* Sample the four weights at a field coordinate, renormalised so they sum to one.
|
|
106
|
+
*
|
|
107
|
+
* **The renormalisation is not tidiness.** Four weights each rounded to eight bits sum to anywhere
|
|
108
|
+
* between 0.994 and 1.006, and a shader that trusts the sum draws a hillside slightly too bright
|
|
109
|
+
* or slightly too dark — smoothly, so it reads as lighting rather than as a texture bug. The
|
|
110
|
+
* alternative, storing three weights and deriving the fourth, puts the entire error on one
|
|
111
|
+
* material and is worse for the same total.
|
|
112
|
+
*/
|
|
113
|
+
export declare function decodeTerrainSplat(layer: TerrainSplatLayer, u: number, v: number, out: Float32Array): void;
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A heightfield and its material weights, held as `DTEX` layers.
|
|
3
|
+
*
|
|
4
|
+
* **What a layer is here is what a texture is everywhere in this engine: a small decode program
|
|
5
|
+
* over a latent image**, run by `@driftengine/texture`'s reference decoder. So terrain gets the
|
|
6
|
+
* whole format for free — the residency, the streaming, the tolerance, the determinism — instead of
|
|
7
|
+
* a second image path of its own, and a terrain layer is a texture a material graph could have
|
|
8
|
+
* produced.
|
|
9
|
+
*
|
|
10
|
+
* **The decode program produces a normalised channel and the range turns it into metres**, because
|
|
11
|
+
* the vocabulary has no affine operation and should not grow one for this: a height map in any
|
|
12
|
+
* format is a normalised value plus a range, and putting the range in the layer keeps the two
|
|
13
|
+
* together. `height-linear` is the semantic that says so.
|
|
14
|
+
*
|
|
15
|
+
* ---
|
|
16
|
+
*
|
|
17
|
+
* **The rule this file exists to hold, and the plan had it the other way round.** The plan asked
|
|
18
|
+
* that heights decoded from a layer match the source heightfield within the format's tolerance.
|
|
19
|
+
* They do. That is not enough, and believing it is is how the most reported terrain defect
|
|
20
|
+
* anywhere gets shipped: if the renderer reads the layer and collision reads the source, the two
|
|
21
|
+
* surfaces differ by exactly that tolerance, everywhere, permanently — a character floating or
|
|
22
|
+
* sinking by a fraction of a millimetre with nothing to attribute it to.
|
|
23
|
+
*
|
|
24
|
+
* So `terrainFromHeightLayer` builds a `Terrain` from the **decoded** samples, and everything —
|
|
25
|
+
* the query, the patch mesh, the collider `heightfieldPatch` feeds — reads that one. The two
|
|
26
|
+
* cannot disagree because there is only one set of numbers. `heightfield.ts` states the same rule
|
|
27
|
+
* one level down, for the triangle against the bilinear patch.
|
|
28
|
+
*
|
|
29
|
+
* **A consumer that never touches this path pays nothing for it.** The imports are named, the
|
|
30
|
+
* package is `sideEffects: false`, and `core-and-terrain`'s floor is what proves it stayed that
|
|
31
|
+
* way.
|
|
32
|
+
*/
|
|
33
|
+
import { DECODE_OP, addDecodeNode, createDecodeGraph, createDecodeRegisters, decodeCpu, REMAP_SEMANTICS, } from '@driftengine/texture';
|
|
34
|
+
import { Terrain } from './heightfield.js';
|
|
35
|
+
/** Quantisation levels a height layer carries: sixteen bits. */
|
|
36
|
+
export const TERRAIN_HEIGHT_LEVELS = 65536;
|
|
37
|
+
/** Quantisation levels one splat weight carries: eight bits, four of them to a texel. */
|
|
38
|
+
export const TERRAIN_SPLAT_LEVELS = 256;
|
|
39
|
+
/** Materials a splat layer blends. Four, because that is one texel. */
|
|
40
|
+
export const TERRAIN_SPLAT_CHANNELS = 4;
|
|
41
|
+
/** `b` for a `REMAP_CHANNEL` node: the semantic's index in `REMAP_SEMANTICS`, then the component. */
|
|
42
|
+
function remapOperand(semantic, component) {
|
|
43
|
+
const index = REMAP_SEMANTICS.indexOf(semantic);
|
|
44
|
+
if (index < 0)
|
|
45
|
+
throw new Error(`terrainTexture: ${semantic} is not a remappable semantic`);
|
|
46
|
+
return (index << 4) | component;
|
|
47
|
+
}
|
|
48
|
+
/** A decode program that samples one latent and applies one channel convention. */
|
|
49
|
+
function oneChannelGraph(semantic) {
|
|
50
|
+
const graph = createDecodeGraph(2);
|
|
51
|
+
addDecodeNode(graph, DECODE_OP.SAMPLE_LATENT, 0, 0, 0);
|
|
52
|
+
addDecodeNode(graph, DECODE_OP.REMAP_CHANNEL, 0, remapOperand(semantic, 0), 1);
|
|
53
|
+
graph.result = 1;
|
|
54
|
+
/* Clamped, not wrapped: a field has an edge, and a query past it answers the edge. */
|
|
55
|
+
graph.addressMode = 0;
|
|
56
|
+
return graph;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Encode a terrain's samples into a height layer.
|
|
60
|
+
*
|
|
61
|
+
* The range is the field's own, not a fixed world range: a valley two metres deep quantised
|
|
62
|
+
* against a range of four kilometres is a staircase, and terrain is where that is most visible
|
|
63
|
+
* because a shallow slope magnifies a step in height into a wide flat shelf.
|
|
64
|
+
*/
|
|
65
|
+
export function encodeTerrainHeights(terrain) {
|
|
66
|
+
const count = terrain.width * terrain.depth;
|
|
67
|
+
let minM = Infinity;
|
|
68
|
+
let maxM = -Infinity;
|
|
69
|
+
for (let at = 0; at < count; at += 1) {
|
|
70
|
+
const h = terrain.heights[at];
|
|
71
|
+
if (h < minM)
|
|
72
|
+
minM = h;
|
|
73
|
+
if (h > maxM)
|
|
74
|
+
maxM = h;
|
|
75
|
+
}
|
|
76
|
+
if (!Number.isFinite(minM)) {
|
|
77
|
+
minM = 0;
|
|
78
|
+
maxM = 0;
|
|
79
|
+
}
|
|
80
|
+
const rangeM = maxM - minM;
|
|
81
|
+
const data = new Float32Array(count);
|
|
82
|
+
for (let at = 0; at < count; at += 1) {
|
|
83
|
+
/* A flat field has no range to divide by, and every sample is the minimum. */
|
|
84
|
+
const normalised = rangeM === 0 ? 0 : (terrain.heights[at] - minM) / rangeM;
|
|
85
|
+
data[at] = Math.round(normalised * (TERRAIN_HEIGHT_LEVELS - 1)) / (TERRAIN_HEIGHT_LEVELS - 1);
|
|
86
|
+
}
|
|
87
|
+
const latent = { data, width: terrain.width, height: terrain.depth, channels: 1 };
|
|
88
|
+
return {
|
|
89
|
+
graph: oneChannelGraph('height-linear'),
|
|
90
|
+
resources: { latents: [latent], blocks: [], networks: [] },
|
|
91
|
+
width: terrain.width,
|
|
92
|
+
depth: terrain.depth,
|
|
93
|
+
minM,
|
|
94
|
+
rangeM,
|
|
95
|
+
spacingM: terrain.spacingM,
|
|
96
|
+
origin: Float32Array.from(terrain.origin),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* The most a decoded height can differ from the one encoded.
|
|
101
|
+
*
|
|
102
|
+
* **Half a quantisation step plus one float32 step of the range, and the second term is not
|
|
103
|
+
* padding.** The normalised value is stored in the latent as a float32, so the code is exact but
|
|
104
|
+
* `code / 65535` is not — and the decode multiplies that representation error back up by the whole
|
|
105
|
+
* range. Written as half a step alone, this function is wrong by about a part in a thousand, which
|
|
106
|
+
* is exactly enough for a tolerance assertion to fail on a field somebody did not choose
|
|
107
|
+
* carefully: measured 4.6372e-5 against a half-step of 4.6314e-5 over a range of six metres.
|
|
108
|
+
*
|
|
109
|
+
* Stated as a function of the layer rather than as a constant, since it is the field's own range
|
|
110
|
+
* that sets both terms.
|
|
111
|
+
*/
|
|
112
|
+
export function terrainHeightTolerance(layer) {
|
|
113
|
+
/* 2⁻²⁴, written as a division because `**` is `Math.pow` to the determinism scan — and that
|
|
114
|
+
scan is right to refuse it: an engine is free to round `Math.pow` differently, and a
|
|
115
|
+
tolerance that differs by an ulp between two peers is a replay that disagrees. */
|
|
116
|
+
const FLOAT32_STEP = 1 / 16777216;
|
|
117
|
+
return layer.rangeM * (0.5 / (TERRAIN_HEIGHT_LEVELS - 1) + FLOAT32_STEP);
|
|
118
|
+
}
|
|
119
|
+
/** Decode every sample of a height layer into `out`, in metres, row-major with x fastest. */
|
|
120
|
+
export function decodeTerrainHeights(layer, out) {
|
|
121
|
+
const registers = createDecodeRegisters();
|
|
122
|
+
const sample = new Float32Array(4);
|
|
123
|
+
for (let z = 0; z < layer.depth; z += 1) {
|
|
124
|
+
for (let x = 0; x < layer.width; x += 1) {
|
|
125
|
+
/* Lattice coordinates: the decoder maps `u` across `width - 1`, so a sample lands on its own
|
|
126
|
+
texel exactly and the bilinear filter contributes nothing to a sample-aligned read. */
|
|
127
|
+
const u = layer.width === 1 ? 0 : x / (layer.width - 1);
|
|
128
|
+
const v = layer.depth === 1 ? 0 : z / (layer.depth - 1);
|
|
129
|
+
decodeCpu(layer.graph, layer.resources, u, v, 0, sample, registers);
|
|
130
|
+
out[z * layer.width + x] = layer.minM + sample[0] * layer.rangeM;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* The terrain a layer describes: the same class every consumer already queries, built from the
|
|
136
|
+
* decoded samples.
|
|
137
|
+
*
|
|
138
|
+
* **This is the whole answer to "collision and rendering read the same height".** They read the
|
|
139
|
+
* same object, because the layer produces exactly one.
|
|
140
|
+
*/
|
|
141
|
+
export function terrainFromHeightLayer(layer) {
|
|
142
|
+
const heights = new Float32Array(layer.width * layer.depth);
|
|
143
|
+
decodeTerrainHeights(layer, heights);
|
|
144
|
+
return new Terrain({
|
|
145
|
+
width: layer.width,
|
|
146
|
+
depth: layer.depth,
|
|
147
|
+
spacingM: layer.spacingM,
|
|
148
|
+
heights,
|
|
149
|
+
origin: layer.origin,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Encode four material weights per sample.
|
|
154
|
+
*
|
|
155
|
+
* Refuses a map whose weights do not already sum to one, rather than normalising it on the way in.
|
|
156
|
+
* A weight map nobody filled in sums to zero at every texel, and a normalisation applied quietly
|
|
157
|
+
* turns that into "material zero, everywhere" — a whole world of one texture, shipped, with no
|
|
158
|
+
* error anywhere. The caller's own weights are a statement about the ground and this does not
|
|
159
|
+
* edit it.
|
|
160
|
+
*/
|
|
161
|
+
export function encodeTerrainSplat(width, depth, weights) {
|
|
162
|
+
const count = width * depth;
|
|
163
|
+
if (weights.length !== count * TERRAIN_SPLAT_CHANNELS) {
|
|
164
|
+
throw new Error(`encodeTerrainSplat: ${width} by ${depth} needs ${count * TERRAIN_SPLAT_CHANNELS} weights, ` +
|
|
165
|
+
`got ${weights.length}`);
|
|
166
|
+
}
|
|
167
|
+
const data = new Float32Array(count * TERRAIN_SPLAT_CHANNELS);
|
|
168
|
+
for (let at = 0; at < count; at += 1) {
|
|
169
|
+
let sum = 0;
|
|
170
|
+
for (let c = 0; c < TERRAIN_SPLAT_CHANNELS; c += 1) {
|
|
171
|
+
sum += weights[at * TERRAIN_SPLAT_CHANNELS + c];
|
|
172
|
+
}
|
|
173
|
+
if (Math.abs(sum - 1) > 1e-3) {
|
|
174
|
+
throw new Error(`encodeTerrainSplat: the weights at sample ${at} sum to ${sum.toFixed(4)} and must sum to one`);
|
|
175
|
+
}
|
|
176
|
+
for (let c = 0; c < TERRAIN_SPLAT_CHANNELS; c += 1) {
|
|
177
|
+
const raw = weights[at * TERRAIN_SPLAT_CHANNELS + c];
|
|
178
|
+
data[at * TERRAIN_SPLAT_CHANNELS + c] =
|
|
179
|
+
Math.round(raw * (TERRAIN_SPLAT_LEVELS - 1)) / (TERRAIN_SPLAT_LEVELS - 1);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const latent = { data, width, height: depth, channels: TERRAIN_SPLAT_CHANNELS };
|
|
183
|
+
const graph = createDecodeGraph(1);
|
|
184
|
+
addDecodeNode(graph, DECODE_OP.SAMPLE_LATENT, 0, 0, 0);
|
|
185
|
+
graph.result = 0;
|
|
186
|
+
graph.addressMode = 0;
|
|
187
|
+
return { graph, resources: { latents: [latent], blocks: [], networks: [] }, width, depth };
|
|
188
|
+
}
|
|
189
|
+
const SPLAT_REGISTERS = createDecodeRegisters();
|
|
190
|
+
const SPLAT_SAMPLE = new Float32Array(4);
|
|
191
|
+
/**
|
|
192
|
+
* Sample the four weights at a field coordinate, renormalised so they sum to one.
|
|
193
|
+
*
|
|
194
|
+
* **The renormalisation is not tidiness.** Four weights each rounded to eight bits sum to anywhere
|
|
195
|
+
* between 0.994 and 1.006, and a shader that trusts the sum draws a hillside slightly too bright
|
|
196
|
+
* or slightly too dark — smoothly, so it reads as lighting rather than as a texture bug. The
|
|
197
|
+
* alternative, storing three weights and deriving the fourth, puts the entire error on one
|
|
198
|
+
* material and is worse for the same total.
|
|
199
|
+
*/
|
|
200
|
+
export function decodeTerrainSplat(layer, u, v, out) {
|
|
201
|
+
decodeCpu(layer.graph, layer.resources, u, v, 0, SPLAT_SAMPLE, SPLAT_REGISTERS);
|
|
202
|
+
let sum = 0;
|
|
203
|
+
for (let c = 0; c < TERRAIN_SPLAT_CHANNELS; c += 1)
|
|
204
|
+
sum += SPLAT_SAMPLE[c];
|
|
205
|
+
if (sum <= 0) {
|
|
206
|
+
/* Unreachable through `encodeTerrainSplat`, which refuses a map that could produce it. Kept
|
|
207
|
+
because a caller may build a layer another way, and dividing by zero here would answer NaN
|
|
208
|
+
weights that a shader turns into a black patch of ground. */
|
|
209
|
+
for (let c = 0; c < TERRAIN_SPLAT_CHANNELS; c += 1)
|
|
210
|
+
out[c] = c === 0 ? 1 : 0;
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
for (let c = 0; c < TERRAIN_SPLAT_CHANNELS; c += 1)
|
|
214
|
+
out[c] = SPLAT_SAMPLE[c] / sum;
|
|
215
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@driftengine/terrain",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "4.0.0",
|
|
4
4
|
"description": "Heightfields: a query that answers the surface that is drawn, and patches that meet without cracks",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -26,11 +26,15 @@
|
|
|
26
26
|
"NOTICE"
|
|
27
27
|
],
|
|
28
28
|
"sideEffects": false,
|
|
29
|
+
"dependencies": {
|
|
30
|
+
"@driftengine/texture": "4.0.0",
|
|
31
|
+
"gl-matrix": "^3.4.3"
|
|
32
|
+
},
|
|
29
33
|
"peerDependencies": {
|
|
30
|
-
"@driftengine/drft": "
|
|
34
|
+
"@driftengine/drft": "4.0.0"
|
|
31
35
|
},
|
|
32
36
|
"devDependencies": {
|
|
33
|
-
"@driftengine/physics": "
|
|
37
|
+
"@driftengine/physics": "4.0.0"
|
|
34
38
|
},
|
|
35
39
|
"author": "Drift Technologies",
|
|
36
40
|
"repository": {
|
package/src/clipmap.ts
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which squares of the field to draw, at what detail, for a camera standing somewhere.
|
|
3
|
+
*
|
|
4
|
+
* **A clipmap is rings of patches whose step doubles outward**, so the ground under the camera is
|
|
5
|
+
* drawn at full resolution and the horizon at a sixteenth of it, and the vertex count stays flat
|
|
6
|
+
* however large the world is. The hard part is not the rings; it is that they have to meet.
|
|
7
|
+
*
|
|
8
|
+
* **Two failures decide the whole design and neither is visible in a list of patches.** A field
|
|
9
|
+
* cell drawn by two patches is z-fighting on the ground. A field cell drawn by none is a hole
|
|
10
|
+
* through to the sky. Both come out of the arithmetic that snaps each level's block to a lattice,
|
|
11
|
+
* so that is what the tests assert directly: every cell in the footprint, counted.
|
|
12
|
+
*
|
|
13
|
+
* **Each level's block is snapped to an even patch index**, which is what makes the levels nest:
|
|
14
|
+
* an origin at an even multiple of a level's patch span is a multiple of the next level's patch
|
|
15
|
+
* span, so a finer block's edge always falls on a coarser patch boundary and never through the
|
|
16
|
+
* middle of one. A coarse patch is therefore covered by the finer block entirely or not at all,
|
|
17
|
+
* and culling it when it is covered leaves no gap and no overlap. The block being an even number
|
|
18
|
+
* of patches on each side of that index is why `patchesAcross` must be a multiple of four.
|
|
19
|
+
*
|
|
20
|
+
* **The hole a finer level leaves is not fixed at the centre**, which is the part that looks wrong
|
|
21
|
+
* and is right. Levels snap to their own lattices and re-centre at different moments, so the finer
|
|
22
|
+
* block sits a patch off-centre in the coarser one about half the time. Culling by *coverage*
|
|
23
|
+
* rather than by a fixed central hole is what makes that harmless — and a fixed hole is what the
|
|
24
|
+
* usual implementation does, which is why it needs an L-shaped strip to take up the slack.
|
|
25
|
+
*
|
|
26
|
+
* **The selection is one decision per patch**, taking the frame and an index and reading nothing
|
|
27
|
+
* else. That is what lets it move into a compute pass beside the cluster cut rather than be
|
|
28
|
+
* rewritten there: `clipmapPatchAt` is the body of that shader, and the test that asks for the
|
|
29
|
+
* patches in a scrambled order is what holds it.
|
|
30
|
+
*
|
|
31
|
+
* What it does *not* do is build geometry. `heightfieldPatch` does that, including moving a fine
|
|
32
|
+
* edge's odd vertices onto its coarse neighbour's chord — this module's job is to tell it which
|
|
33
|
+
* neighbour is coarser, because a patch that is not told draws an edge that follows the field
|
|
34
|
+
* while the patch beside it cuts the chord beneath.
|
|
35
|
+
*/
|
|
36
|
+
import type { Terrain } from './heightfield.ts';
|
|
37
|
+
import type { HeightfieldPatchOptions } from './heightfieldPatch.ts';
|
|
38
|
+
|
|
39
|
+
/** Drawn cells along one edge of every patch, at every level. */
|
|
40
|
+
export const CLIPMAP_PATCH_CELLS = 8;
|
|
41
|
+
|
|
42
|
+
/** Patches along one edge of a level's block. A multiple of four, for the reason above. */
|
|
43
|
+
export const CLIPMAP_PATCHES_ACROSS = 8;
|
|
44
|
+
|
|
45
|
+
/** Levels a frame builds when the caller does not say. */
|
|
46
|
+
export const CLIPMAP_LEVELS = 4;
|
|
47
|
+
|
|
48
|
+
export interface ClipmapOptions {
|
|
49
|
+
readonly levels?: number;
|
|
50
|
+
readonly patchCells?: number;
|
|
51
|
+
readonly patchesAcross?: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Everything a per-patch decision needs, computed once for a camera.
|
|
56
|
+
*
|
|
57
|
+
* Deliberately plain data: an `Int32Array` of origins and four numbers. What a compute pass would
|
|
58
|
+
* be handed as a uniform block, and nothing a shader could not hold.
|
|
59
|
+
*/
|
|
60
|
+
export interface ClipmapFrame {
|
|
61
|
+
readonly levels: number;
|
|
62
|
+
readonly patchCells: number;
|
|
63
|
+
readonly patchesAcross: number;
|
|
64
|
+
/** Two entries a level: the field-cell index of the block's first cell, x then z. */
|
|
65
|
+
readonly origins: Int32Array;
|
|
66
|
+
/** Cells the field has, which is one fewer than its samples. */
|
|
67
|
+
readonly fieldCellsX: number;
|
|
68
|
+
readonly fieldCellsZ: number;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A selected patch, in exactly the terms `heightfieldPatch` takes. */
|
|
72
|
+
export interface ClipmapPatch {
|
|
73
|
+
level: number;
|
|
74
|
+
/** Grid index of the patch's first sample, along x. */
|
|
75
|
+
x: number;
|
|
76
|
+
z: number;
|
|
77
|
+
/** Field cells the patch spans: `patchCells * step`. */
|
|
78
|
+
cells: number;
|
|
79
|
+
/** Field cells per drawn cell: `1 << level`. */
|
|
80
|
+
step: number;
|
|
81
|
+
/** The step of each neighbour that is *coarser* than this patch. The finer side is the one that
|
|
82
|
+
moves, so nothing is said about a neighbour at the same level or a finer one. */
|
|
83
|
+
neighbours: {
|
|
84
|
+
minusX?: number;
|
|
85
|
+
plusX?: number;
|
|
86
|
+
minusZ?: number;
|
|
87
|
+
plusZ?: number;
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** A patch to write into, so a caller looping over indices allocates nothing. */
|
|
92
|
+
export function emptyClipmapPatch(): ClipmapPatch {
|
|
93
|
+
return { level: 0, x: 0, z: 0, cells: 0, step: 1, neighbours: {} };
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Snap a camera to each level's block, once, for a frame.
|
|
98
|
+
*
|
|
99
|
+
* The camera is in world coordinates and the blocks are in field cells, because that is what
|
|
100
|
+
* `heightfieldPatch` indexes by and converting in one place is one place to be wrong.
|
|
101
|
+
*/
|
|
102
|
+
export function clipmapFrame(
|
|
103
|
+
terrain: Terrain,
|
|
104
|
+
cameraX: number,
|
|
105
|
+
cameraZ: number,
|
|
106
|
+
options: ClipmapOptions = {},
|
|
107
|
+
): ClipmapFrame {
|
|
108
|
+
const levels = Math.max(1, Math.round(options.levels ?? CLIPMAP_LEVELS));
|
|
109
|
+
const patchCells = Math.max(1, Math.round(options.patchCells ?? CLIPMAP_PATCH_CELLS));
|
|
110
|
+
const patchesAcross = Math.round(options.patchesAcross ?? CLIPMAP_PATCHES_ACROSS);
|
|
111
|
+
if (patchesAcross % 4 !== 0) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
`clipmapFrame: patchesAcross must be a multiple of four so the levels nest, got ${patchesAcross}`,
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const spacing = terrain.spacingM;
|
|
118
|
+
const cellX = Math.floor((cameraX - (terrain.origin[0] ?? 0)) / spacing);
|
|
119
|
+
const cellZ = Math.floor((cameraZ - (terrain.origin[2] ?? 0)) / spacing);
|
|
120
|
+
|
|
121
|
+
const origins = new Int32Array(levels * 2);
|
|
122
|
+
for (let level = 0; level < levels; level += 1) {
|
|
123
|
+
const span = patchCells << level;
|
|
124
|
+
origins[level * 2] = blockOrigin(cellX, span, patchesAcross);
|
|
125
|
+
origins[level * 2 + 1] = blockOrigin(cellZ, span, patchesAcross);
|
|
126
|
+
}
|
|
127
|
+
return {
|
|
128
|
+
levels,
|
|
129
|
+
patchCells,
|
|
130
|
+
patchesAcross,
|
|
131
|
+
origins,
|
|
132
|
+
fieldCellsX: terrain.width - 1,
|
|
133
|
+
fieldCellsZ: terrain.depth - 1,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The first field cell of a level's block along one axis.
|
|
139
|
+
*
|
|
140
|
+
* The camera's patch index rounded *down to an even one*, then half the block subtracted. Even is
|
|
141
|
+
* what makes the origin a multiple of twice the span, which is the next level's span — the one
|
|
142
|
+
* property everything else rests on.
|
|
143
|
+
*/
|
|
144
|
+
function blockOrigin(cameraCell: number, span: number, patchesAcross: number): number {
|
|
145
|
+
const patch = Math.floor(cameraCell / span);
|
|
146
|
+
const even = 2 * Math.floor(patch / 2);
|
|
147
|
+
return (even - patchesAcross / 2) * span;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Patches a frame decides about: every index a compute pass would launch a thread for. */
|
|
151
|
+
export function clipmapPatchCount(frame: ClipmapFrame): number {
|
|
152
|
+
return frame.levels * frame.patchesAcross * frame.patchesAcross;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Whether `[x, x + span)` on both axes lies inside level `level`'s block. */
|
|
156
|
+
function insideBlock(
|
|
157
|
+
frame: ClipmapFrame,
|
|
158
|
+
level: number,
|
|
159
|
+
x: number,
|
|
160
|
+
z: number,
|
|
161
|
+
span: number,
|
|
162
|
+
): boolean {
|
|
163
|
+
const width = frame.patchesAcross * (frame.patchCells << level);
|
|
164
|
+
const x0 = frame.origins[level * 2] as number;
|
|
165
|
+
const z0 = frame.origins[level * 2 + 1] as number;
|
|
166
|
+
return x >= x0 && z >= z0 && x + span <= x0 + width && z + span <= z0 + width;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function onField(frame: ClipmapFrame, x: number, z: number, span: number): boolean {
|
|
170
|
+
return x >= 0 && z >= 0 && x + span <= frame.fieldCellsX && z + span <= frame.fieldCellsZ;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The level whose drawn patch covers a field cell, or `-1` where nothing draws it.
|
|
175
|
+
*
|
|
176
|
+
* The finest level whose block contains the cell and whose patch there fits on the field. Finest
|
|
177
|
+
* rather than any, because a coarser patch over the same ground is exactly what gets culled.
|
|
178
|
+
*/
|
|
179
|
+
export function clipmapLevelAt(frame: ClipmapFrame, cellX: number, cellZ: number): number {
|
|
180
|
+
for (let level = 0; level < frame.levels; level += 1) {
|
|
181
|
+
const span = frame.patchCells << level;
|
|
182
|
+
const x0 = frame.origins[level * 2] as number;
|
|
183
|
+
const z0 = frame.origins[level * 2 + 1] as number;
|
|
184
|
+
const width = frame.patchesAcross * span;
|
|
185
|
+
if (cellX < x0 || cellZ < z0 || cellX >= x0 + width || cellZ >= z0 + width) continue;
|
|
186
|
+
const px = x0 + Math.floor((cellX - x0) / span) * span;
|
|
187
|
+
const pz = z0 + Math.floor((cellZ - z0) / span) * span;
|
|
188
|
+
if (!onField(frame, px, pz, span)) continue;
|
|
189
|
+
return level;
|
|
190
|
+
}
|
|
191
|
+
return -1;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Decide one patch, by index, writing it into `out`.
|
|
196
|
+
*
|
|
197
|
+
* Returns whether it is drawn. Reads the frame and the index and nothing else, which is what makes
|
|
198
|
+
* this the body of a compute shader rather than a loop that has to be rewritten as one.
|
|
199
|
+
*/
|
|
200
|
+
export function clipmapPatchAt(frame: ClipmapFrame, index: number, out: ClipmapPatch): boolean {
|
|
201
|
+
const perLevel = frame.patchesAcross * frame.patchesAcross;
|
|
202
|
+
const level = Math.floor(index / perLevel);
|
|
203
|
+
if (level < 0 || level >= frame.levels) return false;
|
|
204
|
+
const within = index - level * perLevel;
|
|
205
|
+
const i = within % frame.patchesAcross;
|
|
206
|
+
const j = Math.floor(within / frame.patchesAcross);
|
|
207
|
+
|
|
208
|
+
const step = 1 << level;
|
|
209
|
+
const span = frame.patchCells * step;
|
|
210
|
+
const x = (frame.origins[level * 2] as number) + i * span;
|
|
211
|
+
const z = (frame.origins[level * 2 + 1] as number) + j * span;
|
|
212
|
+
|
|
213
|
+
if (!onField(frame, x, z, span)) return false;
|
|
214
|
+
/* Covered by the level inside this one, whole — which is the only way it can be covered, because
|
|
215
|
+
a finer block's edge falls on this level's patch boundaries. */
|
|
216
|
+
if (level > 0 && insideBlock(frame, level - 1, x, z, span)) return false;
|
|
217
|
+
|
|
218
|
+
out.level = level;
|
|
219
|
+
out.x = x;
|
|
220
|
+
out.z = z;
|
|
221
|
+
out.cells = span;
|
|
222
|
+
out.step = step;
|
|
223
|
+
out.neighbours = {};
|
|
224
|
+
declareCoarser(frame, out, 'minusX', x - 1, z);
|
|
225
|
+
declareCoarser(frame, out, 'plusX', x + span, z);
|
|
226
|
+
declareCoarser(frame, out, 'minusZ', x, z - 1);
|
|
227
|
+
declareCoarser(frame, out, 'plusZ', x, z + span);
|
|
228
|
+
return true;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** Name a neighbour's step where it is coarser than this patch, and say nothing otherwise. */
|
|
232
|
+
function declareCoarser(
|
|
233
|
+
frame: ClipmapFrame,
|
|
234
|
+
patch: ClipmapPatch,
|
|
235
|
+
side: keyof ClipmapPatch['neighbours'],
|
|
236
|
+
probeX: number,
|
|
237
|
+
probeZ: number,
|
|
238
|
+
): void {
|
|
239
|
+
const level = clipmapLevelAt(frame, probeX, probeZ);
|
|
240
|
+
if (level <= patch.level) return;
|
|
241
|
+
patch.neighbours[side] = 1 << level;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Every drawn patch, as a list. The convenience over `clipmapPatchAt`, not a second rule. */
|
|
245
|
+
export function selectClipmap(frame: ClipmapFrame): ClipmapPatch[] {
|
|
246
|
+
const patches: ClipmapPatch[] = [];
|
|
247
|
+
const total = clipmapPatchCount(frame);
|
|
248
|
+
const scratch = emptyClipmapPatch();
|
|
249
|
+
for (let index = 0; index < total; index += 1) {
|
|
250
|
+
if (!clipmapPatchAt(frame, index, scratch)) continue;
|
|
251
|
+
patches.push({ ...scratch, neighbours: { ...scratch.neighbours } });
|
|
252
|
+
}
|
|
253
|
+
return patches;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** A selected patch as the options `heightfieldPatch` takes, with nothing added or renamed. */
|
|
257
|
+
export function clipmapPatchOptions(patch: ClipmapPatch): HeightfieldPatchOptions {
|
|
258
|
+
return {
|
|
259
|
+
x: patch.x,
|
|
260
|
+
z: patch.z,
|
|
261
|
+
cells: patch.cells,
|
|
262
|
+
step: patch.step,
|
|
263
|
+
neighbours: patch.neighbours,
|
|
264
|
+
};
|
|
265
|
+
}
|
package/src/heightfield.ts
CHANGED
|
@@ -15,6 +15,13 @@ import type { ReadonlyVec3 } from 'gl-matrix';
|
|
|
15
15
|
* So `heightAt` reads the *triangle*, and `heightfieldPatch` builds those same triangles, and a
|
|
16
16
|
* test asserts each vertex against the query.
|
|
17
17
|
*
|
|
18
|
+
* **And there is now a fourth surface, so the rule extends rather than changes.** Wave 4B stores
|
|
19
|
+
* heights as a `DTEX` layer, which quantises them — so a renderer reading the layer while a query
|
|
20
|
+
* reads the samples this class was built from differ by the format's tolerance, everywhere,
|
|
21
|
+
* permanently. `terrainTexture.ts` answers it the only way that holds: `terrainFromHeightLayer`
|
|
22
|
+
* builds a `Terrain` from the *decoded* samples, and everything reads that one object. The rule is
|
|
23
|
+
* unchanged — there is one surface — and what changed is where the numbers come from.
|
|
24
|
+
*
|
|
18
25
|
* **The normal is the other way round on purpose.** A face normal is constant over a triangle and
|
|
19
26
|
* jumps at every edge, so a field built from them is faceted, and — worse — two patches meeting at
|
|
20
27
|
* a boundary shade differently along it, which reads as a crack that is not there. `normalAt` is a
|
package/src/index.ts
CHANGED
|
@@ -21,3 +21,38 @@ export { heightfieldPatch } from './heightfieldPatch.ts';
|
|
|
21
21
|
export type { HeightfieldPatchOptions } from './heightfieldPatch.ts';
|
|
22
22
|
export { TerrainMaterials, terrainMaterialWeights } from './terrainMaterials.ts';
|
|
23
23
|
export type { TerrainMaterial, TerrainMaterialsOptions } from './terrainMaterials.ts';
|
|
24
|
+
/*
|
|
25
|
+
* The clipmap: which squares of the field to draw at what detail, as one decision per patch so it
|
|
26
|
+
* can move into a compute pass beside the cluster cut rather than be rewritten there. It builds no
|
|
27
|
+
* geometry — `heightfieldPatch` does that, and matches the seam.
|
|
28
|
+
*/
|
|
29
|
+
export {
|
|
30
|
+
CLIPMAP_LEVELS,
|
|
31
|
+
CLIPMAP_PATCHES_ACROSS,
|
|
32
|
+
CLIPMAP_PATCH_CELLS,
|
|
33
|
+
clipmapFrame,
|
|
34
|
+
clipmapLevelAt,
|
|
35
|
+
clipmapPatchAt,
|
|
36
|
+
clipmapPatchCount,
|
|
37
|
+
clipmapPatchOptions,
|
|
38
|
+
emptyClipmapPatch,
|
|
39
|
+
selectClipmap,
|
|
40
|
+
} from './clipmap.ts';
|
|
41
|
+
export type { ClipmapFrame, ClipmapOptions, ClipmapPatch } from './clipmap.ts';
|
|
42
|
+
/*
|
|
43
|
+
* Heights and material weights as `DTEX` layers, and the one rule that makes it safe: a terrain
|
|
44
|
+
* rebuilt from a layer is built from the decoded samples, so collision and rendering cannot read
|
|
45
|
+
* different numbers. Opt-in — a consumer who never names these imports none of `@driftengine/texture`.
|
|
46
|
+
*/
|
|
47
|
+
export {
|
|
48
|
+
TERRAIN_HEIGHT_LEVELS,
|
|
49
|
+
TERRAIN_SPLAT_CHANNELS,
|
|
50
|
+
TERRAIN_SPLAT_LEVELS,
|
|
51
|
+
decodeTerrainHeights,
|
|
52
|
+
decodeTerrainSplat,
|
|
53
|
+
encodeTerrainHeights,
|
|
54
|
+
encodeTerrainSplat,
|
|
55
|
+
terrainFromHeightLayer,
|
|
56
|
+
terrainHeightTolerance,
|
|
57
|
+
} from './terrainTexture.ts';
|
|
58
|
+
export type { TerrainHeightLayer, TerrainSplatLayer } from './terrainTexture.ts';
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A heightfield and its material weights, held as `DTEX` layers.
|
|
3
|
+
*
|
|
4
|
+
* **What a layer is here is what a texture is everywhere in this engine: a small decode program
|
|
5
|
+
* over a latent image**, run by `@driftengine/texture`'s reference decoder. So terrain gets the
|
|
6
|
+
* whole format for free — the residency, the streaming, the tolerance, the determinism — instead of
|
|
7
|
+
* a second image path of its own, and a terrain layer is a texture a material graph could have
|
|
8
|
+
* produced.
|
|
9
|
+
*
|
|
10
|
+
* **The decode program produces a normalised channel and the range turns it into metres**, because
|
|
11
|
+
* the vocabulary has no affine operation and should not grow one for this: a height map in any
|
|
12
|
+
* format is a normalised value plus a range, and putting the range in the layer keeps the two
|
|
13
|
+
* together. `height-linear` is the semantic that says so.
|
|
14
|
+
*
|
|
15
|
+
* ---
|
|
16
|
+
*
|
|
17
|
+
* **The rule this file exists to hold, and the plan had it the other way round.** The plan asked
|
|
18
|
+
* that heights decoded from a layer match the source heightfield within the format's tolerance.
|
|
19
|
+
* They do. That is not enough, and believing it is is how the most reported terrain defect
|
|
20
|
+
* anywhere gets shipped: if the renderer reads the layer and collision reads the source, the two
|
|
21
|
+
* surfaces differ by exactly that tolerance, everywhere, permanently — a character floating or
|
|
22
|
+
* sinking by a fraction of a millimetre with nothing to attribute it to.
|
|
23
|
+
*
|
|
24
|
+
* So `terrainFromHeightLayer` builds a `Terrain` from the **decoded** samples, and everything —
|
|
25
|
+
* the query, the patch mesh, the collider `heightfieldPatch` feeds — reads that one. The two
|
|
26
|
+
* cannot disagree because there is only one set of numbers. `heightfield.ts` states the same rule
|
|
27
|
+
* one level down, for the triangle against the bilinear patch.
|
|
28
|
+
*
|
|
29
|
+
* **A consumer that never touches this path pays nothing for it.** The imports are named, the
|
|
30
|
+
* package is `sideEffects: false`, and `core-and-terrain`'s floor is what proves it stayed that
|
|
31
|
+
* way.
|
|
32
|
+
*/
|
|
33
|
+
import {
|
|
34
|
+
DECODE_OP,
|
|
35
|
+
addDecodeNode,
|
|
36
|
+
createDecodeGraph,
|
|
37
|
+
createDecodeRegisters,
|
|
38
|
+
decodeCpu,
|
|
39
|
+
REMAP_SEMANTICS,
|
|
40
|
+
type DecodeGraph,
|
|
41
|
+
type DecodeResources,
|
|
42
|
+
type LatentImage,
|
|
43
|
+
} from '@driftengine/texture';
|
|
44
|
+
|
|
45
|
+
import { Terrain } from './heightfield.ts';
|
|
46
|
+
|
|
47
|
+
/** Quantisation levels a height layer carries: sixteen bits. */
|
|
48
|
+
export const TERRAIN_HEIGHT_LEVELS = 65536;
|
|
49
|
+
|
|
50
|
+
/** Quantisation levels one splat weight carries: eight bits, four of them to a texel. */
|
|
51
|
+
export const TERRAIN_SPLAT_LEVELS = 256;
|
|
52
|
+
|
|
53
|
+
/** Materials a splat layer blends. Four, because that is one texel. */
|
|
54
|
+
export const TERRAIN_SPLAT_CHANNELS = 4;
|
|
55
|
+
|
|
56
|
+
/** `b` for a `REMAP_CHANNEL` node: the semantic's index in `REMAP_SEMANTICS`, then the component. */
|
|
57
|
+
function remapOperand(semantic: (typeof REMAP_SEMANTICS)[number], component: number): number {
|
|
58
|
+
const index = REMAP_SEMANTICS.indexOf(semantic);
|
|
59
|
+
if (index < 0) throw new Error(`terrainTexture: ${semantic} is not a remappable semantic`);
|
|
60
|
+
return (index << 4) | component;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** A decode program that samples one latent and applies one channel convention. */
|
|
64
|
+
function oneChannelGraph(semantic: (typeof REMAP_SEMANTICS)[number]): DecodeGraph {
|
|
65
|
+
const graph = createDecodeGraph(2);
|
|
66
|
+
addDecodeNode(graph, DECODE_OP.SAMPLE_LATENT, 0, 0, 0);
|
|
67
|
+
addDecodeNode(graph, DECODE_OP.REMAP_CHANNEL, 0, remapOperand(semantic, 0), 1);
|
|
68
|
+
graph.result = 1;
|
|
69
|
+
/* Clamped, not wrapped: a field has an edge, and a query past it answers the edge. */
|
|
70
|
+
graph.addressMode = 0;
|
|
71
|
+
return graph;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface TerrainHeightLayer {
|
|
75
|
+
readonly graph: DecodeGraph;
|
|
76
|
+
readonly resources: DecodeResources;
|
|
77
|
+
/** Samples across x and z, which is also the latent's size in texels. */
|
|
78
|
+
readonly width: number;
|
|
79
|
+
readonly depth: number;
|
|
80
|
+
/** Metres at the bottom of the encoded range. */
|
|
81
|
+
readonly minM: number;
|
|
82
|
+
/** Metres the encoded range spans. Zero for a flat field. */
|
|
83
|
+
readonly rangeM: number;
|
|
84
|
+
/** Metres between samples, carried so a rebuilt terrain cannot be given a different one. */
|
|
85
|
+
readonly spacingM: number;
|
|
86
|
+
/** World position of sample `(0, 0)`, carried for the same reason. */
|
|
87
|
+
readonly origin: Float32Array;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Encode a terrain's samples into a height layer.
|
|
92
|
+
*
|
|
93
|
+
* The range is the field's own, not a fixed world range: a valley two metres deep quantised
|
|
94
|
+
* against a range of four kilometres is a staircase, and terrain is where that is most visible
|
|
95
|
+
* because a shallow slope magnifies a step in height into a wide flat shelf.
|
|
96
|
+
*/
|
|
97
|
+
export function encodeTerrainHeights(terrain: Terrain): TerrainHeightLayer {
|
|
98
|
+
const count = terrain.width * terrain.depth;
|
|
99
|
+
let minM = Infinity;
|
|
100
|
+
let maxM = -Infinity;
|
|
101
|
+
for (let at = 0; at < count; at += 1) {
|
|
102
|
+
const h = terrain.heights[at] as number;
|
|
103
|
+
if (h < minM) minM = h;
|
|
104
|
+
if (h > maxM) maxM = h;
|
|
105
|
+
}
|
|
106
|
+
if (!Number.isFinite(minM)) {
|
|
107
|
+
minM = 0;
|
|
108
|
+
maxM = 0;
|
|
109
|
+
}
|
|
110
|
+
const rangeM = maxM - minM;
|
|
111
|
+
|
|
112
|
+
const data = new Float32Array(count);
|
|
113
|
+
for (let at = 0; at < count; at += 1) {
|
|
114
|
+
/* A flat field has no range to divide by, and every sample is the minimum. */
|
|
115
|
+
const normalised = rangeM === 0 ? 0 : ((terrain.heights[at] as number) - minM) / rangeM;
|
|
116
|
+
data[at] = Math.round(normalised * (TERRAIN_HEIGHT_LEVELS - 1)) / (TERRAIN_HEIGHT_LEVELS - 1);
|
|
117
|
+
}
|
|
118
|
+
const latent: LatentImage = { data, width: terrain.width, height: terrain.depth, channels: 1 };
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
graph: oneChannelGraph('height-linear'),
|
|
122
|
+
resources: { latents: [latent], blocks: [], networks: [] },
|
|
123
|
+
width: terrain.width,
|
|
124
|
+
depth: terrain.depth,
|
|
125
|
+
minM,
|
|
126
|
+
rangeM,
|
|
127
|
+
spacingM: terrain.spacingM,
|
|
128
|
+
origin: Float32Array.from(terrain.origin),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The most a decoded height can differ from the one encoded.
|
|
134
|
+
*
|
|
135
|
+
* **Half a quantisation step plus one float32 step of the range, and the second term is not
|
|
136
|
+
* padding.** The normalised value is stored in the latent as a float32, so the code is exact but
|
|
137
|
+
* `code / 65535` is not — and the decode multiplies that representation error back up by the whole
|
|
138
|
+
* range. Written as half a step alone, this function is wrong by about a part in a thousand, which
|
|
139
|
+
* is exactly enough for a tolerance assertion to fail on a field somebody did not choose
|
|
140
|
+
* carefully: measured 4.6372e-5 against a half-step of 4.6314e-5 over a range of six metres.
|
|
141
|
+
*
|
|
142
|
+
* Stated as a function of the layer rather than as a constant, since it is the field's own range
|
|
143
|
+
* that sets both terms.
|
|
144
|
+
*/
|
|
145
|
+
export function terrainHeightTolerance(layer: TerrainHeightLayer): number {
|
|
146
|
+
/* 2⁻²⁴, written as a division because `**` is `Math.pow` to the determinism scan — and that
|
|
147
|
+
scan is right to refuse it: an engine is free to round `Math.pow` differently, and a
|
|
148
|
+
tolerance that differs by an ulp between two peers is a replay that disagrees. */
|
|
149
|
+
const FLOAT32_STEP = 1 / 16777216;
|
|
150
|
+
return layer.rangeM * (0.5 / (TERRAIN_HEIGHT_LEVELS - 1) + FLOAT32_STEP);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Decode every sample of a height layer into `out`, in metres, row-major with x fastest. */
|
|
154
|
+
export function decodeTerrainHeights(layer: TerrainHeightLayer, out: Float32Array): void {
|
|
155
|
+
const registers = createDecodeRegisters();
|
|
156
|
+
const sample = new Float32Array(4);
|
|
157
|
+
for (let z = 0; z < layer.depth; z += 1) {
|
|
158
|
+
for (let x = 0; x < layer.width; x += 1) {
|
|
159
|
+
/* Lattice coordinates: the decoder maps `u` across `width - 1`, so a sample lands on its own
|
|
160
|
+
texel exactly and the bilinear filter contributes nothing to a sample-aligned read. */
|
|
161
|
+
const u = layer.width === 1 ? 0 : x / (layer.width - 1);
|
|
162
|
+
const v = layer.depth === 1 ? 0 : z / (layer.depth - 1);
|
|
163
|
+
decodeCpu(layer.graph, layer.resources, u, v, 0, sample, registers);
|
|
164
|
+
out[z * layer.width + x] = layer.minM + (sample[0] as number) * layer.rangeM;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The terrain a layer describes: the same class every consumer already queries, built from the
|
|
171
|
+
* decoded samples.
|
|
172
|
+
*
|
|
173
|
+
* **This is the whole answer to "collision and rendering read the same height".** They read the
|
|
174
|
+
* same object, because the layer produces exactly one.
|
|
175
|
+
*/
|
|
176
|
+
export function terrainFromHeightLayer(layer: TerrainHeightLayer): Terrain {
|
|
177
|
+
const heights = new Float32Array(layer.width * layer.depth);
|
|
178
|
+
decodeTerrainHeights(layer, heights);
|
|
179
|
+
return new Terrain({
|
|
180
|
+
width: layer.width,
|
|
181
|
+
depth: layer.depth,
|
|
182
|
+
spacingM: layer.spacingM,
|
|
183
|
+
heights,
|
|
184
|
+
origin: layer.origin,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export interface TerrainSplatLayer {
|
|
189
|
+
readonly graph: DecodeGraph;
|
|
190
|
+
readonly resources: DecodeResources;
|
|
191
|
+
readonly width: number;
|
|
192
|
+
readonly depth: number;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Encode four material weights per sample.
|
|
197
|
+
*
|
|
198
|
+
* Refuses a map whose weights do not already sum to one, rather than normalising it on the way in.
|
|
199
|
+
* A weight map nobody filled in sums to zero at every texel, and a normalisation applied quietly
|
|
200
|
+
* turns that into "material zero, everywhere" — a whole world of one texture, shipped, with no
|
|
201
|
+
* error anywhere. The caller's own weights are a statement about the ground and this does not
|
|
202
|
+
* edit it.
|
|
203
|
+
*/
|
|
204
|
+
export function encodeTerrainSplat(
|
|
205
|
+
width: number,
|
|
206
|
+
depth: number,
|
|
207
|
+
weights: ArrayLike<number>,
|
|
208
|
+
): TerrainSplatLayer {
|
|
209
|
+
const count = width * depth;
|
|
210
|
+
if (weights.length !== count * TERRAIN_SPLAT_CHANNELS) {
|
|
211
|
+
throw new Error(
|
|
212
|
+
`encodeTerrainSplat: ${width} by ${depth} needs ${count * TERRAIN_SPLAT_CHANNELS} weights, ` +
|
|
213
|
+
`got ${weights.length}`,
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
const data = new Float32Array(count * TERRAIN_SPLAT_CHANNELS);
|
|
217
|
+
for (let at = 0; at < count; at += 1) {
|
|
218
|
+
let sum = 0;
|
|
219
|
+
for (let c = 0; c < TERRAIN_SPLAT_CHANNELS; c += 1) {
|
|
220
|
+
sum += weights[at * TERRAIN_SPLAT_CHANNELS + c] as number;
|
|
221
|
+
}
|
|
222
|
+
if (Math.abs(sum - 1) > 1e-3) {
|
|
223
|
+
throw new Error(
|
|
224
|
+
`encodeTerrainSplat: the weights at sample ${at} sum to ${sum.toFixed(4)} and must sum to one`,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
for (let c = 0; c < TERRAIN_SPLAT_CHANNELS; c += 1) {
|
|
228
|
+
const raw = weights[at * TERRAIN_SPLAT_CHANNELS + c] as number;
|
|
229
|
+
data[at * TERRAIN_SPLAT_CHANNELS + c] =
|
|
230
|
+
Math.round(raw * (TERRAIN_SPLAT_LEVELS - 1)) / (TERRAIN_SPLAT_LEVELS - 1);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
const latent: LatentImage = { data, width, height: depth, channels: TERRAIN_SPLAT_CHANNELS };
|
|
234
|
+
const graph = createDecodeGraph(1);
|
|
235
|
+
addDecodeNode(graph, DECODE_OP.SAMPLE_LATENT, 0, 0, 0);
|
|
236
|
+
graph.result = 0;
|
|
237
|
+
graph.addressMode = 0;
|
|
238
|
+
return { graph, resources: { latents: [latent], blocks: [], networks: [] }, width, depth };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const SPLAT_REGISTERS = createDecodeRegisters();
|
|
242
|
+
const SPLAT_SAMPLE = new Float32Array(4);
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Sample the four weights at a field coordinate, renormalised so they sum to one.
|
|
246
|
+
*
|
|
247
|
+
* **The renormalisation is not tidiness.** Four weights each rounded to eight bits sum to anywhere
|
|
248
|
+
* between 0.994 and 1.006, and a shader that trusts the sum draws a hillside slightly too bright
|
|
249
|
+
* or slightly too dark — smoothly, so it reads as lighting rather than as a texture bug. The
|
|
250
|
+
* alternative, storing three weights and deriving the fourth, puts the entire error on one
|
|
251
|
+
* material and is worse for the same total.
|
|
252
|
+
*/
|
|
253
|
+
export function decodeTerrainSplat(
|
|
254
|
+
layer: TerrainSplatLayer,
|
|
255
|
+
u: number,
|
|
256
|
+
v: number,
|
|
257
|
+
out: Float32Array,
|
|
258
|
+
): void {
|
|
259
|
+
decodeCpu(layer.graph, layer.resources, u, v, 0, SPLAT_SAMPLE, SPLAT_REGISTERS);
|
|
260
|
+
let sum = 0;
|
|
261
|
+
for (let c = 0; c < TERRAIN_SPLAT_CHANNELS; c += 1) sum += SPLAT_SAMPLE[c] as number;
|
|
262
|
+
if (sum <= 0) {
|
|
263
|
+
/* Unreachable through `encodeTerrainSplat`, which refuses a map that could produce it. Kept
|
|
264
|
+
because a caller may build a layer another way, and dividing by zero here would answer NaN
|
|
265
|
+
weights that a shader turns into a black patch of ground. */
|
|
266
|
+
for (let c = 0; c < TERRAIN_SPLAT_CHANNELS; c += 1) out[c] = c === 0 ? 1 : 0;
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
for (let c = 0; c < TERRAIN_SPLAT_CHANNELS; c += 1) out[c] = (SPLAT_SAMPLE[c] as number) / sum;
|
|
270
|
+
}
|