@driftengine/terrain 3.63.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 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;
@@ -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
+ }
@@ -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.63.0",
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": "3.63.0"
34
+ "@driftengine/drft": "4.0.0"
31
35
  },
32
36
  "devDependencies": {
33
- "@driftengine/physics": "3.63.0"
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
+ }
@@ -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
+ }