mapspinner 0.1.1
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/.claude/workflows/fps-perf.js +147 -0
- package/.claude/workflows/optimize-dna.js +201 -0
- package/.claude/workflows/shader-bottleneck-dna.js +168 -0
- package/.claude/workflows/speed-dna.js +209 -0
- package/.claude/workflows/startup-perf.js +117 -0
- package/.github/workflows/publish.yml +43 -0
- package/AGENTS.md +265 -0
- package/CHANGELOG.md +31 -0
- package/CLAUDE.md +1 -0
- package/README.md +82 -0
- package/examples/basic-sdk-usage.html +114 -0
- package/package.json +28 -0
- package/planet.html +2181 -0
- package/planet.zip +0 -0
- package/scripts/backend-ab.mjs +88 -0
- package/scripts/dev-chrome.cmd +22 -0
- package/scripts/verify.mjs +69 -0
- package/server.js +127 -0
- package/src/anchor-field.js +559 -0
- package/src/gl-render.js +944 -0
- package/src/index.js +41 -0
- package/src/planet-orchestrator.js +790 -0
- package/src/quadtree.js +160 -0
- package/src/shaders/atmosphere.glsl +215 -0
- package/src/shaders/terrain.glsl +2109 -0
- package/src/terrain-gen-controls.js +122 -0
- package/tests/run.js +58 -0
- package/textures/grass-color.jpg +0 -0
- package/textures/grass-displacement.jpg +0 -0
- package/textures/rock-color.jpg +0 -0
- package/textures/rock-displacement.jpg +0 -0
- package/textures/sand-color.jpg +0 -0
- package/textures/sand-displacement.jpg +0 -0
- package/textures/snow-color.jpg +0 -0
- package/textures/snow-displacement.jpg +0 -0
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
// anchor-field.js -- the Hierarchical Parameter Field (HPF) that drives TV8 terrain
|
|
2
|
+
// generation from a stack of fixed, spatially-indexed anchor quadtrees.
|
|
3
|
+
//
|
|
4
|
+
// DESIGN (user directive): a HIERARCHY of parameters across scale bands, each band its
|
|
5
|
+
// OWN quadtree spatial index, each band driven by its OWN fractal -- so different fractals
|
|
6
|
+
// affect different scale bands in different ways. Anchors are FIXED (the quadtree node
|
|
7
|
+
// centres; they never move, and the count per band never changes); only their PARAMETER
|
|
8
|
+
// payload mutates. The entire planet is anchor-indexed (every surface point falls inside
|
|
9
|
+
// exactly one node per band), the distribution is EVEN by construction (a quadtree over a
|
|
10
|
+
// cube face subdivides uniformly), and support is LOCAL: a sample reads only the node it
|
|
11
|
+
// lands in plus its 3 neighbours for bilinear blend, so editing one node's params changes
|
|
12
|
+
// only that cell's neighbourhood -- never the whole planet.
|
|
13
|
+
//
|
|
14
|
+
// INTEGRATION: this is a pure-JS field (editable, serialisable). The orchestrator samples
|
|
15
|
+
// it per Proland tile and pushes the result into the wasm via the existing setters + a
|
|
16
|
+
// per-tile additive bias, so Proland's upsample-coherence and seamlessness are preserved
|
|
17
|
+
// (the field is C0-continuous across tile edges because every tile samples the SAME global
|
|
18
|
+
// quadtree partition at its own scale band). No wasm rebuild is needed to edit params.
|
|
19
|
+
//
|
|
20
|
+
// PERFORMANCE (all the most-performant choices):
|
|
21
|
+
// - Param storage is a flat Float32Array per band (cache-friendly, zero object churn),
|
|
22
|
+
// indexed by the node's linear quadtree address. O(1) read/write.
|
|
23
|
+
// - The procedural BASE of every node derives from a deterministic integer hash of its
|
|
24
|
+
// (face, band, level, tx, ty) -- no RNG state, fully reproducible, so persistence only
|
|
25
|
+
// needs the EDITED deltas (a sparse Map), not the whole field.
|
|
26
|
+
// - Sampling is O(bands) -- a handful of band lookups + one bilinear per band -- with no
|
|
27
|
+
// allocation on the hot path (results written into a reused scratch object).
|
|
28
|
+
|
|
29
|
+
// ---- cube-face frame (mirrors proland-planet-orchestrator FACE_FRAME / render localToWorld3).
|
|
30
|
+
// A face-local point (u,v in [-1,1], outward axis) maps to a world direction. We only need
|
|
31
|
+
// the inverse (world dir -> face + uv) for sampleDir, and the forward (face,uv -> dir) for
|
|
32
|
+
// the per-node procedural hash seed coordinate.
|
|
33
|
+
const FACE_FRAME = [
|
|
34
|
+
{ c: [ 1, 0, 0], u: [0, 0, -1], v: [0, 1, 0] }, // +X
|
|
35
|
+
{ c: [-1, 0, 0], u: [0, 0, 1], v: [0, 1, 0] }, // -X
|
|
36
|
+
{ c: [0, 1, 0], u: [1, 0, 0], v: [0, 0, -1] }, // +Y
|
|
37
|
+
{ c: [0, -1, 0], u: [1, 0, 0], v: [0, 0, 1] }, // -Y
|
|
38
|
+
{ c: [0, 0, 1], u: [1, 0, 0], v: [0, 1, 0] }, // +Z
|
|
39
|
+
{ c: [0, 0, -1], u: [-1, 0, 0], v: [0, 1, 0] }, // -Z
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
// ---- deterministic integer hash (no state). Two rounds of integer mixing (a la PCG/xxhash
|
|
43
|
+
// finalizer) -> a uniform float in [0,1). Used to seed each node's procedural base so the
|
|
44
|
+
// field is reproducible across reloads and machines.
|
|
45
|
+
function hash3(a, b, c) {
|
|
46
|
+
let h = (a | 0) * 374761393 + (b | 0) * 668265263 + (c | 0) * 2246822519;
|
|
47
|
+
h = (h ^ (h >>> 13)) >>> 0;
|
|
48
|
+
h = (h * 1274126177) >>> 0;
|
|
49
|
+
h = (h ^ (h >>> 16)) >>> 0;
|
|
50
|
+
return h / 4294967296;
|
|
51
|
+
}
|
|
52
|
+
// value-noise sample at a 2D coord via hashed lattice + smooth (quintic) bilinear. Cheap,
|
|
53
|
+
// allocation-free; the per-band fractal sums a few octaves of this.
|
|
54
|
+
function vnoise(x, y, seed) {
|
|
55
|
+
const xi = Math.floor(x), yi = Math.floor(y);
|
|
56
|
+
const xf = x - xi, yf = y - yi;
|
|
57
|
+
const u = xf*xf*xf*(xf*(xf*6 - 15) + 10); // quintic smoothstep
|
|
58
|
+
const v = yf*yf*yf*(yf*(yf*6 - 15) + 10);
|
|
59
|
+
const h00 = hash3(xi, yi, seed);
|
|
60
|
+
const h10 = hash3(xi + 1, yi, seed);
|
|
61
|
+
const h01 = hash3(xi, yi + 1, seed);
|
|
62
|
+
const h11 = hash3(xi + 1, yi + 1, seed);
|
|
63
|
+
const a = h00 + u * (h10 - h00);
|
|
64
|
+
const b = h01 + u * (h11 - h01);
|
|
65
|
+
return (a + v * (b - a)) * 2.0 - 1.0; // [-1,1]
|
|
66
|
+
}
|
|
67
|
+
// fractal sum (fBm) of `oct` octaves; ridged=true folds to ridges (continental/mountain).
|
|
68
|
+
function fractal(x, y, seed, oct, lacunarity, gain, ridged) {
|
|
69
|
+
let amp = 1.0, freq = 1.0, sum = 0.0, norm = 0.0;
|
|
70
|
+
for (let o = 0; o < oct; o++) {
|
|
71
|
+
let n = vnoise(x * freq, y * freq, seed + o * 1013);
|
|
72
|
+
if (ridged) { n = 1.0 - Math.abs(n); n = n * n; }
|
|
73
|
+
sum += n * amp; norm += amp;
|
|
74
|
+
amp *= gain; freq *= lacunarity;
|
|
75
|
+
}
|
|
76
|
+
let r = sum / Math.max(norm, 1e-6);
|
|
77
|
+
if (ridged) r = r * 2.0 - 1.0; // recentre ridged to ~[-1,1]
|
|
78
|
+
return r;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 3D value-noise + fBm of the WORLD DIRECTION (seam-fix 2026-06-05). The 2D vnoise/fractal above
|
|
82
|
+
// were sampled in FACE-LOCAL coords (baseParams fx=(u+face*4)*..., fy=(v+face*7)*...), so adjacent
|
|
83
|
+
// cube faces sampled DISJOINT regions of the 2D field -> a hard continental-elevation step along
|
|
84
|
+
// every shared cube-face edge (up to ~3.8km, the 'shelf' the user saw). Seeding the band fractals
|
|
85
|
+
// by the 3D world direction instead makes the field a pure function of world dir -> seamless across
|
|
86
|
+
// faces + poles BY CONSTRUCTION (validated src/lab/_seamfix_proto.mjs: seam 3765m->13m, landFrac held).
|
|
87
|
+
function vnoise3(x, y, z, seed) {
|
|
88
|
+
const xi = Math.floor(x), yi = Math.floor(y), zi = Math.floor(z);
|
|
89
|
+
const xf = x - xi, yf = y - yi, zf = z - zi;
|
|
90
|
+
const u = xf*xf*xf*(xf*(xf*6-15)+10), v = yf*yf*yf*(yf*(yf*6-15)+10), w = zf*zf*zf*(zf*(zf*6-15)+10);
|
|
91
|
+
const H = (a,b,c) => hash3(a, b, (c*2654435761 ^ seed)|0);
|
|
92
|
+
const c000=H(xi,yi,zi), c100=H(xi+1,yi,zi), c010=H(xi,yi+1,zi), c110=H(xi+1,yi+1,zi);
|
|
93
|
+
const c001=H(xi,yi,zi+1), c101=H(xi+1,yi,zi+1), c011=H(xi,yi+1,zi+1), c111=H(xi+1,yi+1,zi+1);
|
|
94
|
+
const x00=c000+u*(c100-c000), x10=c010+u*(c110-c010), x01=c001+u*(c101-c001), x11=c011+u*(c111-c011);
|
|
95
|
+
const y0=x00+v*(x10-x00), y1=x01+v*(x11-x01);
|
|
96
|
+
return (y0 + w*(y1-y0)) * 2.0 - 1.0; // [-1,1]
|
|
97
|
+
}
|
|
98
|
+
// 3D fBm (mirrors fractal()). The band fractal closures call this with the world dir * scale.
|
|
99
|
+
function fractal3(x, y, z, seed, oct, lacunarity, gain, ridged) {
|
|
100
|
+
let amp = 1.0, freq = 1.0, sum = 0.0, norm = 0.0;
|
|
101
|
+
for (let o = 0; o < oct; o++) {
|
|
102
|
+
let n = vnoise3(x * freq, y * freq, z * freq, seed + o * 1013);
|
|
103
|
+
if (ridged) { n = 1.0 - Math.abs(n); n = n * n; }
|
|
104
|
+
sum += n * amp; norm += amp; amp *= gain; freq *= lacunarity;
|
|
105
|
+
}
|
|
106
|
+
let r = sum / Math.max(norm, 1e-6);
|
|
107
|
+
if (ridged) r = r * 2.0 - 1.0;
|
|
108
|
+
return r;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// ---- band definitions: each scale band is an independent fixed quadtree over the 6 faces
|
|
112
|
+
// at a chosen LEVEL (resolution), driven by its OWN fractal, contributing its OWN parameter
|
|
113
|
+
// set. levelsPerFace = 2^level cells per face edge -> 6 * 4^level anchors in the band.
|
|
114
|
+
// The procedural base of each parameter is a fractal of the node's world position; edits
|
|
115
|
+
// are sparse deltas layered on top. Tuned so coarse bands make broad continents and finer
|
|
116
|
+
// bands add regional then local structure (different fractals at different scales).
|
|
117
|
+
const BANDS = [
|
|
118
|
+
{
|
|
119
|
+
name: 'continental', level: 3, // 6 * 64 = 384 anchors; ~plate scale
|
|
120
|
+
// band0 drives the broad land/sea split + plate-scale uplift. Ridged low-octave domain-
|
|
121
|
+
// warped noise -> big coherent landmasses with oceanic basins between (tectonic feel).
|
|
122
|
+
fractal: (x, y, s) => {
|
|
123
|
+
// domain warp for non-blobby continent shapes
|
|
124
|
+
const wx = x + 0.6 * fractal(x, y, s + 7, 2, 2.0, 0.5, false);
|
|
125
|
+
const wy = y + 0.6 * fractal(x, y, s + 9, 2, 2.0, 0.5, false);
|
|
126
|
+
return fractal(wx, wy, s, 4, 2.0, 0.55, true); // ridged plate mask
|
|
127
|
+
},
|
|
128
|
+
// 3D world-dir version (seam fix): domain-warped ridged plate mask, seamless across cube faces.
|
|
129
|
+
fractal3: (x, y, z, s) => {
|
|
130
|
+
const wx = x + 0.6 * fractal3(x, y, z, s + 7, 2, 2.0, 0.5, false);
|
|
131
|
+
const wy = y + 0.6 * fractal3(x, y, z, s + 9, 2, 2.0, 0.5, false);
|
|
132
|
+
const wz = z + 0.6 * fractal3(x, y, z, s + 13, 2, 2.0, 0.5, false);
|
|
133
|
+
return fractal3(wx, wy, wz, s, 4, 2.0, 0.55, true); // ridged plate mask
|
|
134
|
+
},
|
|
135
|
+
// maps the fractal value -> parameter contributions (meters / scales). The -0.13 sea-level bias
|
|
136
|
+
// offsets the ridged mask's positive skew so landFrac stays ~0.43 (tuned, _seamfix_proto.mjs).
|
|
137
|
+
params: (f) => ({
|
|
138
|
+
seaBias: (f - 0.13) * 2600.0, // +/- ~2.6km broad swell: land above 0, ocean below
|
|
139
|
+
elevAmp: 1.0 + 0.25 * f, // gentle continental-shelf amplitude modulation
|
|
140
|
+
temp: 0.0, humidity: 0.0, erosion: 0.0, roughness: 0.0,
|
|
141
|
+
}),
|
|
142
|
+
},
|
|
143
|
+
{
|
|
144
|
+
// SUB-CONTINENTAL infill band (anchor-density ladder [3,5,6,7,9], gate nodejs-2215). Closes the
|
|
145
|
+
// octave gap between continental L3 and regional L6 so ~200-400km features resolve. DISCIPLINE
|
|
146
|
+
// (proven safe-wiring gate): NEUTRAL elevAmp (1.0, multiplies as identity -> no elevAmp compounding)
|
|
147
|
+
// + ZERO-MEAN seaBias ((f), f in [-1,1] -> does not shift land/sea; A/B landFrac delta 0.03 < 0.05).
|
|
148
|
+
name: 'subcontinental', level: 5, // 6 * 1024 = 6144 anchors
|
|
149
|
+
fractal: (x, y, s) => {
|
|
150
|
+
const wx = x + 0.6 * fractal(x, y, s + 7, 2, 2.0, 0.5, false);
|
|
151
|
+
const wy = y + 0.6 * fractal(x, y, s + 9, 2, 2.0, 0.5, false);
|
|
152
|
+
return fractal(wx, wy, s + 65, 4, 2.0, 0.55, true); // decorrelated ridged sub-plate mask
|
|
153
|
+
},
|
|
154
|
+
fractal3: (x, y, z, s) => {
|
|
155
|
+
const wx = x + 0.6 * fractal3(x, y, z, s + 7, 2, 2.0, 0.5, false);
|
|
156
|
+
const wy = y + 0.6 * fractal3(x, y, z, s + 9, 2, 2.0, 0.5, false);
|
|
157
|
+
const wz = z + 0.6 * fractal3(x, y, z, s + 13, 2, 2.0, 0.5, false);
|
|
158
|
+
return fractal3(wx, wy, wz, s + 65, 4, 2.0, 0.55, true); // decorrelated ridged sub-plate mask
|
|
159
|
+
},
|
|
160
|
+
params: (f) => ({
|
|
161
|
+
seaBias: f * 900.0, // ZERO-MEAN sub-continental sea-level wiggle (coastline at sub-plate scale)
|
|
162
|
+
elevAmp: 1.0, // NEUTRAL (gate: keeps composite elevAmp identical to 3-band)
|
|
163
|
+
temp: 0.0, humidity: 0.0, erosion: 0.0, roughness: 0.0,
|
|
164
|
+
}),
|
|
165
|
+
},
|
|
166
|
+
{
|
|
167
|
+
name: 'regional', level: 6, // 6 * 4096 = 24576 anchors; ~mountain-range/climate scale
|
|
168
|
+
// band1 drives mountain belts, erosion strength, and climate (temp/humidity). Mid-octave
|
|
169
|
+
// fBm (not ridged) -> rolling regional variation with belts of higher relief.
|
|
170
|
+
fractal: (x, y, s) => fractal(x, y, s, 5, 2.0, 0.5, false),
|
|
171
|
+
fractal3: (x, y, z, s) => fractal3(x, y, z, s, 5, 2.0, 0.5, false),
|
|
172
|
+
params: (f, lat = 0, fx = 0, fy = 0) => {
|
|
173
|
+
const belt = fractal(f * 3.0, f * 3.0, 31, 3, 2.0, 0.5, true); // mountain-belt mask
|
|
174
|
+
// BIOME-VARIETY CLIMATE (user: continent featureless/self-similar). The old climate was a
|
|
175
|
+
// single smooth gradient (temp=latitude, humidity=0.5-0.5f) so the whole continent fell in
|
|
176
|
+
// ~2 wet-lowland classes that all render the same green. We give temp AND humidity WIDE
|
|
177
|
+
// REGIONAL range from INDEPENDENT multi-octave fractals of the node position + continentality,
|
|
178
|
+
// so distinct biome PATCHES form (deserts, forests, swamps, tundra, taiga) rather than one
|
|
179
|
+
// gradient. fx,fy = the node's fractal coordinate (threaded from baseParams).
|
|
180
|
+
const latBase = Math.pow(Math.max(0, Math.cos(lat)), 1.1); // [0,1] equator->pole (still anchors poles cold)
|
|
181
|
+
// independent regional climate octaves (decorrelated seeds). Coordinate scale 2.2 (was 0.5)
|
|
182
|
+
// so several biome PATCHES form WITHIN a single continent (at 0.5 a whole continent fell in
|
|
183
|
+
// one climate cycle -> one biome region = still self-similar). Higher freq -> biome mosaic.
|
|
184
|
+
const tNoise = fractal(fx * 2.2 + 11.0, fy * 2.2 - 7.0, 9211, 4, 2.0, 0.55, false);
|
|
185
|
+
const hNoise = fractal(fx * 2.2 - 5.0, fy * 2.2 + 13.0, 9307, 4, 2.0, 0.55, false);
|
|
186
|
+
// CONTINENTALITY: interiors/high ground (large seaBias proxy via f) are drier + a touch
|
|
187
|
+
// cooler; near-sea is wetter. f in ~[-1,1] (regional fBm), positive = higher/inland.
|
|
188
|
+
const inland = Math.max(0, Math.min(1, f * 0.5 + 0.5)); // 0 coastal -> 1 interior
|
|
189
|
+
// TEMP: latitude is the spine; regional octave gives +/-0.30 so warm/cool belts cross it;
|
|
190
|
+
// interiors run a bit cooler. Wide range so cold (tundra/ice) AND hot (desert) both occur.
|
|
191
|
+
// latBase^1.4 makes the poles genuinely cold (-> ice/tundra) while the equator stays warm;
|
|
192
|
+
// small offset, wide regional swing so both hot deserts and cold caps occur.
|
|
193
|
+
const temp = Math.max(0, Math.min(1, Math.pow(latBase, 1.4) * 1.05 + 0.28 * tNoise - 0.10 * inland - 0.04));
|
|
194
|
+
// LOGICAL BIOME PLACEMENT (user: biomes in their most logical positions). Two physical
|
|
195
|
+
// climate drivers added so biomes land where Earth puts them, not at random noise spots:
|
|
196
|
+
// (1) EQUATORIAL-WET / SUBTROPICAL-DRY latitude band (the Hadley-cell / ITCZ profile):
|
|
197
|
+
// wet at the equator (rainforest), DRY desert belts near +/-25deg (Sahara/Arabian/
|
|
198
|
+
// Australian latitudes), moderate again in the temperate mid-lats. latDeg from lat.
|
|
199
|
+
// (2) RAIN-SHADOW: the dry lee of mountain belts -- the `belt` mask reduces humidity so
|
|
200
|
+
// arid steppe/desert forms downwind of ranges (continentality already covers interiors;
|
|
201
|
+
// this sharpens the orographic dryness on the high belts themselves).
|
|
202
|
+
const latDeg = Math.abs(lat) * 57.29577951;
|
|
203
|
+
// Hadley/ITCZ humidity profile: equatorial WET bulge, a NARROW subtropical DRY trough at
|
|
204
|
+
// ~25deg (the desert latitudes), and a temperate-humid RECOVERY bump at ~50deg so deserts
|
|
205
|
+
// do NOT bleed into the mid-latitudes. Trough sigma 8 (narrow) so it decays before 40deg.
|
|
206
|
+
const latHumid = 0.20 * Math.exp(-(latDeg * latDeg) / (2 * 11 * 11)) // equatorial wet bulge
|
|
207
|
+
- 0.24 * Math.exp(-((latDeg - 25) * (latDeg - 25)) / (2 * 8 * 8)) // subtropical dry trough (narrow)
|
|
208
|
+
+ 0.12 * Math.exp(-((latDeg - 52) * (latDeg - 52)) / (2 * 14 * 14)); // temperate-humid recovery
|
|
209
|
+
const rainShadow = 0.22 * Math.max(0, belt); // orographic drying on the high belts
|
|
210
|
+
const humidity = Math.max(0, Math.min(1, 0.62 + 0.52 * hNoise - 0.40 * inland + latHumid - rainShadow));
|
|
211
|
+
return {
|
|
212
|
+
// GENERAL ELEVATION (user: anchorpoints should convey general elevation so terrain is
|
|
213
|
+
// not so flat/featureless). The regional band is the ~100-200km scale the user flies
|
|
214
|
+
// over; at f*350 the within-continent height variation was too small (continental
|
|
215
|
+
// f*2600 dominates, then a big flat gap to fine detail), so interiors read as flat
|
|
216
|
+
// plains. Raise the regional elevation amplitude to f*900 to carve rolling
|
|
217
|
+
// hills/plateaus/valleys at that scale. ZERO-MEAN (f in [-1,1]) so the land/sea split
|
|
218
|
+
// is preserved (safe-wiring gate: landFrac delta < 0.05); validated by CLI hypsometry.
|
|
219
|
+
seaBias: f * 1600.0, // regional general-elevation relief. 900->1600 (user 2026-06-02
|
|
220
|
+
// 'elevation distribution doesnt create enough elevation'): more
|
|
221
|
+
// large-scale highlands/lowlands/basins. ZERO-MEAN (land/sea preserved).
|
|
222
|
+
elevAmp: 1.0 + 0.8 * Math.max(0, belt), // amplify relief inside mountain belts
|
|
223
|
+
temp,
|
|
224
|
+
humidity,
|
|
225
|
+
erosion: 0.3 + 0.4 * Math.max(0, belt), // more erosion on the high belts
|
|
226
|
+
roughness: 0.0,
|
|
227
|
+
};
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
{
|
|
231
|
+
// SUB-REGIONAL infill band (ladder [3,5,6,7,9]). Closes the gap between regional L6 and local L9
|
|
232
|
+
// so ~25-50km features resolve. Same discipline: NEUTRAL elevAmp + ZERO-MEAN seaBias.
|
|
233
|
+
name: 'subregional', level: 7, // 6 * 16384 = 98304 anchors
|
|
234
|
+
fractal: (x, y, s) => {
|
|
235
|
+
const wx = x + 0.6 * fractal(x, y, s + 7, 2, 2.0, 0.5, false);
|
|
236
|
+
const wy = y + 0.6 * fractal(x, y, s + 9, 2, 2.0, 0.5, false);
|
|
237
|
+
return fractal(wx, wy, s + 91, 4, 2.0, 0.55, true); // decorrelated ridged sub-regional mask
|
|
238
|
+
},
|
|
239
|
+
fractal3: (x, y, z, s) => {
|
|
240
|
+
const wx = x + 0.6 * fractal3(x, y, z, s + 7, 2, 2.0, 0.5, false);
|
|
241
|
+
const wy = y + 0.6 * fractal3(x, y, z, s + 9, 2, 2.0, 0.5, false);
|
|
242
|
+
const wz = z + 0.6 * fractal3(x, y, z, s + 13, 2, 2.0, 0.5, false);
|
|
243
|
+
return fractal3(wx, wy, wz, s + 91, 4, 2.0, 0.55, true); // decorrelated ridged sub-regional mask
|
|
244
|
+
},
|
|
245
|
+
params: (f) => ({
|
|
246
|
+
seaBias: f * 750.0, // ZERO-MEAN sub-regional relief (~25-50km hills). 450->750 (more elevation distribution)
|
|
247
|
+
elevAmp: 1.0, // NEUTRAL
|
|
248
|
+
temp: 0.0, humidity: 0.0, erosion: 0.0, roughness: 0.0,
|
|
249
|
+
}),
|
|
250
|
+
},
|
|
251
|
+
{
|
|
252
|
+
name: 'local', level: 9, // 6 * 262144 = ~1.5M anchors; ~local-feature scale
|
|
253
|
+
// band2 drives local roughness + material detail weights. High-octave turbulence -> the
|
|
254
|
+
// fine break-up that the detail-texture material reads (the texel-density coupling).
|
|
255
|
+
fractal: (x, y, s) => Math.abs(fractal(x, y, s, 4, 2.2, 0.55, false)),
|
|
256
|
+
fractal3: (x, y, z, s) => Math.abs(fractal3(x, y, z, s, 4, 2.2, 0.55, false)),
|
|
257
|
+
params: (f) => ({
|
|
258
|
+
seaBias: 0.0, elevAmp: 1.0 + 0.15 * f, temp: 0.0, humidity: 0.0,
|
|
259
|
+
erosion: 0.0,
|
|
260
|
+
roughness: 0.4 + 0.6 * f, // [0.4,1] local surface roughness / detail gain
|
|
261
|
+
}),
|
|
262
|
+
},
|
|
263
|
+
];
|
|
264
|
+
|
|
265
|
+
// the parameter keys the field accumulates (sum across bands for additive ones, the field's
|
|
266
|
+
// contract with the wasm bias hook + the shader material).
|
|
267
|
+
const PARAM_KEYS = ['seaBias', 'elevAmp', 'temp', 'humidity', 'erosion', 'roughness'];
|
|
268
|
+
const K = PARAM_KEYS.length;
|
|
269
|
+
const PIDX = Object.create(null); PARAM_KEYS.forEach((k, i) => PIDX[k] = i);
|
|
270
|
+
|
|
271
|
+
// ---- ADAPTIVE QUADTREE node addressing (build-spec aqt-design). Each band has its OWN
|
|
272
|
+
// sparse quadtree of EDIT nodes at ARBITRARY depth below its base level. The procedural BASE
|
|
273
|
+
// stays analytic (no storage) and the EXISTING fixed-level cell bilinear is the BASE layer,
|
|
274
|
+
// byte-for-byte; adaptivity is a strictly ADDITIVE overlay of node "detail hats", so an
|
|
275
|
+
// empty edit tree leaves sampleUV numerically identical to the pre-AQT field (no-regression
|
|
276
|
+
// is a theorem, not a hope). A node's payload is a flat Float32Array(K) of param DELTAS.
|
|
277
|
+
//
|
|
278
|
+
// Path key: a depth-0 root per (face, base-cell) folds the base grid into the key; childKey
|
|
279
|
+
// is *4+q (q=(cy<<1)|cx), parentKey is /4. Integer-only, prefix-free, safe to ~depth 12.
|
|
280
|
+
const FACE_OFF = 8; // face roots start at 8 (keeps key positive + leaves low range free)
|
|
281
|
+
const childKey = (k, q) => k * 4 + q;
|
|
282
|
+
const parentKey = (k) => Math.floor(k / 4);
|
|
283
|
+
// hat basis (tent): 1 at centre, 0 at +/-1, compact support -> C0 + local.
|
|
284
|
+
const hat = (t) => { t = t < 0 ? -t : t; return t >= 1 ? 0 : 1 - t; };
|
|
285
|
+
|
|
286
|
+
export function createAnchorField(opts = {}) {
|
|
287
|
+
const seed = (opts.seed | 0) || 1337;
|
|
288
|
+
|
|
289
|
+
// Per-band ADAPTIVE QUADTREE record. nodes: pathKey -> Float32Array(K) edit deltas.
|
|
290
|
+
// cover: every ANCESTOR pathKey of an edited node, refcounted -> the descent guide (enter
|
|
291
|
+
// a child only if its key is in cover; O(depth) integer-only, no speculative gets).
|
|
292
|
+
// maxDepth: max adaptive depth present (0 = no overlay anywhere -> global early-out).
|
|
293
|
+
const bands = BANDS.map((B) => ({
|
|
294
|
+
baseLevel: B.level, bn: 1 << B.level,
|
|
295
|
+
nodes: new Map(), cover: new Map(), maxDepth: 0,
|
|
296
|
+
}));
|
|
297
|
+
// RUNTIME per-band scales (full-adjustability via window.__gen.hpf.band[i]). 1.0 = identity =
|
|
298
|
+
// the tuned base field (no behaviour change until edited). seaBiasScale multiplies the band's
|
|
299
|
+
// additive seaBias contribution; elevAmpScale scales its elevAmp DEVIATION from 1 (so 0 = flat,
|
|
300
|
+
// 1 = base, >1 = exaggerated); roughnessScale scales its roughness contribution.
|
|
301
|
+
const bandScales = BANDS.map(() => ({ seaBiasScale: 1.0, elevAmpScale: 1.0, roughnessScale: 1.0 }));
|
|
302
|
+
// depth-0 root key for a base-level cell on a face. bn folded so cells don't collide.
|
|
303
|
+
function rootKey(bandIdx, face, btx, bty) {
|
|
304
|
+
const bn = bands[bandIdx].bn;
|
|
305
|
+
return ((FACE_OFF + face) * bn + bty) * bn + btx;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// procedural BASE params of a band node (no edits). Deterministic from the node's face-uv
|
|
309
|
+
// centre + the band fractal. UNCHANGED from the pre-AQT field (regression firewall).
|
|
310
|
+
function baseParams(bandIdx, face, tx, ty) {
|
|
311
|
+
const B = BANDS[bandIdx];
|
|
312
|
+
const n = 1 << B.level;
|
|
313
|
+
// cell-CENTRE face coords -> delegate to the continuous evaluator (used by the edit-overlay grid).
|
|
314
|
+
const fu = (tx + 0.5) / n, fv = (ty + 0.5) / n;
|
|
315
|
+
return baseParamsAt(bandIdx, face, fu, fv);
|
|
316
|
+
}
|
|
317
|
+
// CONTINUOUS base params at exact face coords (fu,fv) in [0,1] -- a pure function of the WORLD DIR,
|
|
318
|
+
// so it is seamless across cube faces + poles by construction (seam fix 2026-06-05). The per-band
|
|
319
|
+
// SCALE (cycles/sphere) mirrors the old face-local frequency (n*0.06 cycles/face * 4 faces/sphere).
|
|
320
|
+
function baseParamsAt(bandIdx, face, fu, fv) {
|
|
321
|
+
const B = BANDS[bandIdx];
|
|
322
|
+
const n = 1 << B.level;
|
|
323
|
+
const u = fu * 2.0 - 1.0, v = fv * 2.0 - 1.0;
|
|
324
|
+
const F = FACE_FRAME[face];
|
|
325
|
+
let dx = F.c[0] + u * F.u[0] + v * F.v[0];
|
|
326
|
+
let dy = F.c[1] + u * F.u[1] + v * F.v[1];
|
|
327
|
+
let dz = F.c[2] + u * F.u[2] + v * F.v[2];
|
|
328
|
+
const dl = Math.sqrt(dx*dx + dy*dy + dz*dz) || 1; dx/=dl; dy/=dl; dz/=dl; // unit world dir
|
|
329
|
+
const lat = Math.asin(Math.max(-1, Math.min(1, dy)));
|
|
330
|
+
const SC = n * 0.06 * 4.0;
|
|
331
|
+
const sx = dx * SC, sy = dy * SC, sz = dz * SC;
|
|
332
|
+
const fval = B.fractal3(sx, sy, sz, seed + bandIdx * 101);
|
|
333
|
+
return B.params(fval, lat, sx, sz);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ADDITIVE overlay walk: descend the band's edit quadtree from the base cell toward the
|
|
337
|
+
// sample, accumulating each visited node's hat-weighted delta into the per-band scratch.
|
|
338
|
+
// Allocation-free. Local support is exact (a node's hat is 0 outside its cell +/-1 ring).
|
|
339
|
+
function addOverlay(out, bandIdx, face, fu, fv) {
|
|
340
|
+
const band = bands[bandIdx];
|
|
341
|
+
if (band.nodes.size === 0) return; // EARLY-OUT: no edits in this band. (Guard
|
|
342
|
+
// on nodes.size, NOT maxDepth===0 -- a DEPTH-0 base-cell edit leaves maxDepth at 0 yet must
|
|
343
|
+
// still apply; the old maxDepth guard silently dropped every depth-0 terraform edit. The
|
|
344
|
+
// loop below runs depth 0..maxDepth, so maxDepth 0 correctly does one iteration at the root.)
|
|
345
|
+
const bn = band.bn;
|
|
346
|
+
let btx = (fu * bn) | 0, bty = (fv * bn) | 0;
|
|
347
|
+
if (btx < 0) btx = 0; else if (btx >= bn) btx = bn - 1;
|
|
348
|
+
if (bty < 0) bty = 0; else if (bty >= bn) bty = bn - 1;
|
|
349
|
+
let key = rootKey(bandIdx, face, btx, bty);
|
|
350
|
+
let lu = fu * bn - btx, lv = fv * bn - bty; // local coords in [0,1] within base cell
|
|
351
|
+
for (let depth = 0; depth <= band.maxDepth; depth++) {
|
|
352
|
+
const node = band.nodes.get(key);
|
|
353
|
+
if (node) {
|
|
354
|
+
const w = hat((lu - 0.5) * 2.0) * hat((lv - 0.5) * 2.0);
|
|
355
|
+
if (w !== 0) for (let i = 0; i < K; i++) out[i] += node[i] * w;
|
|
356
|
+
}
|
|
357
|
+
const cx = lu >= 0.5 ? 1 : 0, cy = lv >= 0.5 ? 1 : 0;
|
|
358
|
+
const ck = childKey(key, (cy << 1) | cx);
|
|
359
|
+
// descend if the child is an ANCESTOR of some edit (in cover) OR is itself an edited
|
|
360
|
+
// leaf (in nodes). cover holds only ancestors, so the deepest edited node is reached
|
|
361
|
+
// only by also checking nodes -- else the descent breaks one level above the leaf.
|
|
362
|
+
if (!band.cover.has(ck) && !band.nodes.has(ck)) break;
|
|
363
|
+
key = ck; lu = lu * 2 - cx; lv = lv * 2 - cy;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// reused flat scratch for the hot sample path (no allocation per sample).
|
|
368
|
+
const _scratch = {}; for (const k of PARAM_KEYS) _scratch[k] = 0;
|
|
369
|
+
const _band = new Float32Array(K), _acc = new Float32Array(K);
|
|
370
|
+
|
|
371
|
+
// LOCAL-SUPPORT bilinear sample of one band's BASE at face uv, then the additive overlay.
|
|
372
|
+
// The base body is the pre-AQT fixed-level cell bilinear, byte-for-byte (regression firewall).
|
|
373
|
+
function sampleBand(out, bandIdx, face, fu, fv) {
|
|
374
|
+
// SEAM FIX (2026-06-05): the base used a per-face GRID bilinear (4 baseParams cells at clamped
|
|
375
|
+
// integer indices). Even after baseParams became a pure world-dir function, the grid CELL CENTRES
|
|
376
|
+
// do not align across a shared cube-face edge, so the clamped edge-cell bilinear reintroduced a
|
|
377
|
+
// ~2km step (the residual shelf). Since baseParams is now continuous in world dir, evaluate it
|
|
378
|
+
// DIRECTLY at the sample's exact (face,fu,fv) world dir -- no grid quantization, no edge step =
|
|
379
|
+
// fully seamless. (The additive edit OVERLAY still uses the band quadtree below; edits are local
|
|
380
|
+
// deltas and seam-irrelevant.) baseParamsAt takes continuous face coords (fu,fv) in [0,1].
|
|
381
|
+
const p = baseParamsAt(bandIdx, face, fu, fv);
|
|
382
|
+
for (let i = 0; i < K; i++) out[i] += p[PARAM_KEYS[i]];
|
|
383
|
+
addOverlay(out, bandIdx, face, fu, fv); // additive edit detail (0 if no edits)
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
// PUBLIC: sample the full hierarchical field at face uv in [0,1]^2. Sums every band, each
|
|
387
|
+
// at its own scale. Compose: additive seaBias/temp/humidity/erosion, MULTIPLICATIVE elevAmp,
|
|
388
|
+
// MAX roughness (finest-band-wins) -- identical semantics to the pre-AQT field. Alloc-free
|
|
389
|
+
// (flat Float32Array scratch, integer-indexed).
|
|
390
|
+
const _iAmp = PIDX.elevAmp, _iRgh = PIDX.roughness, _iSea = PIDX.seaBias;
|
|
391
|
+
// maxBandLevel (optional): skip bands FINER than this level. The baked HPF texture is HPF_RES/face;
|
|
392
|
+
// a band at level L resolves 2^L cells/face, so bands with level > log2(HPF_RES) are SUB-TEXEL in the
|
|
393
|
+
// bake (their detail aliases to noise) and sampling them per-texel is wasted cost. Passing
|
|
394
|
+
// maxBandLevel = log2(HPF_RES) skips them in the bake -> big speedup (the finest ~1.5M-anchor local
|
|
395
|
+
// band dominates cost) with negligible change to the baked channels (local band touches only elevAmp
|
|
396
|
+
// by <=1.1x, CLI-measured median 1.03). Default (undefined) samples ALL bands (full-fidelity path).
|
|
397
|
+
function sampleUV(face, fu, fv, maxBandLevel) {
|
|
398
|
+
for (let i = 0; i < K; i++) _acc[i] = 0;
|
|
399
|
+
let amp = 1.0, rough = 0.0;
|
|
400
|
+
for (let b = 0; b < bands.length; b++) {
|
|
401
|
+
if (maxBandLevel !== undefined && BANDS[b].level > maxBandLevel) continue;
|
|
402
|
+
for (let i = 0; i < K; i++) _band[i] = 0; _band[_iAmp] = 1.0;
|
|
403
|
+
sampleBand(_band, b, face, fu, fv);
|
|
404
|
+
// apply per-band runtime scales (full-adjustability; 1.0 = identity).
|
|
405
|
+
const bs = bandScales[b];
|
|
406
|
+
_band[_iSea] *= bs.seaBiasScale;
|
|
407
|
+
_band[_iAmp] = 1.0 + (_band[_iAmp] - 1.0) * bs.elevAmpScale; // scale deviation from 1
|
|
408
|
+
_band[_iRgh] *= bs.roughnessScale;
|
|
409
|
+
for (let i = 0; i < K; i++) if (i !== _iAmp && i !== _iRgh) _acc[i] += _band[i];
|
|
410
|
+
amp *= _band[_iAmp];
|
|
411
|
+
rough = Math.max(rough, _band[_iRgh]);
|
|
412
|
+
}
|
|
413
|
+
_acc[_iAmp] = amp; _acc[_iRgh] = rough;
|
|
414
|
+
for (let i = 0; i < K; i++) _scratch[PARAM_KEYS[i]] = _acc[i];
|
|
415
|
+
return _scratch;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// PER-TILE sample for the orchestrator: a Proland tile (face, level, tx, ty) covers a uv
|
|
419
|
+
// square on its face; sample the field at the tile CENTRE (the bias is per-tile, and the
|
|
420
|
+
// field is C0 across tiles because every tile samples the same global band quadtrees).
|
|
421
|
+
function sampleTile(face, level, tx, ty) {
|
|
422
|
+
const n = 1 << level;
|
|
423
|
+
const fu = (tx + 0.5) / n, fv = (ty + 0.5) / n;
|
|
424
|
+
return sampleUV(face, fu, fv);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// world-direction (unit vector) -> face + uv, for sampleDir / biomeAt(worldDir).
|
|
428
|
+
function dirToFaceUV(d) {
|
|
429
|
+
const ax = Math.abs(d[0]), ay = Math.abs(d[1]), az = Math.abs(d[2]);
|
|
430
|
+
let face, sc, fu, fv;
|
|
431
|
+
if (ax >= ay && ax >= az) { face = d[0] > 0 ? 0 : 1; sc = 1 / ax; }
|
|
432
|
+
else if (ay >= az) { face = d[1] > 0 ? 2 : 3; sc = 1 / ay; }
|
|
433
|
+
else { face = d[2] > 0 ? 4 : 5; sc = 1 / az; }
|
|
434
|
+
const F = FACE_FRAME[face];
|
|
435
|
+
// project d onto the face's u,v axes (face plane is the unit-cube face), -> [-1,1] -> [0,1]
|
|
436
|
+
const u = (d[0]*F.u[0] + d[1]*F.u[1] + d[2]*F.u[2]) * sc;
|
|
437
|
+
const v = (d[0]*F.v[0] + d[1]*F.v[1] + d[2]*F.v[2]) * sc;
|
|
438
|
+
fu = u * 0.5 + 0.5; fv = v * 0.5 + 0.5;
|
|
439
|
+
return { face, fu, fv };
|
|
440
|
+
}
|
|
441
|
+
function sampleDir(d) { const { face, fu, fv } = dirToFaceUV(d); return sampleUV(face, fu, fv); }
|
|
442
|
+
|
|
443
|
+
// ---- EDIT API (terraform), ADAPTIVE DEPTH. Edits a node's param DELTAS at ANY depth below
|
|
444
|
+
// the band base level. depth=0 -> the base cell (btx,bty); depth>0 -> the sub-cell (sx,sy)
|
|
445
|
+
// in [0,2^depth) within that base cell. Auto-creates the node + registers its ancestor
|
|
446
|
+
// chain in `cover` (refcounted) so the descent walk reaches it. Local support holds at
|
|
447
|
+
// every depth (additive hat). Returns the node's path key.
|
|
448
|
+
function editNode(bandIdx, face, btx, bty, deltas, depth = 0, sx = 0, sy = 0) {
|
|
449
|
+
const band = bands[bandIdx];
|
|
450
|
+
let key = rootKey(bandIdx, face, btx, bty);
|
|
451
|
+
for (let dd = 0; dd < depth; dd++) {
|
|
452
|
+
const sh = depth - 1 - dd, cx = (sx >> sh) & 1, cy = (sy >> sh) & 1;
|
|
453
|
+
key = childKey(key, (cy << 1) | cx);
|
|
454
|
+
}
|
|
455
|
+
let v = band.nodes.get(key);
|
|
456
|
+
const fresh = !v;
|
|
457
|
+
if (!v) { v = new Float32Array(K); band.nodes.set(key, v); }
|
|
458
|
+
for (const k in deltas) { const i = PIDX[k]; if (i !== undefined) v[i] += deltas[k]; }
|
|
459
|
+
if (fresh) {
|
|
460
|
+
const root = rootKey(bandIdx, face, btx, bty);
|
|
461
|
+
let pk = key;
|
|
462
|
+
while (pk !== root) { pk = parentKey(pk); band.cover.set(pk, (band.cover.get(pk) || 0) + 1); }
|
|
463
|
+
if (depth > band.maxDepth) band.maxDepth = depth;
|
|
464
|
+
}
|
|
465
|
+
return key;
|
|
466
|
+
}
|
|
467
|
+
// edit the node CONTAINING a world direction at a chosen depth (click-to-terraform).
|
|
468
|
+
function editAtDir(bandIdx, d, deltas, depth = 0) {
|
|
469
|
+
const { face, fu, fv } = dirToFaceUV(d);
|
|
470
|
+
const bn = bands[bandIdx].bn;
|
|
471
|
+
let btx = Math.min(bn - 1, Math.max(0, Math.floor(fu * bn)));
|
|
472
|
+
let bty = Math.min(bn - 1, Math.max(0, Math.floor(fv * bn)));
|
|
473
|
+
// sub-cell index within the base cell at `depth`.
|
|
474
|
+
const sub = 1 << depth;
|
|
475
|
+
const lu = fu * bn - btx, lv = fv * bn - bty;
|
|
476
|
+
const sx = Math.min(sub - 1, Math.max(0, Math.floor(lu * sub)));
|
|
477
|
+
const sy = Math.min(sub - 1, Math.max(0, Math.floor(lv * sub)));
|
|
478
|
+
return editNode(bandIdx, face, btx, bty, deltas, depth, sx, sy);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// rebuild a band's cover + maxDepth from a flat [key, deltaArray] node list (used by load).
|
|
482
|
+
// depth of a node is derived by walking parents until the key falls in a root range.
|
|
483
|
+
function rebuildBand(bandIdx, entries) {
|
|
484
|
+
const band = bands[bandIdx];
|
|
485
|
+
band.nodes = new Map(); band.cover = new Map(); band.maxDepth = 0;
|
|
486
|
+
const bn = band.bn, rootLo = FACE_OFF * bn * bn, rootHi = (FACE_OFF + 6) * bn * bn;
|
|
487
|
+
const isRoot = (k) => k >= rootLo && k < rootHi;
|
|
488
|
+
for (const [k, arr] of entries) {
|
|
489
|
+
band.nodes.set(k, arr instanceof Float32Array ? arr : Float32Array.from(arr));
|
|
490
|
+
// walk ancestors, counting depth, registering cover.
|
|
491
|
+
let pk = k, depth = 0;
|
|
492
|
+
while (!isRoot(pk)) { pk = parentKey(pk); band.cover.set(pk, (band.cover.get(pk) || 0) + 1); depth++; }
|
|
493
|
+
if (depth > band.maxDepth) band.maxDepth = depth;
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// ---- PERSISTENCE (v2): serialise ONLY the sparse edit nodes (procedural base is
|
|
498
|
+
// deterministic from seed). Compact, reload-safe, offline-authorable. v1 shim maps old
|
|
499
|
+
// fixed-level addr edits to depth-0 nodes (terraform saves survive).
|
|
500
|
+
function serialize() {
|
|
501
|
+
return JSON.stringify({
|
|
502
|
+
seed, version: 2,
|
|
503
|
+
bands: bands.map((b) => ({ baseLevel: b.baseLevel,
|
|
504
|
+
nodes: Array.from(b.nodes, ([k, arr]) => [k, Array.from(arr)]) })),
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
function load(json) {
|
|
508
|
+
const o = typeof json === 'string' ? JSON.parse(json) : json;
|
|
509
|
+
if (!o) return false;
|
|
510
|
+
if (o.version === 2) {
|
|
511
|
+
for (let b = 0; b < bands.length && b < o.bands.length; b++) rebuildBand(b, o.bands[b].nodes);
|
|
512
|
+
return true;
|
|
513
|
+
}
|
|
514
|
+
if (o.version === 1) { // SHIM: old fixed-level addr -> depth-0 node
|
|
515
|
+
for (let b = 0; b < bands.length && b < (o.edits || []).length; b++) {
|
|
516
|
+
const bn = bands[b].bn;
|
|
517
|
+
for (const [addr, delta] of o.edits[b]) {
|
|
518
|
+
const face = (addr / (bn * bn)) | 0, rem = addr % (bn * bn);
|
|
519
|
+
const bty = (rem / bn) | 0, btx = rem % bn;
|
|
520
|
+
editNode(b, face, btx, bty, delta, 0);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
return true;
|
|
524
|
+
}
|
|
525
|
+
return false;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// effective params of a base-level node = base + (depth-0 edit overlay at cell centre).
|
|
529
|
+
// Kept for introspection (__diag); the hot path uses sampleUV.
|
|
530
|
+
function nodeParams(bandIdx, face, tx, ty) {
|
|
531
|
+
const p = baseParams(bandIdx, face, tx, ty);
|
|
532
|
+
const node = bands[bandIdx].nodes.get(rootKey(bandIdx, face, tx, ty));
|
|
533
|
+
if (node) for (let i = 0; i < K; i++) p[PARAM_KEYS[i]] += node[i];
|
|
534
|
+
return p;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// ---- introspection for __diag.hpf
|
|
538
|
+
function bandsInfo() {
|
|
539
|
+
return BANDS.map((B, i) => ({
|
|
540
|
+
band: i, name: B.name, level: B.level,
|
|
541
|
+
anchorsPerFace: (1 << B.level) * (1 << B.level),
|
|
542
|
+
totalAnchors: 6 * (1 << B.level) * (1 << B.level),
|
|
543
|
+
editedNodes: bands[i].nodes.size, maxDepth: bands[i].maxDepth,
|
|
544
|
+
}));
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
return {
|
|
548
|
+
BANDS, PARAM_KEYS,
|
|
549
|
+
sampleUV, sampleTile, sampleDir,
|
|
550
|
+
editNode, editAtDir,
|
|
551
|
+
nodeParams, baseParams, rootKey, dirToFaceUV,
|
|
552
|
+
serialize, load, bandsInfo,
|
|
553
|
+
// RUNTIME per-band scale setter (full-adjustability; consumed in sampleUV). i = band index.
|
|
554
|
+
setBandScales(i, s){ if(i>=0 && i<bandScales.length && s){ const b=bandScales[i];
|
|
555
|
+
if(s.seaBiasScale!=null)b.seaBiasScale=+s.seaBiasScale; if(s.elevAmpScale!=null)b.elevAmpScale=+s.elevAmpScale; if(s.roughnessScale!=null)b.roughnessScale=+s.roughnessScale; } return bandScales[i]; },
|
|
556
|
+
getBandScales(i){ return bandScales[i]; },
|
|
557
|
+
get totalEdits() { return bands.reduce((s, b) => s + b.nodes.size, 0); },
|
|
558
|
+
};
|
|
559
|
+
}
|