@hello-terrain/three 0.0.0-alpha.10 → 0.0.0-alpha.11

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/dist/index.mjs CHANGED
@@ -1,32 +1,31 @@
1
1
  import { BufferGeometry, BufferAttribute, RGBAFormat, ClampToEdgeWrapping, HalfFloatType, FloatType, LinearFilter, NearestFilter, Vector3 } from 'three';
2
2
  import { MeshStandardNodeMaterial, InstancedMesh, InstancedBufferAttribute, StorageTexture, StorageArrayTexture, StorageBufferAttribute, Vector3 as Vector3$1 } from 'three/webgpu';
3
3
  import { param, task, graph } from '@hello-terrain/work';
4
- import { uniform, Fn, float, globalId, int, vec2, uint, If, workgroupBarrier, textureStore, uvec3, vec4, texture, ivec2, ivec3, textureLoad, pow, vec3, storage, workgroupArray, localId, workgroupId, min, Loop, max, Break, vertexIndex, uv, select, instanceIndex, positionLocal, normalLocal, remap, dot, varyingProperty, mx_noise_float, mix } from 'three/tsl';
4
+ import { float, uniform, Fn, globalId, int, vec2, uint, If, textureStore, uvec3, vec4, texture, ivec2, ivec3, textureLoad, select, vec3, pow, storage, workgroupArray, localId, workgroupId, min, Loop, max, workgroupBarrier, Break, vertexIndex, uv, instanceIndex, positionLocal, normalLocal, remap, dot as dot$1, varyingProperty, mx_noise_float, mix } from 'three/tsl';
5
5
  import { Fn as Fn$1 } from 'three/src/nodes/TSL.js';
6
6
 
7
7
  class TerrainGeometry extends BufferGeometry {
8
- constructor(innerSegments = 14, extendUV = false) {
8
+ /**
9
+ * @param flipWinding Reverse triangle winding so front faces point the
10
+ * opposite way. The default winding makes flat tiles front-face `+Y`; the
11
+ * cube-sphere maps `(u→right, v→up)`, which would otherwise leave the
12
+ * planet's outer shell back-facing, so it passes `flipWinding` to render
13
+ * the outer surface with `FrontSide`.
14
+ */
15
+ constructor(innerSegments = 14, extendUV = false, flipWinding = false) {
9
16
  super();
10
17
  if (innerSegments < 1 || !Number.isFinite(innerSegments) || !Number.isInteger(innerSegments)) {
11
- throw new Error(
12
- `Invalid innerSegments: ${innerSegments}. Must be a positive integer.`
13
- );
18
+ throw new Error(`Invalid innerSegments: ${innerSegments}. Must be a positive integer.`);
14
19
  }
15
20
  try {
16
- this.setIndex(this.generateIndices(innerSegments));
21
+ this.setIndex(this.generateIndices(innerSegments, flipWinding));
17
22
  this.setAttribute(
18
23
  "position",
19
- new BufferAttribute(
20
- new Float32Array(this.generatePositions(innerSegments)),
21
- 3
22
- )
24
+ new BufferAttribute(new Float32Array(this.generatePositions(innerSegments)), 3)
23
25
  );
24
26
  this.setAttribute(
25
27
  "normal",
26
- new BufferAttribute(
27
- new Float32Array(this.generateNormals(innerSegments)),
28
- 3
29
- )
28
+ new BufferAttribute(new Float32Array(this.generateNormals(innerSegments)), 3)
30
29
  );
31
30
  this.setAttribute(
32
31
  "uv",
@@ -101,12 +100,16 @@ class TerrainGeometry extends BufferGeometry {
101
100
  * triangle 1: a, c, b
102
101
  * triangle 2: b, c, d
103
102
  */
104
- generateIndices(innerSegments) {
103
+ generateIndices(innerSegments, flipWinding = false) {
105
104
  const innerEdgeVertexCount = innerSegments + 1;
106
105
  const edgeVertexCountWithSkirt = innerEdgeVertexCount + 2;
107
106
  const indices = [];
108
107
  const cellsPerEdge = edgeVertexCountWithSkirt - 1;
109
108
  const mid = Math.floor(cellsPerEdge / 2);
109
+ const pushTri = (v0, v1, v2) => {
110
+ if (flipWinding) indices.push(v0, v2, v1);
111
+ else indices.push(v0, v1, v2);
112
+ };
110
113
  for (let y = 0; y < cellsPerEdge; y++) {
111
114
  for (let x = 0; x < cellsPerEdge; x++) {
112
115
  const a = y * edgeVertexCountWithSkirt + x;
@@ -123,11 +126,11 @@ class TerrainGeometry extends BufferGeometry {
123
126
  useDefaultDiagonal = (x + y) % 2 === 0;
124
127
  }
125
128
  if (useDefaultDiagonal) {
126
- indices.push(a, d, b);
127
- indices.push(a, c, d);
129
+ pushTri(a, d, b);
130
+ pushTri(a, c, d);
128
131
  } else {
129
- indices.push(a, c, b);
130
- indices.push(b, c, d);
132
+ pushTri(a, c, b);
133
+ pushTri(b, c, d);
131
134
  }
132
135
  }
133
136
  }
@@ -218,32 +221,70 @@ class TerrainGeometry extends BufferGeometry {
218
221
  }
219
222
  }
220
223
 
224
+ const rootSize = param(256).displayName("rootSize");
225
+ const origin = param({
226
+ x: 0,
227
+ y: 0,
228
+ z: 0
229
+ }).displayName("origin");
230
+ const innerTileSegments = param(61).displayName("innerTileSegments");
231
+ const skirtScale = param(100).displayName("skirtScale");
232
+ const elevationScale = param(1).displayName("elevationScale");
233
+ const radius = param(1e3).displayName("radius");
234
+ const maxNodes = param(1024).displayName("maxNodes");
235
+ const maxLevel = param(16).displayName("maxLevel");
236
+ const quadtreeUpdate = param({
237
+ cameraOrigin: { x: 0, y: 0, z: 0 },
238
+ mode: "distance",
239
+ distanceFactor: 1.5
240
+ }).displayName("quadtreeUpdate");
241
+ const topology = param(null).displayName("topology");
242
+ const terrainFieldFilter = param("linear").displayName(
243
+ "terrainFieldFilter"
244
+ );
245
+ const elevationFn = param(() => float(0));
246
+
221
247
  const defaultTerrainMeshParams = {
222
- innerTileSegments: 14,
248
+ // Source of truth is the `innerTileSegments` param itself.
249
+ innerTileSegments: innerTileSegments.get(),
223
250
  maxNodes: 1024,
224
- material: new MeshStandardNodeMaterial()
251
+ material: new MeshStandardNodeMaterial(),
252
+ flipWinding: false
225
253
  };
226
254
  class TerrainMesh extends InstancedMesh {
227
255
  _innerTileSegments;
228
256
  _maxNodes;
257
+ _flipWinding;
229
258
  terrainRaycast = null;
230
259
  constructor(params = defaultTerrainMeshParams) {
231
260
  const mergedParams = { ...defaultTerrainMeshParams, ...params };
232
- const { innerTileSegments, maxNodes, material } = mergedParams;
233
- const geometry = new TerrainGeometry(innerTileSegments, true);
261
+ const { innerTileSegments, maxNodes, material, flipWinding } = mergedParams;
262
+ const geometry = new TerrainGeometry(innerTileSegments, true, flipWinding);
234
263
  super(geometry, material, maxNodes);
235
264
  this.frustumCulled = false;
236
265
  this._innerTileSegments = innerTileSegments;
237
266
  this._maxNodes = maxNodes;
267
+ this._flipWinding = flipWinding;
238
268
  }
239
269
  get innerTileSegments() {
240
270
  return this._innerTileSegments;
241
271
  }
242
272
  set innerTileSegments(tileSegments) {
273
+ if (tileSegments === this._innerTileSegments) return;
243
274
  const oldGeometry = this.geometry;
244
- this.geometry = new TerrainGeometry(tileSegments, true);
275
+ this.geometry = new TerrainGeometry(tileSegments, true, this._flipWinding);
245
276
  this._innerTileSegments = tileSegments;
246
- setTimeout(oldGeometry.dispose);
277
+ setTimeout(() => oldGeometry.dispose());
278
+ }
279
+ get flipWinding() {
280
+ return this._flipWinding;
281
+ }
282
+ set flipWinding(flip) {
283
+ if (flip === this._flipWinding) return;
284
+ const oldGeometry = this.geometry;
285
+ this.geometry = new TerrainGeometry(this._innerTileSegments, true, flip);
286
+ this._flipWinding = flip;
287
+ setTimeout(() => oldGeometry.dispose());
247
288
  }
248
289
  get maxNodes() {
249
290
  return this._maxNodes;
@@ -309,13 +350,8 @@ function compileComputePipeline(stages, width, options) {
309
350
  WORKGROUP_X,
310
351
  WORKGROUP_Y
311
352
  ];
312
- const preferSingleKernelWhenPossible = options?.preferSingleKernelWhenPossible ?? true;
313
353
  const uInstanceCount = uniform(0, "uint");
314
- let singleKernel;
315
354
  const stagedKernelCache = /* @__PURE__ */ new Map();
316
- function canRunSingleKernel(widthValue, limits) {
317
- return widthValue <= limits.maxWorkgroupSizeX && widthValue <= limits.maxWorkgroupSizeY && widthValue * widthValue <= limits.maxWorkgroupInvocations;
318
- }
319
355
  function clampWorkgroupToLimits(requested, limits) {
320
356
  let x = Math.max(1, Math.floor(requested[0]));
321
357
  let y = Math.max(1, Math.floor(requested[1]));
@@ -331,37 +367,6 @@ function compileComputePipeline(stages, width, options) {
331
367
  );
332
368
  return [x, y];
333
369
  }
334
- function buildSingleKernel(workgroupSize) {
335
- return Fn(() => {
336
- bindings?.forEach((b) => b.toVar());
337
- const fWidth = float(width);
338
- const activeIndex = globalId.z;
339
- const nodeIndex = int(activeIndex).toVar();
340
- const iWidth = int(width);
341
- const ix = int(globalId.x);
342
- const iy = int(globalId.y);
343
- const texelSize = vec2(1, 1).div(fWidth);
344
- const localCoordinates = vec2(globalId.x, globalId.y);
345
- const localUVCoords = localCoordinates.div(fWidth);
346
- const verticesPerNode = iWidth.mul(iWidth);
347
- const globalIndex = int(nodeIndex).mul(verticesPerNode).add(iy.mul(iWidth).add(ix));
348
- const inBounds = ix.lessThan(iWidth).and(iy.lessThan(iWidth)).and(uint(activeIndex).lessThan(uInstanceCount)).toVar();
349
- for (let i = 0; i < stages.length; i++) {
350
- if (i > 0) {
351
- workgroupBarrier();
352
- }
353
- If(inBounds, () => {
354
- stages[i](
355
- nodeIndex,
356
- globalIndex,
357
- localUVCoords,
358
- localCoordinates,
359
- texelSize
360
- );
361
- });
362
- }
363
- })().computeKernel(workgroupSize);
364
- }
365
370
  function buildStagedKernels(workgroupSize) {
366
371
  return stages.map(
367
372
  (stage) => Fn(() => {
@@ -392,15 +397,7 @@ function compileComputePipeline(stages, width, options) {
392
397
  }
393
398
  function execute(renderer, instanceCount) {
394
399
  const limits = getDeviceComputeLimits(renderer);
395
- const canUseSingleKernel = preferSingleKernelWhenPossible && canRunSingleKernel(width, limits);
396
400
  uInstanceCount.value = instanceCount;
397
- if (canUseSingleKernel) {
398
- if (!singleKernel) {
399
- singleKernel = buildSingleKernel([width, width, 1]);
400
- }
401
- renderer.compute(singleKernel, [1, 1, instanceCount]);
402
- return;
403
- }
404
401
  const [workgroupX, workgroupY] = clampWorkgroupToLimits(
405
402
  preferredWorkgroup,
406
403
  limits
@@ -543,40 +540,9 @@ function AtlasBackend(edgeVertexCount, tileCount, options) {
543
540
  }
544
541
  };
545
542
  }
546
- function Texture3DBackend(edgeVertexCount, tileCount, options) {
547
- let currentEdgeVertexCount = edgeVertexCount;
548
- let currentTileCount = tileCount;
549
- const tex = new StorageArrayTexture(
550
- edgeVertexCount,
551
- edgeVertexCount,
552
- tileCount
553
- );
554
- configureStorageTexture(tex, options.format, options.filter);
555
- return {
556
- backendType: "texture-3d",
557
- get edgeVertexCount() {
558
- return currentEdgeVertexCount;
559
- },
560
- get tileCount() {
561
- return currentTileCount;
562
- },
563
- texture: tex,
564
- uv(ix, iy, _tileIndex) {
565
- return vec2(ix.toFloat(), iy.toFloat());
566
- },
567
- texel(ix, iy, tileIndex) {
568
- return ivec3(ix, iy, tileIndex);
569
- },
570
- sample(u, v, tileIndex) {
571
- return texture(tex, vec2(u, v)).depth(int(tileIndex));
572
- },
573
- resize(width, height, nextTileCount) {
574
- currentEdgeVertexCount = width;
575
- currentTileCount = nextTileCount;
576
- tex.setSize(width, height, nextTileCount);
577
- tex.needsUpdate = true;
578
- }
579
- };
543
+ function texture3DBackend(edgeVertexCount, tileCount, options) {
544
+ const storage = ArrayTextureBackend(edgeVertexCount, tileCount, options);
545
+ return { ...storage, backendType: "texture-3d" };
580
546
  }
581
547
  function tryGetDeviceLimits(renderer) {
582
548
  const backend = renderer;
@@ -590,7 +556,7 @@ function createTerrainFieldStorage(edgeVertexCount, tileCount, renderer, options
590
556
  return AtlasBackend(edgeVertexCount, tileCount, { filter, format });
591
557
  }
592
558
  if (forcedBackend === "texture-3d") {
593
- return Texture3DBackend(edgeVertexCount, tileCount, { filter, format });
559
+ return texture3DBackend(edgeVertexCount, tileCount, { filter, format });
594
560
  }
595
561
  if (forcedBackend === "array-texture") {
596
562
  return ArrayTextureBackend(edgeVertexCount, tileCount, { filter, format });
@@ -633,10 +599,6 @@ function sampleTerrainField(storage, u, v, tileIndex) {
633
599
  function sampleTerrainFieldElevation(storage, u, v, tileIndex) {
634
600
  return sampleTerrainField(storage, u, v, tileIndex).r;
635
601
  }
636
- function sampleTerrainFieldNormal(storage, u, v, tileIndex) {
637
- const raw = sampleTerrainField(storage, u, v, tileIndex);
638
- return vec2(raw.g, raw.b);
639
- }
640
602
  function packTerrainFieldSample(height, normalXZ, extra = float(0)) {
641
603
  return vec4(height, normalXZ.x, normalXZ.y, extra);
642
604
  }
@@ -663,23 +625,132 @@ const createElevation = (tile, uniforms, elevationFn) => {
663
625
  };
664
626
  };
665
627
 
666
- function createTileCompute(leafStorage, uniforms) {
628
+ const CUBE_FACE_COUNT = 6;
629
+ const CUBE_FACES = [
630
+ // 0: +X
631
+ { forward: [1, 0, 0], right: [0, 0, -1], up: [0, 1, 0] },
632
+ // 1: -X
633
+ { forward: [-1, 0, 0], right: [0, 0, 1], up: [0, 1, 0] },
634
+ // 2: +Y (north pole)
635
+ { forward: [0, 1, 0], right: [1, 0, 0], up: [0, 0, -1] },
636
+ // 3: -Y (south pole)
637
+ { forward: [0, -1, 0], right: [1, 0, 0], up: [0, 0, 1] },
638
+ // 4: +Z
639
+ { forward: [0, 0, 1], right: [1, 0, 0], up: [0, 1, 0] },
640
+ // 5: -Z
641
+ { forward: [0, 0, -1], right: [-1, 0, 0], up: [0, 1, 0] }
642
+ ];
643
+
644
+ function vec3Const(v) {
645
+ return vec3(float(v[0]), float(v[1]), float(v[2]));
646
+ }
647
+ function selectFaceVec3(face, pick) {
648
+ const last = CUBE_FACES.length - 1;
649
+ let acc = vec3Const(pick(CUBE_FACES[last]));
650
+ for (let i = last - 1; i >= 0; i--) {
651
+ acc = select(int(face).equal(int(i)), vec3Const(pick(CUBE_FACES[i])), acc);
652
+ }
653
+ return acc;
654
+ }
655
+ function cubeFaceBasis(face) {
656
+ return {
657
+ forward: selectFaceVec3(face, (f) => f.forward),
658
+ right: selectFaceVec3(face, (f) => f.right),
659
+ up: selectFaceVec3(face, (f) => f.up)
660
+ };
661
+ }
662
+ function cubeFacePoint(basis, u, v) {
663
+ const s = float(u).mul(2).sub(1);
664
+ const t = float(v).mul(2).sub(1);
665
+ return basis.forward.add(basis.right.mul(s)).add(basis.up.mul(t));
666
+ }
667
+ function cubeFaceDirection(basis, u, v) {
668
+ return cubeFacePoint(basis, u, v).normalize();
669
+ }
670
+ function tangentFromAxis(dir, axis) {
671
+ return axis.sub(dir.mul(dir.dot(axis))).normalize();
672
+ }
673
+ function unpackTangentNormal(nx, nz) {
674
+ const ny = float(1).sub(nx.mul(nx)).sub(nz.mul(nz)).max(float(0)).sqrt();
675
+ return vec3(nx, ny, nz);
676
+ }
677
+ function sphereTangentFrameNormal(dir, basis, tangentNormal) {
678
+ const n = vec3(tangentNormal);
679
+ const tu = tangentFromAxis(dir, basis.right);
680
+ const tv = tangentFromAxis(dir, basis.up);
681
+ return tu.mul(n.x).add(dir.mul(n.y)).add(tv.mul(n.z)).normalize();
682
+ }
683
+ function cubeFaceFromDirection(dir) {
684
+ const d = vec3(dir);
685
+ const ax = d.x.abs();
686
+ const ay = d.y.abs();
687
+ const az = d.z.abs();
688
+ const faceX = select(d.x.greaterThanEqual(float(0)), int(0), int(1));
689
+ const faceY = select(d.y.greaterThanEqual(float(0)), int(2), int(3));
690
+ const faceZ = select(d.z.greaterThanEqual(float(0)), int(4), int(5));
691
+ const xDominant = ax.greaterThanEqual(ay).and(ax.greaterThanEqual(az));
692
+ const yDominant = ay.greaterThanEqual(ax).and(ay.greaterThanEqual(az));
693
+ return select(xDominant, faceX, select(yDominant, faceY, faceZ));
694
+ }
695
+ function cubeFaceUVFromDirection(basis, dir) {
696
+ const d = vec3(dir);
697
+ const p = d.div(d.dot(basis.forward));
698
+ const s = p.dot(basis.right);
699
+ const t = p.dot(basis.up);
700
+ return vec2(s.add(float(1)).mul(float(0.5)), t.add(float(1)).mul(float(0.5)));
701
+ }
702
+
703
+ const HALF_PI = Math.PI * 0.5;
704
+ const FIELD_INNER_TEXEL_OFFSET = 1.5;
705
+ const FIELD_EDGE_EXTRA_TEXELS = 3;
706
+ function sphereTileArcLength(radius, levelDivisor) {
707
+ return radius * HALF_PI / levelDivisor;
708
+ }
709
+ function decodeLeafTile(leafStorage, nodeIndex) {
710
+ const nodeOffset = int(nodeIndex).mul(int(4));
711
+ return {
712
+ level: leafStorage.node.element(nodeOffset).toInt(),
713
+ x: leafStorage.node.element(nodeOffset.add(int(1))).toFloat(),
714
+ y: leafStorage.node.element(nodeOffset.add(int(2))).toFloat(),
715
+ face: leafStorage.node.element(nodeOffset.add(int(3))).toInt()
716
+ };
717
+ }
718
+ function faceUVFromTileLocal(tile, localU, localV) {
719
+ const n = pow(float(2), tile.level.toFloat());
720
+ return vec2(tile.x.add(localU).div(n), tile.y.add(localV).div(n));
721
+ }
722
+ function createTileCompute(leafStorage, uniforms, projection = "flat") {
723
+ const isSphere = projection === "cubeSphere";
667
724
  const tileLevel = Fn(([nodeIndex]) => {
668
- const nodeOffset = nodeIndex.mul(int(4));
669
- return leafStorage.node.element(nodeOffset).toInt();
725
+ return decodeLeafTile(leafStorage, nodeIndex).level;
726
+ });
727
+ const tileFace = Fn(([nodeIndex]) => {
728
+ return decodeLeafTile(leafStorage, nodeIndex).face;
670
729
  });
671
730
  const tileOriginVec2 = Fn(([nodeIndex]) => {
672
- const nodeOffset = nodeIndex.mul(int(4));
673
- const nodeX = leafStorage.node.element(nodeOffset.add(int(1))).toFloat();
674
- const nodeY = leafStorage.node.element(nodeOffset.add(int(2))).toFloat();
675
- return vec2(nodeX, nodeY);
731
+ const tile = decodeLeafTile(leafStorage, nodeIndex);
732
+ return vec2(tile.x, tile.y);
676
733
  });
677
734
  const tileSize = Fn(([nodeIndex]) => {
678
- const rootSize = uniforms.uRootSize.toVar();
679
735
  const level = tileLevel(nodeIndex);
680
- return float(rootSize).div(pow(float(2), level.toFloat()));
736
+ const divisor = pow(float(2), level.toFloat());
737
+ if (isSphere) {
738
+ return uniforms.uRadius.toVar().mul(float(HALF_PI)).div(divisor);
739
+ }
740
+ const rootSize = uniforms.uRootSize.toVar();
741
+ return float(rootSize).div(divisor);
742
+ });
743
+ const tileFaceUV = Fn(([nodeIndex, ix, iy]) => {
744
+ const tile = decodeLeafTile(leafStorage, nodeIndex);
745
+ const fInnerSegments = uniforms.uInnerTileSegments.toVar().toFloat();
746
+ const localU = int(ix).toFloat().sub(float(1)).div(fInnerSegments);
747
+ const localV = int(iy).toFloat().sub(float(1)).div(fInnerSegments);
748
+ return faceUVFromTileLocal(tile, localU, localV);
681
749
  });
682
750
  const rootUVCompute = Fn(([nodeIndex, ix, iy]) => {
751
+ if (isSphere) {
752
+ return tileFaceUV(nodeIndex, ix, iy);
753
+ }
683
754
  const nodeVec2 = tileOriginVec2(nodeIndex);
684
755
  const nodeX = nodeVec2.x;
685
756
  const nodeY = nodeVec2.y;
@@ -704,6 +775,12 @@ function createTileCompute(leafStorage, uniforms) {
704
775
  const tileVertexWorldPositionCompute = Fn(
705
776
  ([nodeIndex, ix, iy]) => {
706
777
  const rootOrigin = uniforms.uRootOrigin.toVar();
778
+ if (isSphere) {
779
+ const faceUV = tileFaceUV(nodeIndex, ix, iy);
780
+ const basis = cubeFaceBasis(tileFace(nodeIndex));
781
+ const dir = cubeFaceDirection(basis, faceUV.x, faceUV.y);
782
+ return rootOrigin.add(dir.mul(uniforms.uRadius.toVar()));
783
+ }
707
784
  const nodeVec2 = tileOriginVec2(nodeIndex);
708
785
  const nodeX = nodeVec2.x;
709
786
  const nodeY = nodeVec2.y;
@@ -722,36 +799,22 @@ function createTileCompute(leafStorage, uniforms) {
722
799
  );
723
800
  return {
724
801
  tileLevel,
802
+ tileFace,
725
803
  tileOriginVec2,
726
804
  tileSize,
805
+ tileFaceUV,
727
806
  rootUVCompute,
728
807
  tileVertexWorldPositionCompute
729
808
  };
730
809
  }
731
- function tileLocalToFieldUV$1(localCoord, innerSegments) {
732
- const edge = float(innerSegments).add(float(3));
733
- return float(localCoord).mul(float(innerSegments)).add(float(1.5)).div(edge);
810
+ function tileLocalToFieldUV(localCoord, innerSegments) {
811
+ const edge = float(innerSegments).add(float(FIELD_EDGE_EXTRA_TEXELS));
812
+ return float(localCoord).mul(float(innerSegments)).add(float(FIELD_INNER_TEXEL_OFFSET)).div(edge);
813
+ }
814
+ function tileLocalToFieldUVNumber(localCoord, innerSegments) {
815
+ const edge = innerSegments + FIELD_EDGE_EXTRA_TEXELS;
816
+ return (localCoord * innerSegments + FIELD_INNER_TEXEL_OFFSET) / edge;
734
817
  }
735
-
736
- const rootSize = param(256).displayName("rootSize");
737
- const origin = param({
738
- x: 0,
739
- y: 0,
740
- z: 0
741
- }).displayName("origin");
742
- const innerTileSegments = param(13).displayName("innerTileSegments");
743
- const skirtScale = param(100).displayName("skirtScale");
744
- const elevationScale = param(1).displayName("elevationScale");
745
- const maxNodes = param(1024).displayName("maxNodes");
746
- const maxLevel = param(16).displayName("maxLevel");
747
- const quadtreeUpdate = param({
748
- cameraOrigin: { x: 0, y: 0, z: 0 },
749
- mode: "distance",
750
- distanceFactor: 1.5
751
- }).displayName("quadtreeUpdate");
752
- const surface = param(null).displayName("surface");
753
- const terrainFieldFilter = param("linear").displayName("terrainFieldFilter");
754
- const elevationFn = param(() => float(0));
755
818
 
756
819
  function createLeafStorage(maxNodes) {
757
820
  const data = new Int32Array(maxNodes * 4);
@@ -936,10 +999,10 @@ function buildLeafIndex(leaves, out) {
936
999
  return index;
937
1000
  }
938
1001
 
939
- function createState(cfg, surface) {
940
- const store = createNodeStore(cfg.maxNodes, surface.spaceCount);
1002
+ function createState(cfg, topology) {
1003
+ const store = createNodeStore(cfg.maxNodes, topology.spaceCount);
941
1004
  const scratchRootTiles = [];
942
- for (let i = 0; i < surface.maxRootCount; i++) {
1005
+ for (let i = 0; i < topology.maxRootCount; i++) {
943
1006
  scratchRootTiles.push({ space: 0, level: 0, x: 0, y: 0 });
944
1007
  }
945
1008
  return {
@@ -949,7 +1012,7 @@ function createState(cfg, surface) {
949
1012
  leafNodeIds: new Uint32Array(cfg.maxNodes),
950
1013
  leafIndex: createSpatialIndex(cfg.maxNodes),
951
1014
  stack: new Uint32Array(cfg.maxNodes),
952
- rootNodeIds: new Uint32Array(surface.maxRootCount),
1015
+ rootNodeIds: new Uint32Array(topology.maxRootCount),
953
1016
  rootCount: 0,
954
1017
  splitQueue: new Uint32Array(cfg.maxNodes),
955
1018
  splitStamp: new Uint16Array(cfg.maxNodes),
@@ -958,25 +1021,25 @@ function createState(cfg, surface) {
958
1021
  scratchNeighbor: { space: 0, level: 0, x: 0, y: 0 },
959
1022
  scratchBounds: { cx: 0, cy: 0, cz: 0, r: 0 },
960
1023
  scratchRootTiles,
961
- spaceCount: surface.spaceCount
1024
+ spaceCount: topology.spaceCount
962
1025
  };
963
1026
  }
964
- function beginUpdate(state, surface, params) {
965
- if (surface.spaceCount !== state.spaceCount) {
1027
+ function beginUpdate(state, topology, params) {
1028
+ if (topology.spaceCount !== state.spaceCount) {
966
1029
  throw new Error(
967
- `Surface spaceCount changed (${state.spaceCount} -> ${surface.spaceCount}). Create a new quadtree state.`
1030
+ `Topology spaceCount changed (${state.spaceCount} -> ${topology.spaceCount}). Create a new quadtree state.`
968
1031
  );
969
1032
  }
970
- if (surface.maxRootCount !== state.rootNodeIds.length) {
1033
+ if (topology.maxRootCount !== state.rootNodeIds.length) {
971
1034
  throw new Error(
972
- `Surface maxRootCount changed (${state.rootNodeIds.length} -> ${surface.maxRootCount}). Create a new quadtree state.`
1035
+ `Topology maxRootCount changed (${state.rootNodeIds.length} -> ${topology.maxRootCount}). Create a new quadtree state.`
973
1036
  );
974
1037
  }
975
1038
  beginFrame(state.store);
976
1039
  state.rootCount = 0;
977
- const rootCount = surface.rootTiles(params.cameraOrigin, state.scratchRootTiles);
978
- if (rootCount < 0 || rootCount > surface.maxRootCount) {
979
- throw new Error(`Surface returned invalid root count (${rootCount}).`);
1040
+ const rootCount = topology.rootTiles(params.cameraOrigin, state.scratchRootTiles);
1041
+ if (rootCount < 0 || rootCount > topology.maxRootCount) {
1042
+ throw new Error(`Topology returned invalid root count (${rootCount}).`);
980
1043
  }
981
1044
  for (let i = 0; i < rootCount; i++) {
982
1045
  const rootId = allocNode(state.store, state.scratchRootTiles[i]);
@@ -1013,7 +1076,7 @@ function shouldSplit(bounds, level, maxLevel, params) {
1013
1076
  return safeDistSq < threshold * threshold;
1014
1077
  }
1015
1078
 
1016
- function refineLeaves(state, surface, params, outLeaves) {
1079
+ function refineLeaves(state, topology, params, outLeaves) {
1017
1080
  const leaves = outLeaves ?? state.leaves;
1018
1081
  resetLeafSet(leaves);
1019
1082
  const store = state.store;
@@ -1034,7 +1097,7 @@ function refineLeaves(state, surface, params, outLeaves) {
1034
1097
  tile.x = x;
1035
1098
  tile.y = y;
1036
1099
  const bounds = state.scratchBounds;
1037
- surface.tileBounds(tile, params.cameraOrigin, bounds);
1100
+ topology.tileBounds(tile, params.cameraOrigin, bounds);
1038
1101
  if (hasChildren(store, nodeId)) {
1039
1102
  const base = store.firstChild[nodeId];
1040
1103
  stack[sp++] = base + 3;
@@ -1082,7 +1145,7 @@ function scheduleSplit(state, nodeId, count) {
1082
1145
  state.splitQueue[count] = nodeId;
1083
1146
  return count + 1;
1084
1147
  }
1085
- function balance2to1(state, surface, params, leaves) {
1148
+ function balance2to1(state, topology, params, leaves) {
1086
1149
  const maxIters = state.cfg.maxLevel + 1;
1087
1150
  for (let iter = 0; iter < maxIters; iter++) {
1088
1151
  const index = buildLeafIndex(leaves, state.leafIndex);
@@ -1103,7 +1166,7 @@ function balance2to1(state, surface, params, leaves) {
1103
1166
  tile.x = leafX >>> shift;
1104
1167
  tile.y = leafY >>> shift;
1105
1168
  const neighbor = state.scratchNeighbor;
1106
- if (!surface.neighborSameLevel(tile, dir, neighbor)) break;
1169
+ if (!topology.neighborSameLevel(tile, dir, neighbor)) break;
1107
1170
  const j = lookupSpatialIndexRaw(
1108
1171
  index,
1109
1172
  neighbor.space,
@@ -1127,18 +1190,38 @@ function balance2to1(state, surface, params, leaves) {
1127
1190
  if (base !== U32_EMPTY) anySplit = true;
1128
1191
  }
1129
1192
  if (!anySplit) return leaves;
1130
- refineLeaves(state, surface, params, leaves);
1193
+ refineLeaves(state, topology, params, leaves);
1131
1194
  }
1132
1195
  return leaves;
1133
1196
  }
1134
1197
 
1135
- function update(state, surface, params, outLeaves) {
1136
- const origY = params.cameraOrigin.y;
1137
- params.cameraOrigin.y -= params.elevationAtCameraXZ ?? 0;
1138
- beginUpdate(state, surface, params);
1139
- const leaves = refineLeaves(state, surface, params, outLeaves);
1140
- const result = balance2to1(state, surface, params, leaves);
1141
- params.cameraOrigin.y = origY;
1198
+ function update(state, topology, params, outLeaves) {
1199
+ const cam = params.cameraOrigin;
1200
+ const elevation = params.elevationAtCameraXZ ?? 0;
1201
+ const origX = cam.x;
1202
+ const origY = cam.y;
1203
+ const origZ = cam.z;
1204
+ if (topology.projection === "cubeSphere") {
1205
+ const center = topology.center ?? { x: 0, y: 0, z: 0 };
1206
+ const dx = cam.x - center.x;
1207
+ const dy = cam.y - center.y;
1208
+ const dz = cam.z - center.z;
1209
+ const len = Math.hypot(dx, dy, dz);
1210
+ if (len > 1e-12) {
1211
+ const inv = elevation / len;
1212
+ cam.x -= dx * inv;
1213
+ cam.y -= dy * inv;
1214
+ cam.z -= dz * inv;
1215
+ }
1216
+ } else {
1217
+ cam.y -= elevation;
1218
+ }
1219
+ beginUpdate(state, topology, params);
1220
+ const leaves = refineLeaves(state, topology, params, outLeaves);
1221
+ const result = balance2to1(state, topology, params, leaves);
1222
+ cam.x = origX;
1223
+ cam.y = origY;
1224
+ cam.z = origZ;
1142
1225
  return result;
1143
1226
  }
1144
1227
 
@@ -1146,7 +1229,7 @@ const scratchTile = { space: 0, level: 0, x: 0, y: 0 };
1146
1229
  const scratchNbr = { space: 0, level: 0, x: 0, y: 0 };
1147
1230
  const scratchParentTile = { space: 0, level: 0, x: 0, y: 0 };
1148
1231
  const scratchParentNbr = { space: 0, level: 0, x: 0, y: 0 };
1149
- function buildSeams2to1(surface, leaves, outSeams, outIndex) {
1232
+ function buildSeams2to1(topology, leaves, outSeams, outIndex) {
1150
1233
  if (outSeams.capacity < leaves.count) {
1151
1234
  throw new Error("SeamTable capacity is smaller than LeafSet.count.");
1152
1235
  }
@@ -1167,7 +1250,7 @@ function buildSeams2to1(surface, leaves, outSeams, outIndex) {
1167
1250
  scratchTile.level = level;
1168
1251
  scratchTile.x = x;
1169
1252
  scratchTile.y = y;
1170
- if (!surface.neighborSameLevel(scratchTile, dir, scratchNbr)) continue;
1253
+ if (!topology.neighborSameLevel(scratchTile, dir, scratchNbr)) continue;
1171
1254
  let j = lookupSpatialIndexRaw(index, scratchNbr.space, scratchNbr.level, scratchNbr.x, scratchNbr.y);
1172
1255
  if (j !== U32_EMPTY) {
1173
1256
  neighbors[outOffset + 0] = j;
@@ -1180,7 +1263,7 @@ function buildSeams2to1(surface, leaves, outSeams, outIndex) {
1180
1263
  scratchParentTile.level = level - 1;
1181
1264
  scratchParentTile.x = px;
1182
1265
  scratchParentTile.y = py;
1183
- if (surface.neighborSameLevel(scratchParentTile, dir, scratchParentNbr)) {
1266
+ if (topology.neighborSameLevel(scratchParentTile, dir, scratchParentNbr)) {
1184
1267
  j = lookupSpatialIndexRaw(
1185
1268
  index,
1186
1269
  scratchParentNbr.space,
@@ -1236,10 +1319,10 @@ function buildSeams2to1(surface, leaves, outSeams, outIndex) {
1236
1319
  return outSeams;
1237
1320
  }
1238
1321
 
1239
- function createFlatSurface(cfg) {
1322
+ function createFlatTopology(cfg) {
1240
1323
  const halfRoot = 0.5 * cfg.rootSize;
1241
1324
  const maxHeight = cfg.maxHeight ?? 0;
1242
- const surface = {
1325
+ const topology = {
1243
1326
  spaceCount: 1,
1244
1327
  maxRootCount: 1,
1245
1328
  neighborSameLevel(tile, dir, out) {
@@ -1294,10 +1377,10 @@ function createFlatSurface(cfg) {
1294
1377
  return 1;
1295
1378
  }
1296
1379
  };
1297
- return surface;
1380
+ return topology;
1298
1381
  }
1299
1382
 
1300
- function createInfiniteFlatSurface(cfg) {
1383
+ function createInfiniteFlatTopology(cfg) {
1301
1384
  const halfRoot = 0.5 * cfg.rootSize;
1302
1385
  const maxHeight = cfg.maxHeight ?? 0;
1303
1386
  const rootGridRadius = Math.max(0, Math.floor(cfg.rootGridRadius ?? 1));
@@ -1361,18 +1444,158 @@ function createInfiniteFlatSurface(cfg) {
1361
1444
  };
1362
1445
  }
1363
1446
 
1364
- function createCubeSphereSurface(_cfg) {
1447
+ const DEG_TO_RAD = Math.PI / 180;
1448
+ const RAD_TO_DEG = 180 / Math.PI;
1449
+ function dot(a, b) {
1450
+ return a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
1451
+ }
1452
+ function faceUVToCube(face, u, v, out) {
1453
+ const f = CUBE_FACES[face];
1454
+ const s = 2 * u - 1;
1455
+ const t = 2 * v - 1;
1456
+ out[0] = f.forward[0] + s * f.right[0] + t * f.up[0];
1457
+ out[1] = f.forward[1] + s * f.right[1] + t * f.up[1];
1458
+ out[2] = f.forward[2] + s * f.right[2] + t * f.up[2];
1459
+ }
1460
+ function directionToFace(d) {
1461
+ const ax = Math.abs(d[0]);
1462
+ const ay = Math.abs(d[1]);
1463
+ const az = Math.abs(d[2]);
1464
+ if (ax >= ay && ax >= az) return d[0] >= 0 ? 0 : 1;
1465
+ if (ay >= ax && ay >= az) return d[1] >= 0 ? 2 : 3;
1466
+ return d[2] >= 0 ? 4 : 5;
1467
+ }
1468
+ function directionToFaceUV(face, d, out) {
1469
+ const f = CUBE_FACES[face];
1470
+ const denom = dot(d, f.forward);
1471
+ const inv = 1 / denom;
1472
+ const px = d[0] * inv;
1473
+ const py = d[1] * inv;
1474
+ const pz = d[2] * inv;
1475
+ const p = [px, py, pz];
1476
+ const s = dot(p, f.right);
1477
+ const t = dot(p, f.up);
1478
+ out[0] = (s + 1) * 0.5;
1479
+ out[1] = (t + 1) * 0.5;
1480
+ }
1481
+ function latLongToDirection(latDeg, lonDeg, out) {
1482
+ const lat = latDeg * DEG_TO_RAD;
1483
+ const lon = lonDeg * DEG_TO_RAD;
1484
+ const cosLat = Math.cos(lat);
1485
+ out[0] = cosLat * Math.sin(lon);
1486
+ out[1] = Math.sin(lat);
1487
+ out[2] = cosLat * Math.cos(lon);
1488
+ }
1489
+ function directionToLatLong(d) {
1490
+ const len = Math.hypot(d[0], d[1], d[2]) || 1;
1491
+ const y = Math.max(-1, Math.min(1, d[1] / len));
1492
+ return {
1493
+ latitude: Math.asin(y) * RAD_TO_DEG,
1494
+ longitude: Math.atan2(d[0], d[2]) * RAD_TO_DEG
1495
+ };
1496
+ }
1497
+
1498
+ function createCubeSphereTopology(cfg) {
1499
+ const radius = cfg.radius;
1500
+ const maxHeight = cfg.maxHeight ?? 0;
1501
+ const center = cfg.center ?? { x: 0, y: 0, z: 0 };
1502
+ const cube = [0, 0, 0];
1503
+ const uv = [0, 0];
1504
+ function crossFaceNeighbor(face, level, nx, ny, out) {
1505
+ const n = 1 << level;
1506
+ const u = (nx + 0.5) / n;
1507
+ const v = (ny + 0.5) / n;
1508
+ faceUVToCube(face, u, v, cube);
1509
+ const len = Math.hypot(cube[0], cube[1], cube[2]);
1510
+ const dir = [cube[0] / len, cube[1] / len, cube[2] / len];
1511
+ const nbrFace = directionToFace(dir);
1512
+ directionToFaceUV(nbrFace, dir, uv);
1513
+ let bx = Math.floor(uv[0] * n);
1514
+ let by = Math.floor(uv[1] * n);
1515
+ if (bx < 0) bx = 0;
1516
+ else if (bx > n - 1) bx = n - 1;
1517
+ if (by < 0) by = 0;
1518
+ else if (by > n - 1) by = n - 1;
1519
+ out.space = nbrFace;
1520
+ out.level = level;
1521
+ out.x = bx;
1522
+ out.y = by;
1523
+ }
1365
1524
  return {
1366
1525
  spaceCount: 6,
1367
1526
  maxRootCount: 6,
1368
- neighborSameLevel(_tile, _dir, _out) {
1369
- return false;
1527
+ neighborSameLevel(tile, dir, out) {
1528
+ const level = tile.level;
1529
+ const n = 1 << level;
1530
+ let nx = tile.x;
1531
+ let ny = tile.y;
1532
+ switch (dir) {
1533
+ case 0:
1534
+ nx -= 1;
1535
+ break;
1536
+ case 1:
1537
+ nx += 1;
1538
+ break;
1539
+ case 2:
1540
+ ny -= 1;
1541
+ break;
1542
+ case 3:
1543
+ ny += 1;
1544
+ break;
1545
+ }
1546
+ if (nx >= 0 && ny >= 0 && nx < n && ny < n) {
1547
+ out.space = tile.space;
1548
+ out.level = level;
1549
+ out.x = nx;
1550
+ out.y = ny;
1551
+ return true;
1552
+ }
1553
+ crossFaceNeighbor(tile.space, level, nx, ny, out);
1554
+ return true;
1370
1555
  },
1371
- tileBounds(_tile, _cameraOrigin, out) {
1372
- out.cx = 0;
1373
- out.cy = 0;
1374
- out.cz = 0;
1375
- out.r = Number.MAX_VALUE;
1556
+ tileBounds(tile, cameraOrigin, out) {
1557
+ const level = tile.level;
1558
+ const n = 1 << level;
1559
+ const u0 = tile.x / n;
1560
+ const u1 = (tile.x + 1) / n;
1561
+ const v0 = tile.y / n;
1562
+ const v1 = (tile.y + 1) / n;
1563
+ const cornersU = [u0, u1, u0, u1];
1564
+ const cornersV = [v0, v0, v1, v1];
1565
+ let sumX = 0;
1566
+ let sumY = 0;
1567
+ let sumZ = 0;
1568
+ const px = [0, 0, 0, 0];
1569
+ const py = [0, 0, 0, 0];
1570
+ const pz = [0, 0, 0, 0];
1571
+ for (let i = 0; i < 4; i++) {
1572
+ faceUVToCube(tile.space, cornersU[i], cornersV[i], cube);
1573
+ const len = Math.hypot(cube[0], cube[1], cube[2]);
1574
+ const sx = center.x + cube[0] / len * radius;
1575
+ const sy = center.y + cube[1] / len * radius;
1576
+ const sz = center.z + cube[2] / len * radius;
1577
+ px[i] = sx;
1578
+ py[i] = sy;
1579
+ pz[i] = sz;
1580
+ sumX += sx;
1581
+ sumY += sy;
1582
+ sumZ += sz;
1583
+ }
1584
+ const cX = sumX * 0.25;
1585
+ const cY = sumY * 0.25;
1586
+ const cZ = sumZ * 0.25;
1587
+ let maxDistSq = 0;
1588
+ for (let i = 0; i < 4; i++) {
1589
+ const dx = px[i] - cX;
1590
+ const dy = py[i] - cY;
1591
+ const dz = pz[i] - cZ;
1592
+ const dSq = dx * dx + dy * dy + dz * dz;
1593
+ if (dSq > maxDistSq) maxDistSq = dSq;
1594
+ }
1595
+ out.cx = cX - cameraOrigin.x;
1596
+ out.cy = cY - cameraOrigin.y;
1597
+ out.cz = cZ - cameraOrigin.z;
1598
+ out.r = Math.sqrt(maxDistSq) + maxHeight;
1376
1599
  },
1377
1600
  rootTiles(_cameraOrigin, out) {
1378
1601
  for (let s = 0; s < 6; s++) {
@@ -1383,10 +1606,141 @@ function createCubeSphereSurface(_cfg) {
1383
1606
  root.y = 0;
1384
1607
  }
1385
1608
  return 6;
1386
- }
1609
+ },
1610
+ projection: "cubeSphere",
1611
+ radius,
1612
+ center
1387
1613
  };
1388
1614
  }
1389
1615
 
1616
+ function readHeight(elevation, shape, leafIndex, ix, iy) {
1617
+ const base = leafIndex * shape.verticesPerNode;
1618
+ return elevation[base + iy * shape.edgeVertexCount + ix] ?? 0;
1619
+ }
1620
+ function sampleGridBilinear(elevation, shape, leafIndex, gx, gy) {
1621
+ const max = shape.edgeVertexCount - 1;
1622
+ const x = Math.max(0, Math.min(max, gx));
1623
+ const y = Math.max(0, Math.min(max, gy));
1624
+ const x0 = Math.floor(x);
1625
+ const y0 = Math.floor(y);
1626
+ const x1 = Math.min(max, x0 + 1);
1627
+ const y1 = Math.min(max, y0 + 1);
1628
+ const tx = x - x0;
1629
+ const ty = y - y0;
1630
+ const h00 = readHeight(elevation, shape, leafIndex, x0, y0);
1631
+ const h10 = readHeight(elevation, shape, leafIndex, x1, y0);
1632
+ const h01 = readHeight(elevation, shape, leafIndex, x0, y1);
1633
+ const h11 = readHeight(elevation, shape, leafIndex, x1, y1);
1634
+ const hx0 = h00 + (h10 - h00) * tx;
1635
+ const hx1 = h01 + (h11 - h01) * tx;
1636
+ return hx0 + (hx1 - hx0) * ty;
1637
+ }
1638
+ function elevationGradientAt(elevation, shape, leafIndex, gx, gy, stepWorld, elevationScale, out) {
1639
+ const hLeft = sampleGridBilinear(elevation, shape, leafIndex, gx - 1, gy);
1640
+ const hRight = sampleGridBilinear(elevation, shape, leafIndex, gx + 1, gy);
1641
+ const hUp = sampleGridBilinear(elevation, shape, leafIndex, gx, gy - 1);
1642
+ const hDown = sampleGridBilinear(elevation, shape, leafIndex, gx, gy + 1);
1643
+ const inv2Step = 0.5 / stepWorld;
1644
+ out.dhdu = (hRight - hLeft) * elevationScale * inv2Step;
1645
+ out.dhdv = (hDown - hUp) * elevationScale * inv2Step;
1646
+ return out;
1647
+ }
1648
+
1649
+ const MISSED_LOOKUP = Object.freeze({
1650
+ found: false,
1651
+ leafIndex: -1,
1652
+ space: -1,
1653
+ level: -1,
1654
+ tileX: -1,
1655
+ tileY: -1,
1656
+ tileSize: 0,
1657
+ localU: 0,
1658
+ localV: 0
1659
+ });
1660
+ function lookupTile(index, config, worldX, worldZ) {
1661
+ const halfRoot = config.rootSize * 0.5;
1662
+ for (let level = config.maxLevel; level >= 0; level -= 1) {
1663
+ const scale = 2 ** level;
1664
+ const tileSize = config.rootSize / scale;
1665
+ const tileX = Math.floor((worldX - config.originX + halfRoot) / tileSize);
1666
+ const tileY = Math.floor((worldZ - config.originZ + halfRoot) / tileSize);
1667
+ const leafIndex = lookupSpatialIndexRaw(index, 0, level, tileX, tileY);
1668
+ if (leafIndex !== U32_EMPTY) {
1669
+ const tileMinX = config.originX + tileX * tileSize - halfRoot;
1670
+ const tileMinZ = config.originZ + tileY * tileSize - halfRoot;
1671
+ return {
1672
+ found: true,
1673
+ leafIndex,
1674
+ space: 0,
1675
+ level,
1676
+ tileX,
1677
+ tileY,
1678
+ tileSize,
1679
+ localU: (worldX - tileMinX) / tileSize,
1680
+ localV: (worldZ - tileMinZ) / tileSize
1681
+ };
1682
+ }
1683
+ }
1684
+ return MISSED_LOOKUP;
1685
+ }
1686
+ function clamp01(value) {
1687
+ return value < 0 ? 0 : value > 1 ? 1 : value;
1688
+ }
1689
+ function lookupTileByFaceUV(index, config, face, u, v) {
1690
+ for (let level = config.maxLevel; level >= 0; level -= 1) {
1691
+ const n = 2 ** level;
1692
+ let tileX = Math.floor(u * n);
1693
+ let tileY = Math.floor(v * n);
1694
+ if (tileX < 0) tileX = 0;
1695
+ else if (tileX > n - 1) tileX = n - 1;
1696
+ if (tileY < 0) tileY = 0;
1697
+ else if (tileY > n - 1) tileY = n - 1;
1698
+ const leafIndex = lookupSpatialIndexRaw(index, face, level, tileX, tileY);
1699
+ if (leafIndex !== U32_EMPTY) {
1700
+ const tileSize = sphereTileArcLength(config.radius, n);
1701
+ return {
1702
+ found: true,
1703
+ leafIndex,
1704
+ space: face,
1705
+ level,
1706
+ tileX,
1707
+ tileY,
1708
+ tileSize,
1709
+ localU: clamp01(u * n - tileX),
1710
+ localV: clamp01(v * n - tileY)
1711
+ };
1712
+ }
1713
+ }
1714
+ return MISSED_LOOKUP;
1715
+ }
1716
+ function lookupTileForDirection(index, config, dx, dy, dz, dirScratch, uvScratch) {
1717
+ if (config.projection !== "cubeSphere") return MISSED_LOOKUP;
1718
+ const len = Math.hypot(dx, dy, dz);
1719
+ if (len === 0) return MISSED_LOOKUP;
1720
+ dirScratch[0] = dx / len;
1721
+ dirScratch[1] = dy / len;
1722
+ dirScratch[2] = dz / len;
1723
+ const face = directionToFace(dirScratch);
1724
+ directionToFaceUV(face, dirScratch, uvScratch);
1725
+ return lookupTileByFaceUV(index, config, face, uvScratch[0], uvScratch[1]);
1726
+ }
1727
+
1728
+ function createTerrainSnapshotState(maxNodes, totalElements) {
1729
+ return {
1730
+ frontElevation: new Float32Array(totalElements),
1731
+ backElevation: new Float32Array(totalElements),
1732
+ frontIndex: createSpatialIndex(maxNodes),
1733
+ backIndex: createSpatialIndex(maxNodes),
1734
+ frontTileBounds: new Float32Array(maxNodes * 2),
1735
+ backTileBounds: new Float32Array(maxNodes * 2),
1736
+ frontLeafCount: 0,
1737
+ globalRange: null,
1738
+ hasSnapshot: false,
1739
+ readbackPending: false,
1740
+ generation: 0,
1741
+ lastScheduledStampGen: -1
1742
+ };
1743
+ }
1390
1744
  function cloneSpatialIndex(target, source) {
1391
1745
  if (target.size !== source.size) {
1392
1746
  throw new Error(
@@ -1402,218 +1756,278 @@ function cloneSpatialIndex(target, source) {
1402
1756
  target.keysY.set(source.keysY);
1403
1757
  target.values.set(source.values);
1404
1758
  }
1405
- function tileLocalToFieldUV(localCoord, innerSegments) {
1406
- const edge = innerSegments + 3;
1407
- return (localCoord * innerSegments + 1.5) / edge;
1759
+ function triggerSnapshotReadback(state, renderer, attribute, spatialIndex, boundsAttribute, captured) {
1760
+ if (state.readbackPending) return;
1761
+ const withReadback = renderer;
1762
+ if (!withReadback.getArrayBufferAsync) return;
1763
+ if (spatialIndex.stampGen === state.lastScheduledStampGen) return;
1764
+ cloneSpatialIndex(state.backIndex, spatialIndex);
1765
+ state.lastScheduledStampGen = spatialIndex.stampGen;
1766
+ const { activeLeafCount, totalElements, elevationScale, originY } = captured;
1767
+ state.readbackPending = true;
1768
+ const elevationPromise = withReadback.getArrayBufferAsync(attribute);
1769
+ const boundsPromise = boundsAttribute ? withReadback.getArrayBufferAsync(boundsAttribute) : null;
1770
+ const onComplete = (elevResult, boundsResult) => {
1771
+ const data = new Float32Array(elevResult);
1772
+ state.backElevation.fill(0);
1773
+ state.backElevation.set(data.subarray(0, totalElements));
1774
+ let boundsValid = activeLeafCount === 0;
1775
+ if (boundsResult) {
1776
+ const rawBounds = new Float32Array(boundsResult);
1777
+ state.backTileBounds.fill(0);
1778
+ state.backTileBounds.set(rawBounds.subarray(0, activeLeafCount * 2));
1779
+ for (let i = 0; i < activeLeafCount; i += 1) {
1780
+ if ((rawBounds[i * 2 + 1] ?? 0) !== 0) {
1781
+ boundsValid = true;
1782
+ break;
1783
+ }
1784
+ }
1785
+ }
1786
+ const oldFrontElevation = state.frontElevation;
1787
+ const oldFrontIndex = state.frontIndex;
1788
+ state.frontElevation = state.backElevation;
1789
+ state.frontIndex = state.backIndex;
1790
+ state.frontLeafCount = activeLeafCount;
1791
+ state.backElevation = oldFrontElevation;
1792
+ state.backIndex = oldFrontIndex;
1793
+ if (boundsResult && boundsValid) {
1794
+ const oldFrontBounds = state.frontTileBounds;
1795
+ state.frontTileBounds = state.backTileBounds;
1796
+ state.backTileBounds = oldFrontBounds;
1797
+ }
1798
+ if (boundsResult && boundsValid && activeLeafCount > 0) {
1799
+ let gMin = Infinity;
1800
+ let gMax = -Infinity;
1801
+ for (let i = 0; i < activeLeafCount; i++) {
1802
+ const rawMin = state.frontTileBounds[i * 2];
1803
+ const rawMax = state.frontTileBounds[i * 2 + 1];
1804
+ const a = originY + rawMin * elevationScale;
1805
+ const b = originY + rawMax * elevationScale;
1806
+ gMin = Math.min(gMin, a, b);
1807
+ gMax = Math.max(gMax, a, b);
1808
+ }
1809
+ state.globalRange = { min: gMin, max: gMax };
1810
+ }
1811
+ state.hasSnapshot = true;
1812
+ state.generation += 1;
1813
+ };
1814
+ if (boundsPromise) {
1815
+ Promise.all([elevationPromise, boundsPromise]).then(([elev, bounds]) => onComplete(elev, bounds)).finally(() => {
1816
+ state.readbackPending = false;
1817
+ });
1818
+ } else {
1819
+ elevationPromise.then((elev) => onComplete(elev, null)).finally(() => {
1820
+ state.readbackPending = false;
1821
+ });
1822
+ }
1408
1823
  }
1824
+
1409
1825
  function createCpuTerrainCache(maxNodes, initialConfig) {
1410
1826
  let config = initialConfig;
1411
- let edgeVertexCount = config.innerTileSegments + 3;
1412
- let verticesPerNode = edgeVertexCount * edgeVertexCount;
1413
- let totalElements = maxNodes * verticesPerNode;
1414
- let frontElevation = new Float32Array(totalElements);
1415
- let backElevation = new Float32Array(totalElements);
1416
- let frontIndex = createSpatialIndex(maxNodes);
1417
- let backIndex = createSpatialIndex(maxNodes);
1418
- let frontTileBounds = new Float32Array(maxNodes * 2);
1419
- let backTileBounds = new Float32Array(maxNodes * 2);
1420
- let frontLeafCount = 0;
1421
- let globalRange = null;
1422
- let hasSnapshot = false;
1423
- let readbackPending = false;
1424
- let generationCount = 0;
1425
- let lastScheduledStampGen = -1;
1426
- const readHeight = (leafIndex, ix, iy) => {
1427
- const base = leafIndex * verticesPerNode;
1428
- return frontElevation[base + iy * edgeVertexCount + ix] ?? 0;
1827
+ const shape = {
1828
+ edgeVertexCount: config.innerTileSegments + 3,
1829
+ verticesPerNode: 0
1429
1830
  };
1430
- const sampleGridBilinear = (leafIndex, gx, gy) => {
1431
- const max = edgeVertexCount - 1;
1432
- const x = Math.max(0, Math.min(max, gx));
1433
- const y = Math.max(0, Math.min(max, gy));
1434
- const x0 = Math.floor(x);
1435
- const y0 = Math.floor(y);
1436
- const x1 = Math.min(max, x0 + 1);
1437
- const y1 = Math.min(max, y0 + 1);
1438
- const tx = x - x0;
1439
- const ty = y - y0;
1440
- const h00 = readHeight(leafIndex, x0, y0);
1441
- const h10 = readHeight(leafIndex, x1, y0);
1442
- const h01 = readHeight(leafIndex, x0, y1);
1443
- const h11 = readHeight(leafIndex, x1, y1);
1444
- const hx0 = h00 + (h10 - h00) * tx;
1445
- const hx1 = h01 + (h11 - h01) * tx;
1446
- return hx0 + (hx1 - hx0) * ty;
1831
+ shape.verticesPerNode = shape.edgeVertexCount * shape.edgeVertexCount;
1832
+ let totalElements = maxNodes * shape.verticesPerNode;
1833
+ const state = createTerrainSnapshotState(
1834
+ maxNodes,
1835
+ totalElements
1836
+ );
1837
+ const dirScratch = [0, 0, 0];
1838
+ const uvScratch = [0, 0];
1839
+ const llScratch = [0, 0, 0];
1840
+ const gridScratch = { gx: 0, gy: 0 };
1841
+ const gradientScratch = { dhdu: 0, dhdv: 0 };
1842
+ const gridCoordsFromLookup = (lookup) => {
1843
+ const fieldU = tileLocalToFieldUVNumber(lookup.localU, config.innerTileSegments);
1844
+ const fieldV = tileLocalToFieldUVNumber(lookup.localV, config.innerTileSegments);
1845
+ gridScratch.gx = fieldU * (shape.edgeVertexCount - 1);
1846
+ gridScratch.gy = fieldV * (shape.edgeVertexCount - 1);
1847
+ return gridScratch;
1848
+ };
1849
+ const rawHeightFromLookup = (lookup) => {
1850
+ const g = gridCoordsFromLookup(lookup);
1851
+ return sampleGridBilinear(state.frontElevation, shape, lookup.leafIndex, g.gx, g.gy);
1447
1852
  };
1448
1853
  const computeNormal = (leafIndex, gx, gy, tileSize) => {
1449
- const hLeft = sampleGridBilinear(leafIndex, gx - 1, gy);
1450
- const hRight = sampleGridBilinear(leafIndex, gx + 1, gy);
1451
- const hUp = sampleGridBilinear(leafIndex, gx, gy - 1);
1452
- const hDown = sampleGridBilinear(leafIndex, gx, gy + 1);
1453
1854
  const stepWorld = tileSize / config.innerTileSegments;
1454
- const inv2Step = 0.5 / stepWorld;
1455
- const dhdx = (hRight - hLeft) * config.elevationScale * inv2Step;
1456
- const dhdz = (hDown - hUp) * config.elevationScale * inv2Step;
1457
- return new Vector3(-dhdx, 1, -dhdz).normalize();
1855
+ const { dhdu, dhdv } = elevationGradientAt(
1856
+ state.frontElevation,
1857
+ shape,
1858
+ leafIndex,
1859
+ gx,
1860
+ gy,
1861
+ stepWorld,
1862
+ config.elevationScale,
1863
+ gradientScratch
1864
+ );
1865
+ return new Vector3(-dhdu, 1, -dhdv).normalize();
1458
1866
  };
1459
- const lookupTile = (worldX, worldZ) => {
1460
- const halfRoot = config.rootSize * 0.5;
1461
- for (let level = config.maxLevel; level >= 0; level -= 1) {
1462
- const scale = 2 ** level;
1463
- const tileSize = config.rootSize / scale;
1464
- const tileX = Math.floor((worldX - config.originX + halfRoot) / tileSize);
1465
- const tileY = Math.floor((worldZ - config.originZ + halfRoot) / tileSize);
1466
- const leafIndex = lookupSpatialIndexRaw(
1467
- frontIndex,
1468
- 0,
1469
- level,
1470
- tileX,
1471
- tileY
1472
- );
1473
- if (leafIndex !== U32_EMPTY) {
1474
- const tileMinX = config.originX + tileX * tileSize - halfRoot;
1475
- const tileMinZ = config.originZ + tileY * tileSize - halfRoot;
1476
- return {
1477
- found: true,
1478
- leafIndex,
1479
- level,
1480
- tileX,
1481
- tileY,
1482
- tileSize,
1483
- localU: (worldX - tileMinX) / tileSize,
1484
- localV: (worldZ - tileMinZ) / tileSize
1485
- };
1486
- }
1487
- }
1488
- return {
1489
- found: false,
1490
- leafIndex: -1,
1491
- level: -1,
1492
- tileX: -1,
1493
- tileY: -1,
1494
- tileSize: 0,
1495
- localU: 0,
1496
- localV: 0
1497
- };
1867
+ const computeSphereNormal = (leafIndex, gx, gy, tileSize, face, dirX, dirY, dirZ) => {
1868
+ const stepWorld = tileSize / config.innerTileSegments;
1869
+ const { dhdu, dhdv } = elevationGradientAt(
1870
+ state.frontElevation,
1871
+ shape,
1872
+ leafIndex,
1873
+ gx,
1874
+ gy,
1875
+ stepWorld,
1876
+ config.elevationScale,
1877
+ gradientScratch
1878
+ );
1879
+ const f = CUBE_FACES[face];
1880
+ const dDotR = dirX * f.right[0] + dirY * f.right[1] + dirZ * f.right[2];
1881
+ let tux = f.right[0] - dirX * dDotR;
1882
+ let tuy = f.right[1] - dirY * dDotR;
1883
+ let tuz = f.right[2] - dirZ * dDotR;
1884
+ const tuLen = Math.hypot(tux, tuy, tuz) || 1;
1885
+ tux /= tuLen;
1886
+ tuy /= tuLen;
1887
+ tuz /= tuLen;
1888
+ const dDotU = dirX * f.up[0] + dirY * f.up[1] + dirZ * f.up[2];
1889
+ let tvx = f.up[0] - dirX * dDotU;
1890
+ let tvy = f.up[1] - dirY * dDotU;
1891
+ let tvz = f.up[2] - dirZ * dDotU;
1892
+ const tvLen = Math.hypot(tvx, tvy, tvz) || 1;
1893
+ tvx /= tvLen;
1894
+ tvy /= tvLen;
1895
+ tvz /= tvLen;
1896
+ const nx = -dhdu;
1897
+ const ny = 1;
1898
+ const nz = -dhdv;
1899
+ return new Vector3(
1900
+ tux * nx + dirX * ny + tvx * nz,
1901
+ tuy * nx + dirY * ny + tvy * nz,
1902
+ tuz * nx + dirZ * ny + tvz * nz
1903
+ ).normalize();
1498
1904
  };
1499
1905
  const sampleFromLookup = (lookup) => {
1500
- const fieldU = tileLocalToFieldUV(lookup.localU, config.innerTileSegments);
1501
- const fieldV = tileLocalToFieldUV(lookup.localV, config.innerTileSegments);
1502
- const gx = fieldU * (edgeVertexCount - 1);
1503
- const gy = fieldV * (edgeVertexCount - 1);
1504
- const height = sampleGridBilinear(lookup.leafIndex, gx, gy);
1906
+ const height = rawHeightFromLookup(lookup);
1505
1907
  const scaledHeight = config.originY + height * config.elevationScale;
1506
- const normal = computeNormal(lookup.leafIndex, gx, gy, lookup.tileSize);
1908
+ const normal = computeNormal(
1909
+ lookup.leafIndex,
1910
+ gridScratch.gx,
1911
+ gridScratch.gy,
1912
+ lookup.tileSize
1913
+ );
1507
1914
  return { elevation: scaledHeight, normal, valid: true };
1508
1915
  };
1509
- const sampleElevationFromLookup = (lookup) => {
1510
- const fieldU = tileLocalToFieldUV(lookup.localU, config.innerTileSegments);
1511
- const fieldV = tileLocalToFieldUV(lookup.localV, config.innerTileSegments);
1512
- const gx = fieldU * (edgeVertexCount - 1);
1513
- const gy = fieldV * (edgeVertexCount - 1);
1514
- const height = sampleGridBilinear(lookup.leafIndex, gx, gy);
1515
- const scaledHeight = config.originY + height * config.elevationScale;
1516
- return { elevation: scaledHeight, valid: true };
1517
- };
1518
1916
  const sampleTerrain = (worldX, worldZ) => {
1519
- if (!hasSnapshot) {
1917
+ if (!state.hasSnapshot) {
1520
1918
  return { elevation: 0, normal: new Vector3(0, 1, 0), valid: false };
1521
1919
  }
1522
- const lookup = lookupTile(worldX, worldZ);
1920
+ const lookup = lookupTile(state.frontIndex, config, worldX, worldZ);
1523
1921
  if (!lookup.found) {
1524
1922
  return { elevation: 0, normal: new Vector3(0, 1, 0), valid: false };
1525
1923
  }
1526
1924
  return sampleFromLookup(lookup);
1527
1925
  };
1528
1926
  const getElevation = (worldX, worldZ) => {
1529
- if (!hasSnapshot) {
1927
+ if (!state.hasSnapshot) {
1530
1928
  return { elevation: 0, valid: false };
1531
1929
  }
1532
- const lookup = lookupTile(worldX, worldZ);
1930
+ const lookup = lookupTile(state.frontIndex, config, worldX, worldZ);
1533
1931
  if (!lookup.found) {
1534
1932
  return { elevation: 0, valid: false };
1535
1933
  }
1536
- return sampleElevationFromLookup(lookup);
1934
+ const height = rawHeightFromLookup(lookup);
1935
+ return {
1936
+ elevation: config.originY + height * config.elevationScale,
1937
+ valid: true
1938
+ };
1939
+ };
1940
+ const invalidSurfaceSample = (dx, dy, dz) => ({
1941
+ position: new Vector3(),
1942
+ normal: new Vector3(0, 1, 0),
1943
+ direction: new Vector3(dx, dy, dz),
1944
+ elevation: 0,
1945
+ valid: false
1946
+ });
1947
+ const lookupDirection = (dx, dy, dz) => lookupTileForDirection(state.frontIndex, config, dx, dy, dz, dirScratch, uvScratch);
1948
+ const sampleSurfaceByDirection = (dx, dy, dz) => {
1949
+ if (!state.hasSnapshot || config.projection !== "cubeSphere") {
1950
+ return invalidSurfaceSample(dx, dy, dz);
1951
+ }
1952
+ const len = Math.hypot(dx, dy, dz);
1953
+ if (len === 0) return invalidSurfaceSample(0, 0, 0);
1954
+ const nx = dx / len;
1955
+ const ny = dy / len;
1956
+ const nz = dz / len;
1957
+ const lookup = lookupDirection(nx, ny, nz);
1958
+ if (!lookup.found) return invalidSurfaceSample(nx, ny, nz);
1959
+ const height = rawHeightFromLookup(lookup);
1960
+ const elevation = height * config.elevationScale;
1961
+ const r = config.radius + elevation;
1962
+ const position = new Vector3(
1963
+ config.originX + nx * r,
1964
+ config.originY + ny * r,
1965
+ config.originZ + nz * r
1966
+ );
1967
+ const normal = computeSphereNormal(
1968
+ lookup.leafIndex,
1969
+ gridScratch.gx,
1970
+ gridScratch.gy,
1971
+ lookup.tileSize,
1972
+ lookup.space,
1973
+ nx,
1974
+ ny,
1975
+ nz
1976
+ );
1977
+ return {
1978
+ position,
1979
+ normal,
1980
+ direction: new Vector3(nx, ny, nz),
1981
+ elevation,
1982
+ valid: true
1983
+ };
1984
+ };
1985
+ const tileFromLookup = (lookup) => {
1986
+ if (!lookup.found) return null;
1987
+ return {
1988
+ space: lookup.space,
1989
+ level: lookup.level,
1990
+ x: lookup.tileX,
1991
+ y: lookup.tileY,
1992
+ index: lookup.leafIndex
1993
+ };
1994
+ };
1995
+ const tileBoundsFromLookup = (lookup, elevationBase) => {
1996
+ if (!lookup.found || lookup.leafIndex >= state.frontLeafCount) return null;
1997
+ const rawMin = state.frontTileBounds[lookup.leafIndex * 2];
1998
+ const rawMax = state.frontTileBounds[lookup.leafIndex * 2 + 1];
1999
+ const a = elevationBase + rawMin * config.elevationScale;
2000
+ const b = elevationBase + rawMax * config.elevationScale;
2001
+ return {
2002
+ space: lookup.space,
2003
+ level: lookup.level,
2004
+ x: lookup.tileX,
2005
+ y: lookup.tileY,
2006
+ index: lookup.leafIndex,
2007
+ minElevation: Math.min(a, b),
2008
+ maxElevation: Math.max(a, b)
2009
+ };
1537
2010
  };
1538
2011
  const api = {
1539
2012
  get generation() {
1540
- return generationCount;
2013
+ return state.generation;
1541
2014
  },
1542
2015
  get ready() {
1543
- return hasSnapshot;
2016
+ return state.hasSnapshot;
1544
2017
  },
1545
2018
  updateConfig(nextConfig) {
1546
2019
  config = nextConfig;
1547
- edgeVertexCount = config.innerTileSegments + 3;
1548
- verticesPerNode = edgeVertexCount * edgeVertexCount;
1549
- totalElements = maxNodes * verticesPerNode;
2020
+ shape.edgeVertexCount = config.innerTileSegments + 3;
2021
+ shape.verticesPerNode = shape.edgeVertexCount * shape.edgeVertexCount;
2022
+ totalElements = maxNodes * shape.verticesPerNode;
1550
2023
  },
1551
2024
  triggerReadback(renderer, attribute, spatialIndex, boundsAttribute, activeLeafCount) {
1552
- if (readbackPending) return;
1553
- const withReadback = renderer;
1554
- if (!withReadback.getArrayBufferAsync) return;
1555
- if (spatialIndex.stampGen === lastScheduledStampGen) return;
1556
- cloneSpatialIndex(backIndex, spatialIndex);
1557
- lastScheduledStampGen = spatialIndex.stampGen;
1558
- const capturedLeafCount = activeLeafCount ?? 0;
1559
- const capturedScale = config.elevationScale;
1560
- const capturedOriginY = config.originY;
1561
- readbackPending = true;
1562
- const elevationPromise = withReadback.getArrayBufferAsync(attribute);
1563
- const boundsPromise = boundsAttribute ? withReadback.getArrayBufferAsync(boundsAttribute) : null;
1564
- const onComplete = (elevResult, boundsResult) => {
1565
- const data = new Float32Array(elevResult);
1566
- backElevation.fill(0);
1567
- backElevation.set(data.subarray(0, totalElements));
1568
- let boundsValid = capturedLeafCount === 0;
1569
- if (boundsResult) {
1570
- const rawBounds = new Float32Array(boundsResult);
1571
- backTileBounds.fill(0);
1572
- backTileBounds.set(rawBounds.subarray(0, capturedLeafCount * 2));
1573
- for (let i = 0; i < capturedLeafCount; i += 1) {
1574
- if ((rawBounds[i * 2 + 1] ?? 0) !== 0) {
1575
- boundsValid = true;
1576
- break;
1577
- }
1578
- }
1579
- }
1580
- const oldFrontElevation = frontElevation;
1581
- const oldFrontIndex = frontIndex;
1582
- frontElevation = backElevation;
1583
- frontIndex = backIndex;
1584
- frontLeafCount = capturedLeafCount;
1585
- backElevation = oldFrontElevation;
1586
- backIndex = oldFrontIndex;
1587
- if (boundsResult && boundsValid) {
1588
- const oldFrontBounds = frontTileBounds;
1589
- frontTileBounds = backTileBounds;
1590
- backTileBounds = oldFrontBounds;
1591
- }
1592
- if (boundsResult && boundsValid && capturedLeafCount > 0) {
1593
- let gMin = Infinity;
1594
- let gMax = -Infinity;
1595
- for (let i = 0; i < capturedLeafCount; i++) {
1596
- const rawMin = frontTileBounds[i * 2];
1597
- const rawMax = frontTileBounds[i * 2 + 1];
1598
- const a = capturedOriginY + rawMin * capturedScale;
1599
- const b = capturedOriginY + rawMax * capturedScale;
1600
- gMin = Math.min(gMin, a, b);
1601
- gMax = Math.max(gMax, a, b);
1602
- }
1603
- globalRange = { min: gMin, max: gMax };
1604
- }
1605
- hasSnapshot = true;
1606
- generationCount += 1;
1607
- };
1608
- if (boundsPromise) {
1609
- Promise.all([elevationPromise, boundsPromise]).then(([elev, bounds]) => onComplete(elev, bounds)).finally(() => {
1610
- readbackPending = false;
1611
- });
1612
- } else {
1613
- elevationPromise.then((elev) => onComplete(elev, null)).finally(() => {
1614
- readbackPending = false;
1615
- });
1616
- }
2025
+ triggerSnapshotReadback(state, renderer, attribute, spatialIndex, boundsAttribute, {
2026
+ activeLeafCount: activeLeafCount ?? 0,
2027
+ totalElements,
2028
+ elevationScale: config.elevationScale,
2029
+ originY: config.originY
2030
+ });
1617
2031
  },
1618
2032
  getElevation(worldX, worldZ) {
1619
2033
  const sample = getElevation(worldX, worldZ);
@@ -1623,43 +2037,26 @@ function createCpuTerrainCache(maxNodes, initialConfig) {
1623
2037
  return sampleTerrain(worldX, worldZ).normal;
1624
2038
  },
1625
2039
  getTile(worldX, worldZ) {
1626
- if (!hasSnapshot) return null;
1627
- const lookup = lookupTile(worldX, worldZ);
1628
- if (!lookup.found) return null;
1629
- return {
1630
- level: lookup.level,
1631
- x: lookup.tileX,
1632
- y: lookup.tileY,
1633
- index: lookup.leafIndex
1634
- };
2040
+ if (!state.hasSnapshot) return null;
2041
+ return tileFromLookup(lookupTile(state.frontIndex, config, worldX, worldZ));
1635
2042
  },
1636
2043
  getTileBounds(worldX, worldZ) {
1637
- if (!hasSnapshot) return null;
1638
- const lookup = lookupTile(worldX, worldZ);
1639
- if (!lookup.found || lookup.leafIndex >= frontLeafCount) return null;
1640
- const rawMin = frontTileBounds[lookup.leafIndex * 2];
1641
- const rawMax = frontTileBounds[lookup.leafIndex * 2 + 1];
1642
- const a = config.originY + rawMin * config.elevationScale;
1643
- const b = config.originY + rawMax * config.elevationScale;
1644
- return {
1645
- level: lookup.level,
1646
- x: lookup.tileX,
1647
- y: lookup.tileY,
1648
- index: lookup.leafIndex,
1649
- minElevation: Math.min(a, b),
1650
- maxElevation: Math.max(a, b)
1651
- };
2044
+ if (!state.hasSnapshot) return null;
2045
+ return tileBoundsFromLookup(
2046
+ lookupTile(state.frontIndex, config, worldX, worldZ),
2047
+ config.originY
2048
+ );
1652
2049
  },
1653
2050
  getGlobalElevationRange() {
1654
- return globalRange;
2051
+ return state.globalRange;
1655
2052
  },
1656
2053
  sampleTerrainBatch(positions) {
1657
2054
  const count = Math.floor(positions.length / 2);
1658
2055
  const elevations = new Float32Array(count);
1659
2056
  const normals = new Float32Array(count * 3);
1660
2057
  const valid = new Uint8Array(count);
1661
- if (!hasSnapshot) {
1662
- return { elevations, normals, valid, generation: generationCount };
2058
+ if (!state.hasSnapshot) {
2059
+ return { elevations, normals, valid, generation: state.generation };
1663
2060
  }
1664
2061
  let lastTile;
1665
2062
  for (let i = 0; i < count; i += 1) {
@@ -1670,6 +2067,7 @@ function createCpuTerrainCache(maxNodes, initialConfig) {
1670
2067
  lookup = {
1671
2068
  found: true,
1672
2069
  leafIndex: lastTile.leafIndex,
2070
+ space: 0,
1673
2071
  level: lastTile.level,
1674
2072
  tileX: lastTile.tileX,
1675
2073
  tileY: lastTile.tileY,
@@ -1678,7 +2076,7 @@ function createCpuTerrainCache(maxNodes, initialConfig) {
1678
2076
  localV: (worldZ - lastTile.tileMinZ) / lastTile.tileSize
1679
2077
  };
1680
2078
  } else {
1681
- lookup = lookupTile(worldX, worldZ);
2079
+ lookup = lookupTile(state.frontIndex, config, worldX, worldZ);
1682
2080
  if (lookup.found) {
1683
2081
  lastTile = {
1684
2082
  leafIndex: lookup.leafIndex,
@@ -1704,9 +2102,133 @@ function createCpuTerrainCache(maxNodes, initialConfig) {
1704
2102
  normals[i * 3 + 2] = sample.normal.z;
1705
2103
  valid[i] = 1;
1706
2104
  }
1707
- return { elevations, normals, valid, generation: generationCount };
2105
+ return { elevations, normals, valid, generation: state.generation };
2106
+ },
2107
+ sampleTerrain,
2108
+ // --- Cube-sphere queries ---
2109
+ sampleTerrainByDirection(direction) {
2110
+ return sampleSurfaceByDirection(direction.x, direction.y, direction.z);
2111
+ },
2112
+ sampleTerrainByPosition(position) {
2113
+ return sampleSurfaceByDirection(
2114
+ position.x - config.originX,
2115
+ position.y - config.originY,
2116
+ position.z - config.originZ
2117
+ );
2118
+ },
2119
+ sampleTerrainByLatLong(latitudeDeg, longitudeDeg) {
2120
+ latLongToDirection(latitudeDeg, longitudeDeg, llScratch);
2121
+ return sampleSurfaceByDirection(llScratch[0], llScratch[1], llScratch[2]);
1708
2122
  },
1709
- sampleTerrain
2123
+ getElevationByDirection(direction) {
2124
+ const sample = sampleSurfaceByDirection(direction.x, direction.y, direction.z);
2125
+ return sample.valid ? sample.elevation : null;
2126
+ },
2127
+ getElevationByPosition(position) {
2128
+ const sample = sampleSurfaceByDirection(
2129
+ position.x - config.originX,
2130
+ position.y - config.originY,
2131
+ position.z - config.originZ
2132
+ );
2133
+ return sample.valid ? sample.elevation : null;
2134
+ },
2135
+ getElevationByLatLong(latitudeDeg, longitudeDeg) {
2136
+ latLongToDirection(latitudeDeg, longitudeDeg, llScratch);
2137
+ const sample = sampleSurfaceByDirection(llScratch[0], llScratch[1], llScratch[2]);
2138
+ return sample.valid ? sample.elevation : null;
2139
+ },
2140
+ getNormalByDirection(direction) {
2141
+ const sample = sampleSurfaceByDirection(direction.x, direction.y, direction.z);
2142
+ return sample.valid ? sample.normal : null;
2143
+ },
2144
+ getNormalByPosition(position) {
2145
+ const sample = sampleSurfaceByDirection(
2146
+ position.x - config.originX,
2147
+ position.y - config.originY,
2148
+ position.z - config.originZ
2149
+ );
2150
+ return sample.valid ? sample.normal : null;
2151
+ },
2152
+ getNormalByLatLong(latitudeDeg, longitudeDeg) {
2153
+ latLongToDirection(latitudeDeg, longitudeDeg, llScratch);
2154
+ const sample = sampleSurfaceByDirection(llScratch[0], llScratch[1], llScratch[2]);
2155
+ return sample.valid ? sample.normal : null;
2156
+ },
2157
+ getTileByDirection(direction) {
2158
+ if (!state.hasSnapshot) return null;
2159
+ return tileFromLookup(lookupDirection(direction.x, direction.y, direction.z));
2160
+ },
2161
+ getTileByPosition(position) {
2162
+ if (!state.hasSnapshot) return null;
2163
+ return tileFromLookup(
2164
+ lookupDirection(
2165
+ position.x - config.originX,
2166
+ position.y - config.originY,
2167
+ position.z - config.originZ
2168
+ )
2169
+ );
2170
+ },
2171
+ getTileByLatLong(latitudeDeg, longitudeDeg) {
2172
+ if (!state.hasSnapshot) return null;
2173
+ latLongToDirection(latitudeDeg, longitudeDeg, llScratch);
2174
+ return tileFromLookup(lookupDirection(llScratch[0], llScratch[1], llScratch[2]));
2175
+ },
2176
+ getTileBoundsByDirection(direction) {
2177
+ if (!state.hasSnapshot) return null;
2178
+ return tileBoundsFromLookup(
2179
+ lookupDirection(direction.x, direction.y, direction.z),
2180
+ 0
2181
+ );
2182
+ },
2183
+ getTileBoundsByPosition(position) {
2184
+ if (!state.hasSnapshot) return null;
2185
+ return tileBoundsFromLookup(
2186
+ lookupDirection(
2187
+ position.x - config.originX,
2188
+ position.y - config.originY,
2189
+ position.z - config.originZ
2190
+ ),
2191
+ 0
2192
+ );
2193
+ },
2194
+ getTileBoundsByLatLong(latitudeDeg, longitudeDeg) {
2195
+ if (!state.hasSnapshot) return null;
2196
+ latLongToDirection(latitudeDeg, longitudeDeg, llScratch);
2197
+ return tileBoundsFromLookup(
2198
+ lookupDirection(llScratch[0], llScratch[1], llScratch[2]),
2199
+ 0
2200
+ );
2201
+ },
2202
+ sampleTerrainBatchByDirection(directions) {
2203
+ const count = Math.floor(directions.length / 3);
2204
+ const positions = new Float32Array(count * 3);
2205
+ const normals = new Float32Array(count * 3);
2206
+ const elevations = new Float32Array(count);
2207
+ const valid = new Uint8Array(count);
2208
+ if (!state.hasSnapshot || config.projection !== "cubeSphere") {
2209
+ return { positions, normals, elevations, valid, generation: state.generation };
2210
+ }
2211
+ for (let i = 0; i < count; i += 1) {
2212
+ const sample = sampleSurfaceByDirection(
2213
+ directions[i * 3] ?? 0,
2214
+ directions[i * 3 + 1] ?? 0,
2215
+ directions[i * 3 + 2] ?? 0
2216
+ );
2217
+ if (!sample.valid) {
2218
+ normals[i * 3 + 1] = 1;
2219
+ continue;
2220
+ }
2221
+ positions[i * 3] = sample.position.x;
2222
+ positions[i * 3 + 1] = sample.position.y;
2223
+ positions[i * 3 + 2] = sample.position.z;
2224
+ normals[i * 3] = sample.normal.x;
2225
+ normals[i * 3 + 1] = sample.normal.y;
2226
+ normals[i * 3 + 2] = sample.normal.z;
2227
+ elevations[i] = sample.elevation;
2228
+ valid[i] = 1;
2229
+ }
2230
+ return { positions, normals, elevations, valid, generation: state.generation };
2231
+ }
1710
2232
  };
1711
2233
  return api;
1712
2234
  }
@@ -1739,6 +2261,61 @@ function createTerrainQuery(cache) {
1739
2261
  }
1740
2262
  };
1741
2263
  }
2264
+ function createTerrainSphereQuery(cache) {
2265
+ return {
2266
+ get generation() {
2267
+ return cache.generation;
2268
+ },
2269
+ getElevationByDirection(direction) {
2270
+ return cache.getElevationByDirection(direction);
2271
+ },
2272
+ getElevationByPosition(position) {
2273
+ return cache.getElevationByPosition(position);
2274
+ },
2275
+ getElevationByLatLong(latitudeDeg, longitudeDeg) {
2276
+ return cache.getElevationByLatLong(latitudeDeg, longitudeDeg);
2277
+ },
2278
+ getNormalByDirection(direction) {
2279
+ return cache.getNormalByDirection(direction);
2280
+ },
2281
+ getNormalByPosition(position) {
2282
+ return cache.getNormalByPosition(position);
2283
+ },
2284
+ getNormalByLatLong(latitudeDeg, longitudeDeg) {
2285
+ return cache.getNormalByLatLong(latitudeDeg, longitudeDeg);
2286
+ },
2287
+ sampleTerrainByDirection(direction) {
2288
+ return cache.sampleTerrainByDirection(direction);
2289
+ },
2290
+ sampleTerrainByPosition(position) {
2291
+ return cache.sampleTerrainByPosition(position);
2292
+ },
2293
+ sampleTerrainByLatLong(latitudeDeg, longitudeDeg) {
2294
+ return cache.sampleTerrainByLatLong(latitudeDeg, longitudeDeg);
2295
+ },
2296
+ getTileByDirection(direction) {
2297
+ return cache.getTileByDirection(direction);
2298
+ },
2299
+ getTileByPosition(position) {
2300
+ return cache.getTileByPosition(position);
2301
+ },
2302
+ getTileByLatLong(latitudeDeg, longitudeDeg) {
2303
+ return cache.getTileByLatLong(latitudeDeg, longitudeDeg);
2304
+ },
2305
+ getTileBoundsByDirection(direction) {
2306
+ return cache.getTileBoundsByDirection(direction);
2307
+ },
2308
+ getTileBoundsByPosition(position) {
2309
+ return cache.getTileBoundsByPosition(position);
2310
+ },
2311
+ getTileBoundsByLatLong(latitudeDeg, longitudeDeg) {
2312
+ return cache.getTileBoundsByLatLong(latitudeDeg, longitudeDeg);
2313
+ },
2314
+ sampleTerrainBatchByDirection(directions) {
2315
+ return cache.sampleTerrainBatchByDirection(directions);
2316
+ }
2317
+ };
2318
+ }
1742
2319
 
1743
2320
  const WGSIZE = 64;
1744
2321
  function buildReductionKernel(elevationFieldNode, boundsNode, verticesPerNode) {
@@ -1808,8 +2385,11 @@ const terrainQueryTask = task((get, work) => {
1808
2385
  const rootSizeValue = get(rootSize);
1809
2386
  const originValue = get(origin);
1810
2387
  const elevationScaleValue = get(elevationScale);
2388
+ const radiusValue = get(radius);
2389
+ const topologyValue = get(topologyTask);
2390
+ const projectionValue = topologyValue.projection ?? "flat";
1811
2391
  return work((prev) => {
1812
- const shapeKey = `${maxNodesValue}:${innerTileSegmentsValue}`;
2392
+ const shapeKey = `${maxNodesValue}:${innerTileSegmentsValue}:${projectionValue}`;
1813
2393
  const configValues = {
1814
2394
  rootSize: rootSizeValue,
1815
2395
  originX: originValue.x,
@@ -1817,16 +2397,20 @@ const terrainQueryTask = task((get, work) => {
1817
2397
  originZ: originValue.z,
1818
2398
  innerTileSegments: innerTileSegmentsValue,
1819
2399
  elevationScale: elevationScaleValue,
1820
- maxLevel: maxLevelValue
2400
+ maxLevel: maxLevelValue,
2401
+ projection: projectionValue,
2402
+ radius: topologyValue.radius ?? radiusValue
1821
2403
  };
1822
2404
  let cache = prev?.cache;
1823
2405
  let query = prev?.query;
2406
+ let sphereQuery = prev?.sphereQuery ?? null;
1824
2407
  if (!cache || !query || prev?.shapeKey !== shapeKey) {
1825
2408
  cache = createCpuTerrainCache(maxNodesValue, configValues);
1826
2409
  query = createTerrainQuery(cache);
2410
+ sphereQuery = projectionValue === "cubeSphere" ? createTerrainSphereQuery(cache) : null;
1827
2411
  }
1828
2412
  cache.updateConfig(configValues);
1829
- return { cache, query, shapeKey };
2413
+ return { cache, query, sphereQuery, shapeKey };
1830
2414
  });
1831
2415
  }).displayName("terrainQueryTask");
1832
2416
  const terrainReadbackTask = task(
@@ -1849,38 +2433,44 @@ const terrainReadbackTask = task(
1849
2433
  }
1850
2434
  ).displayName("terrainReadbackTask").lane("gpu");
1851
2435
 
1852
- const surfaceTask = task((get, work) => {
1853
- const customSurface = get(surface);
2436
+ const topologyTask = task((get, work) => {
2437
+ const customTopology = get(topology);
1854
2438
  const rootSizeVal = get(rootSize);
1855
2439
  const originVal = get(origin);
1856
2440
  return work(() => {
1857
- if (customSurface) return customSurface;
1858
- return createFlatSurface({ rootSize: rootSizeVal, origin: originVal });
2441
+ if (customTopology) return customTopology;
2442
+ return createFlatTopology({ rootSize: rootSizeVal, origin: originVal });
1859
2443
  });
1860
- }).displayName("surfaceTask");
2444
+ }).displayName("topologyTask");
1861
2445
  const quadtreeConfigTask = task((get, work) => {
1862
- const surfaceVal = get(surfaceTask);
2446
+ const topologyVal = get(topologyTask);
1863
2447
  const maxNodesVal = get(maxNodes);
1864
2448
  const maxLevelVal = get(maxLevel);
1865
2449
  return work(() => {
1866
- const state = createState({ maxNodes: maxNodesVal, maxLevel: maxLevelVal }, surfaceVal);
2450
+ const state = createState({ maxNodes: maxNodesVal, maxLevel: maxLevelVal }, topologyVal);
1867
2451
  return {
1868
2452
  state,
1869
- surface: surfaceVal
2453
+ topology: topologyVal
1870
2454
  };
1871
2455
  });
1872
2456
  }).displayName("quadtreeConfigTask");
1873
2457
  const quadtreeUpdateTask = task((get, work) => {
1874
2458
  const quadtreeConfig = get(quadtreeConfigTask);
1875
2459
  const quadtreeUpdateConfig = get(quadtreeUpdate);
1876
- const { query: terrainQuery } = get(terrainQueryTask);
2460
+ const { query: terrainQuery, sphereQuery } = get(terrainQueryTask);
1877
2461
  let outLeaves = void 0;
2462
+ const cameraPosition = new Vector3();
1878
2463
  return work(() => {
1879
2464
  const cam = quadtreeUpdateConfig.cameraOrigin;
1880
- quadtreeUpdateConfig.elevationAtCameraXZ = terrainQuery.getElevation(cam.x, cam.z) ?? 0;
2465
+ if (sphereQuery) {
2466
+ cameraPosition.set(cam.x, cam.y, cam.z);
2467
+ quadtreeUpdateConfig.elevationAtCameraXZ = sphereQuery.getElevationByPosition(cameraPosition) ?? 0;
2468
+ } else {
2469
+ quadtreeUpdateConfig.elevationAtCameraXZ = terrainQuery.getElevation(cam.x, cam.z) ?? 0;
2470
+ }
1881
2471
  outLeaves = update(
1882
2472
  quadtreeConfig.state,
1883
- quadtreeConfig.surface,
2473
+ quadtreeConfig.topology,
1884
2474
  quadtreeUpdateConfig,
1885
2475
  outLeaves
1886
2476
  );
@@ -1902,7 +2492,7 @@ const leafGpuBufferTask = task((get, work) => {
1902
2492
  leafStorage.data[offset] = leafSet.level[i] ?? 0;
1903
2493
  leafStorage.data[offset + 1] = leafSet.x[i] ?? 0;
1904
2494
  leafStorage.data[offset + 2] = leafSet.y[i] ?? 0;
1905
- leafStorage.data[offset + 3] = 1;
2495
+ leafStorage.data[offset + 3] = leafSet.space[i] ?? 0;
1906
2496
  }
1907
2497
  leafStorage.attribute.needsUpdate = true;
1908
2498
  leafStorage.node.needsUpdate = true;
@@ -1944,12 +2534,14 @@ function createTerrainUniforms(params) {
1944
2534
  );
1945
2535
  const uSkirtScale = uniform(float(params.skirtScale)).setName(`uSkirtScale${suffix}`);
1946
2536
  const uElevationScale = uniform(float(params.elevationScale)).setName(`uElevationScale${suffix}`);
2537
+ const uRadius = uniform(float(params.radius)).setName(`uRadius${suffix}`);
1947
2538
  return {
1948
2539
  uRootOrigin,
1949
2540
  uRootSize,
1950
2541
  uInnerTileSegments,
1951
2542
  uSkirtScale,
1952
- uElevationScale
2543
+ uElevationScale,
2544
+ uRadius
1953
2545
  };
1954
2546
  }
1955
2547
 
@@ -1963,6 +2555,7 @@ const createUniformsTask = task((get, work) => {
1963
2555
  innerTileSegments: get(innerTileSegments),
1964
2556
  skirtScale: get(skirtScale),
1965
2557
  elevationScale: get(elevationScale),
2558
+ radius: get(radius),
1966
2559
  instanceId: get(instanceIdTask)
1967
2560
  };
1968
2561
  return work(() => createTerrainUniforms(uniformParams));
@@ -1974,6 +2567,7 @@ const updateUniformsTask = task((get, work) => {
1974
2567
  const innerTileSegmentsVal = get(innerTileSegments);
1975
2568
  const skirtScaleVal = get(skirtScale);
1976
2569
  const elevationScaleVal = get(elevationScale);
2570
+ const radiusVal = get(radius);
1977
2571
  return work(() => {
1978
2572
  terrainUniformsContext.uRootSize.value = rootSizeVal;
1979
2573
  terrainUniformsContext.uRootOrigin.value = scratchVector3.set(
@@ -1984,6 +2578,7 @@ const updateUniformsTask = task((get, work) => {
1984
2578
  terrainUniformsContext.uInnerTileSegments.value = innerTileSegmentsVal;
1985
2579
  terrainUniformsContext.uSkirtScale.value = skirtScaleVal;
1986
2580
  terrainUniformsContext.uElevationScale.value = elevationScaleVal;
2581
+ terrainUniformsContext.uRadius.value = radiusVal;
1987
2582
  return terrainUniformsContext;
1988
2583
  });
1989
2584
  }).displayName("updateUniformsTask");
@@ -2006,8 +2601,9 @@ const createElevationFieldContextTask = task((get, work) => {
2006
2601
  const tileNodesTask = task((get, work) => {
2007
2602
  const leafStorage = get(leafStorageTask);
2008
2603
  const uniforms = get(updateUniformsTask);
2604
+ const topology = get(topologyTask);
2009
2605
  return work(() => {
2010
- return createTileCompute(leafStorage, uniforms);
2606
+ return createTileCompute(leafStorage, uniforms, topology.projection ?? "flat");
2011
2607
  });
2012
2608
  }).displayName("tileNodesTask");
2013
2609
  const elevationFieldStageTask = task((get, work) => {
@@ -2104,33 +2700,14 @@ const terrainFieldStageTask = task((get, work) => {
2104
2700
  });
2105
2701
  }).displayName("terrainFieldStageTask");
2106
2702
 
2107
- const compileComputeTask = task((get, work) => {
2108
- const pipeline = get(terrainFieldStageTask);
2109
- const edgeVertexCount = get(innerTileSegments) + 3;
2110
- return work(
2111
- () => compileComputePipeline(pipeline, edgeVertexCount, {
2112
- preferSingleKernelWhenPossible: false
2113
- })
2114
- );
2115
- }).displayName("compileComputeTask");
2116
- const executeComputeTask = task(
2117
- (get, work, { resources }) => {
2118
- const { execute } = get(compileComputeTask);
2119
- const leafState = get(leafGpuBufferTask);
2120
- return work(
2121
- () => resources?.renderer ? execute(resources.renderer, leafState.count) : () => {
2122
- }
2123
- );
2124
- }
2125
- ).displayName("executeComputeTask").lane("gpu");
2703
+ const { compile: compileComputeTask, execute: executeComputeTask } = createComputePipelineTasks(terrainFieldStageTask);
2126
2704
  function createComputePipelineTasks(leafStageTask) {
2127
2705
  const compile = task((get, work) => {
2128
2706
  const pipeline = get(leafStageTask);
2129
2707
  const edgeVertexCount = get(innerTileSegments) + 3;
2130
2708
  return work(
2131
2709
  () => compileComputePipeline(pipeline, edgeVertexCount, {
2132
- preferSingleKernelWhenPossible: false
2133
- })
2710
+ })
2134
2711
  );
2135
2712
  }).displayName("compileComputeTask");
2136
2713
  const execute = task(
@@ -2271,6 +2848,38 @@ const createTileIndexFromWorldPosition = (spatialIndex, uniforms, maxLevel) => {
2271
2848
  return vec3(tileIndex.toFloat(), tileU, tileV);
2272
2849
  });
2273
2850
  };
2851
+ const createTileIndexFromDirection = (spatialIndex, maxLevel) => {
2852
+ const lookup = createGpuSpatialLookup(spatialIndex);
2853
+ const levelCount = Math.max(1, maxLevel + 1);
2854
+ return Fn(([direction]) => {
2855
+ const dir = vec3(direction).normalize().toVar();
2856
+ const face = cubeFaceFromDirection(dir).toVar();
2857
+ const basis = cubeFaceBasis(face);
2858
+ const faceUV = cubeFaceUVFromDirection(basis, dir).toVar();
2859
+ const u = faceUV.x.toVar();
2860
+ const v = faceUV.y.toVar();
2861
+ const tileIndex = int(-1).toVar();
2862
+ const tileU = float(0).toVar();
2863
+ const tileV = float(0).toVar();
2864
+ const i = int(0).toVar();
2865
+ Loop(levelCount, () => {
2866
+ const level = int(maxLevel).sub(i).toVar();
2867
+ const n = pow(float(2), level.toFloat()).toVar();
2868
+ const nInt = int(n).toVar();
2869
+ const tileX = u.mul(n).floor().toInt().max(int(0)).min(nInt.sub(int(1))).toVar();
2870
+ const tileY = v.mul(n).floor().toInt().max(int(0)).min(nInt.sub(int(1))).toVar();
2871
+ const maybeIndex = lookup(face, level, tileX, tileY).toVar();
2872
+ If(maybeIndex.greaterThanEqual(int(0)), () => {
2873
+ tileIndex.assign(maybeIndex);
2874
+ tileU.assign(u.mul(n).sub(tileX.toFloat()));
2875
+ tileV.assign(v.mul(n).sub(tileY.toFloat()));
2876
+ Break();
2877
+ });
2878
+ i.addAssign(1);
2879
+ });
2880
+ return vec3(tileIndex.toFloat(), tileU, tileV);
2881
+ });
2882
+ };
2274
2883
 
2275
2884
  const gpuSpatialIndexStorageTask = task((get, work) => {
2276
2885
  const maxNodesValue = get(maxNodes);
@@ -2286,43 +2895,44 @@ const gpuSpatialIndexUploadTask = task((get, work) => {
2286
2895
  });
2287
2896
  }).displayName("gpuSpatialIndexUploadTask");
2288
2897
 
2898
+ function packedSampleFromTileResult(params, tileResult) {
2899
+ const tileIndex = int(tileResult.x).toVar();
2900
+ const safeTileIndex = tileIndex.max(int(0)).toVar();
2901
+ const fieldU = tileLocalToFieldUV(
2902
+ tileResult.y,
2903
+ params.uniforms.uInnerTileSegments
2904
+ ).toVar();
2905
+ const fieldV = tileLocalToFieldUV(
2906
+ tileResult.z,
2907
+ params.uniforms.uInnerTileSegments
2908
+ ).toVar();
2909
+ const found = tileIndex.greaterThanEqual(int(0)).toVar();
2910
+ const sampled = sampleTerrainField(
2911
+ params.terrainFieldStorage,
2912
+ fieldU,
2913
+ fieldV,
2914
+ safeTileIndex
2915
+ ).toVar();
2916
+ const normal = unpackTangentNormal(sampled.g, sampled.b);
2917
+ const valid = found.select(float(1), float(0)).toVar();
2918
+ return vec4(sampled.r, normal.x, normal.y, normal.z).mul(valid);
2919
+ }
2289
2920
  function createTerrainSampleNode(params) {
2290
2921
  const tileLookup = createTileIndexFromWorldPosition(
2291
2922
  params.spatialIndex,
2292
2923
  params.uniforms,
2293
- maxLevel.get()
2924
+ params.maxLevel
2294
2925
  );
2295
2926
  return Fn(([worldX, worldZ]) => {
2296
2927
  const tileResult = tileLookup(worldX, worldZ).toVar();
2297
- const tileIndex = int(tileResult.x).toVar();
2298
- const safeTileIndex = tileIndex.max(int(0)).toVar();
2299
- const u = tileResult.y.toVar();
2300
- const v = tileResult.z.toVar();
2301
- const fieldU = tileLocalToFieldUV$1(
2302
- u,
2303
- params.uniforms.uInnerTileSegments
2304
- ).toVar();
2305
- const fieldV = tileLocalToFieldUV$1(
2306
- v,
2307
- params.uniforms.uInnerTileSegments
2308
- ).toVar();
2309
- const found = tileIndex.greaterThanEqual(int(0)).toVar();
2310
- const sampled = sampleTerrainField(
2311
- params.terrainFieldStorage,
2312
- fieldU,
2313
- fieldV,
2314
- safeTileIndex
2315
- ).toVar();
2316
- const nx = sampled.g.toVar();
2317
- const nz = sampled.b.toVar();
2318
- const ny = float(1).sub(nx.mul(nx)).sub(nz.mul(nz)).max(0).sqrt();
2319
- const valid = found.select(float(1), float(0)).toVar();
2320
- return vec4(
2321
- sampled.r.mul(valid),
2322
- nx.mul(valid),
2323
- ny.mul(valid),
2324
- nz.mul(valid)
2325
- );
2928
+ return packedSampleFromTileResult(params, tileResult);
2929
+ });
2930
+ }
2931
+ function createTerrainSampleNodeByDirection(params) {
2932
+ const tileLookup = createTileIndexFromDirection(params.spatialIndex, params.maxLevel);
2933
+ return Fn(([direction]) => {
2934
+ const tileResult = tileLookup(direction).toVar();
2935
+ return packedSampleFromTileResult(params, tileResult);
2326
2936
  });
2327
2937
  }
2328
2938
  function createTerrainSampler(params) {
@@ -2354,16 +2964,14 @@ function createTerrainSampler(params) {
2354
2964
  const sampleElevation = Fn(
2355
2965
  ([worldX, worldZ]) => terrainSampleAt(worldX, worldZ).x
2356
2966
  );
2357
- const sampleNormal = Fn(
2358
- ([worldX, worldZ]) => vec3(
2359
- terrainSampleAt(worldX, worldZ).y,
2360
- terrainSampleAt(worldX, worldZ).z,
2361
- terrainSampleAt(worldX, worldZ).w
2362
- )
2363
- );
2364
- const sampleValidity = Fn(
2365
- ([worldX, worldZ]) => terrainSampleAt(worldX, worldZ).y.abs().add(terrainSampleAt(worldX, worldZ).z.abs()).add(terrainSampleAt(worldX, worldZ).w.abs()).greaterThan(float(0)).select(float(1), float(0))
2366
- );
2967
+ const sampleNormal = Fn(([worldX, worldZ]) => {
2968
+ const sample = terrainSampleAt(worldX, worldZ).toVar();
2969
+ return vec3(sample.y, sample.z, sample.w);
2970
+ });
2971
+ const sampleValidity = Fn(([worldX, worldZ]) => {
2972
+ const sample = terrainSampleAt(worldX, worldZ).toVar();
2973
+ return sample.y.abs().add(sample.z.abs()).add(sample.w.abs()).greaterThan(float(0)).select(float(1), float(0));
2974
+ });
2367
2975
  const evaluateElevation = Fn(
2368
2976
  ([worldX, worldZ]) => evaluateElevationAt(worldX, worldZ)
2369
2977
  );
@@ -2390,7 +2998,7 @@ function createTerrainSampler(params) {
2390
2998
  }
2391
2999
  );
2392
3000
  const evaluateNormal = (worldX, worldZ, epsilon) => evaluateNormalNode(worldX, worldZ, epsilon ?? float(0.1));
2393
- return {
3001
+ const sampler = {
2394
3002
  sampleElevation,
2395
3003
  sampleNormal,
2396
3004
  sampleTerrain,
@@ -2398,6 +3006,26 @@ function createTerrainSampler(params) {
2398
3006
  evaluateElevation,
2399
3007
  evaluateNormal
2400
3008
  };
3009
+ if (params.projection === "cubeSphere") {
3010
+ const terrainSampleByDir = createTerrainSampleNodeByDirection(params);
3011
+ sampler.sampleTerrainByDirection = Fn(
3012
+ ([direction]) => terrainSampleByDir(direction)
3013
+ );
3014
+ sampler.sampleElevationByDirection = Fn(
3015
+ ([direction]) => terrainSampleByDir(direction).x
3016
+ );
3017
+ sampler.sampleValidityByDirection = Fn(([direction]) => {
3018
+ const sample = terrainSampleByDir(direction).toVar();
3019
+ return sample.y.abs().add(sample.z.abs()).add(sample.w.abs()).greaterThan(float(0)).select(float(1), float(0));
3020
+ });
3021
+ sampler.sampleNormalByDirection = Fn(([direction]) => {
3022
+ const dir = vec3(direction).normalize().toVar();
3023
+ const packed = terrainSampleByDir(direction).toVar();
3024
+ const basis = cubeFaceBasis(cubeFaceFromDirection(dir));
3025
+ return sphereTangentFrameNormal(dir, basis, vec3(packed.y, packed.z, packed.w));
3026
+ });
3027
+ }
3028
+ return sampler;
2401
3029
  }
2402
3030
 
2403
3031
  const createTerrainSamplerTask = task((get, work) => {
@@ -2405,12 +3033,16 @@ const createTerrainSamplerTask = task((get, work) => {
2405
3033
  const spatialIndex = get(gpuSpatialIndexStorageTask);
2406
3034
  const uniforms = get(updateUniformsTask);
2407
3035
  const elevationCallback = get(elevationFn);
3036
+ const maxLevelValue = get(maxLevel);
3037
+ const projection = get(topologyTask).projection ?? "flat";
2408
3038
  return work(
2409
3039
  () => createTerrainSampler({
2410
3040
  terrainFieldStorage,
2411
3041
  spatialIndex,
2412
3042
  uniforms,
2413
- elevationCallback
3043
+ elevationCallback,
3044
+ maxLevel: maxLevelValue,
3045
+ projection
2414
3046
  })
2415
3047
  );
2416
3048
  }).displayName("createTerrainSamplerTask");
@@ -2437,18 +3069,14 @@ const isSkirtUV = Fn(([segments]) => {
2437
3069
 
2438
3070
  function createTileBaseWorldPosition(leafStorage, terrainUniforms) {
2439
3071
  return Fn(() => {
2440
- const nodeIndex = int(instanceIndex);
2441
- const nodeOffset = nodeIndex.mul(int(4));
2442
- const nodeLevel = leafStorage.node.element(nodeOffset).toInt();
2443
- const nodeX = leafStorage.node.element(nodeOffset.add(int(1))).toFloat();
2444
- const nodeY = leafStorage.node.element(nodeOffset.add(int(2))).toFloat();
3072
+ const tile = decodeLeafTile(leafStorage, int(instanceIndex));
2445
3073
  const rootSize = terrainUniforms.uRootSize.toVar();
2446
3074
  const rootOrigin = terrainUniforms.uRootOrigin.toVar();
2447
3075
  const half = float(0.5);
2448
- const size = rootSize.div(pow(float(2), nodeLevel.toFloat()));
3076
+ const size = rootSize.div(pow(float(2), tile.level.toFloat()));
2449
3077
  const halfRoot = rootSize.mul(half);
2450
- const centerX = rootOrigin.x.add(nodeX.add(half).mul(size)).sub(halfRoot);
2451
- const centerZ = rootOrigin.z.add(nodeY.add(half).mul(size)).sub(halfRoot);
3078
+ const centerX = rootOrigin.x.add(tile.x.add(half).mul(size)).sub(halfRoot);
3079
+ const centerZ = rootOrigin.z.add(tile.y.add(half).mul(size)).sub(halfRoot);
2452
3080
  const clampedX = positionLocal.x.max(half.negate()).min(half);
2453
3081
  const clampedZ = positionLocal.z.max(half.negate()).min(half);
2454
3082
  const worldX = centerX.add(clampedX.mul(size));
@@ -2459,49 +3087,78 @@ function createTileBaseWorldPosition(leafStorage, terrainUniforms) {
2459
3087
  function createTileElevation(terrainUniforms, terrainFieldStorage) {
2460
3088
  if (!terrainFieldStorage) return float(0);
2461
3089
  const innerSegs = terrainUniforms.uInnerTileSegments;
2462
- const u = tileLocalToFieldUV$1(positionLocal.x.add(float(0.5)), innerSegs);
2463
- const v = tileLocalToFieldUV$1(positionLocal.z.add(float(0.5)), innerSegs);
2464
- return sampleTerrainFieldElevation(
2465
- terrainFieldStorage,
2466
- u,
2467
- v,
2468
- int(instanceIndex)
2469
- ).mul(terrainUniforms.uElevationScale);
2470
- }
2471
- function createNormalAssignment(terrainUniforms, terrainFieldStorage) {
3090
+ const u = tileLocalToFieldUV(positionLocal.x.add(float(0.5)), innerSegs);
3091
+ const v = tileLocalToFieldUV(positionLocal.z.add(float(0.5)), innerSegs);
3092
+ return sampleTerrainFieldElevation(terrainFieldStorage, u, v, int(instanceIndex)).mul(
3093
+ terrainUniforms.uElevationScale
3094
+ );
3095
+ }
3096
+ function createNormalAssignment(leafStorage, terrainUniforms, terrainFieldStorage, projection = "flat") {
2472
3097
  if (!terrainFieldStorage) return;
3098
+ normalLocal.assign(
3099
+ createTileLocalNormal(leafStorage, terrainUniforms, terrainFieldStorage, projection)
3100
+ );
3101
+ }
3102
+ function loadTangentNormal(terrainUniforms, terrainFieldStorage) {
2473
3103
  const nodeIndex = int(instanceIndex);
2474
3104
  const edgeVertexCount = int(terrainUniforms.uInnerTileSegments.add(3));
2475
3105
  const localVertexIndex = int(vertexIndex);
2476
3106
  const ix = localVertexIndex.mod(edgeVertexCount);
2477
3107
  const iy = localVertexIndex.div(edgeVertexCount);
2478
- const normalXZ = loadTerrainFieldNormal(
2479
- terrainFieldStorage,
2480
- ix,
2481
- iy,
2482
- nodeIndex
2483
- );
2484
- const nx = normalXZ.x;
2485
- const nz = normalXZ.y;
2486
- const nySq = float(1).sub(nx.mul(nx)).sub(nz.mul(nz)).max(float(0));
2487
- const ny = nySq.sqrt();
2488
- normalLocal.assign(vec3(nx, ny, nz));
2489
- }
2490
- function createTileWorldPosition(leafStorage, terrainUniforms, terrainFieldStorage) {
2491
- const baseWorldPosition = createTileBaseWorldPosition(
2492
- leafStorage,
2493
- terrainUniforms
2494
- );
3108
+ const normalXZ = loadTerrainFieldNormal(terrainFieldStorage, ix, iy, nodeIndex);
3109
+ const normal = unpackTangentNormal(normalXZ.x, normalXZ.y);
3110
+ return { ix, iy, normal };
3111
+ }
3112
+ function createTileLocalNormal(leafStorage, terrainUniforms, terrainFieldStorage, projection = "flat") {
3113
+ if (!terrainFieldStorage) return vec3(0, 1, 0);
3114
+ if (projection === "cubeSphere") {
3115
+ return Fn(() => {
3116
+ const { ix, iy, normal } = loadTangentNormal(terrainUniforms, terrainFieldStorage);
3117
+ const tile = decodeLeafTile(leafStorage, int(instanceIndex));
3118
+ const innerSeg = terrainUniforms.uInnerTileSegments.toVar().toFloat();
3119
+ const localU = ix.toFloat().sub(float(1)).div(innerSeg).max(float(0)).min(float(1));
3120
+ const localV = iy.toFloat().sub(float(1)).div(innerSeg).max(float(0)).min(float(1));
3121
+ const faceUV = faceUVFromTileLocal(tile, localU, localV);
3122
+ const basis = cubeFaceBasis(tile.face);
3123
+ const dir = cubeFaceDirection(basis, faceUV.x, faceUV.y);
3124
+ return sphereTangentFrameNormal(dir, basis, normal);
3125
+ })();
3126
+ }
3127
+ return Fn(() => {
3128
+ const { normal } = loadTangentNormal(terrainUniforms, terrainFieldStorage);
3129
+ return normal;
3130
+ })();
3131
+ }
3132
+ function createCubeSphereWorldPosition(leafStorage, terrainUniforms, terrainFieldStorage) {
3133
+ return Fn(() => {
3134
+ const tile = decodeLeafTile(leafStorage, int(instanceIndex));
3135
+ const half = float(0.5);
3136
+ const localU = positionLocal.x.max(half.negate()).min(half).add(half);
3137
+ const localV = positionLocal.z.max(half.negate()).min(half).add(half);
3138
+ const faceUV = faceUVFromTileLocal(tile, localU, localV);
3139
+ const basis = cubeFaceBasis(tile.face);
3140
+ const dir = cubeFaceDirection(basis, faceUV.x, faceUV.y);
3141
+ const yElevation = createTileElevation(terrainUniforms, terrainFieldStorage);
3142
+ const baseRadius = terrainUniforms.uRadius.toVar().add(yElevation);
3143
+ const skirtVertex = isSkirtVertex(terrainUniforms.uInnerTileSegments);
3144
+ const r = select(skirtVertex, baseRadius.sub(terrainUniforms.uSkirtScale.toVar()), baseRadius);
3145
+ createNormalAssignment(leafStorage, terrainUniforms, terrainFieldStorage, "cubeSphere");
3146
+ const origin = terrainUniforms.uRootOrigin.toVar();
3147
+ return origin.add(dir.mul(r));
3148
+ })();
3149
+ }
3150
+ function createTileWorldPosition(leafStorage, terrainUniforms, terrainFieldStorage, projection = "flat") {
3151
+ if (projection === "cubeSphere") {
3152
+ return createCubeSphereWorldPosition(leafStorage, terrainUniforms, terrainFieldStorage);
3153
+ }
3154
+ const baseWorldPosition = createTileBaseWorldPosition(leafStorage, terrainUniforms);
2495
3155
  return Fn(() => {
2496
3156
  const base = baseWorldPosition();
2497
- const yElevation = createTileElevation(
2498
- terrainUniforms,
2499
- terrainFieldStorage
2500
- );
3157
+ const yElevation = createTileElevation(terrainUniforms, terrainFieldStorage);
2501
3158
  const skirtVertex = isSkirtVertex(terrainUniforms.uInnerTileSegments);
2502
3159
  const skirtY = base.y.add(yElevation).sub(terrainUniforms.uSkirtScale.toVar());
2503
3160
  const worldY = select(skirtVertex, skirtY, base.y.add(yElevation));
2504
- createNormalAssignment(terrainUniforms, terrainFieldStorage);
3161
+ createNormalAssignment(leafStorage, terrainUniforms, terrainFieldStorage, "flat");
2505
3162
  return vec3(base.x, worldY, base.z);
2506
3163
  })();
2507
3164
  }
@@ -2510,11 +3167,13 @@ const positionNodeTask = task((get, work) => {
2510
3167
  const leafStorage = get(leafStorageTask);
2511
3168
  const terrainUniforms = get(updateUniformsTask);
2512
3169
  const terrainFieldStorage = get(createTerrainFieldTextureTask);
3170
+ const topology = get(topologyTask);
2513
3171
  return work(
2514
3172
  () => createTileWorldPosition(
2515
3173
  leafStorage,
2516
3174
  terrainUniforms,
2517
- terrainFieldStorage
3175
+ terrainFieldStorage,
3176
+ topology.projection ?? "flat"
2518
3177
  )
2519
3178
  );
2520
3179
  }).displayName("positionNodeTask");
@@ -2555,71 +3214,33 @@ function getTerrainBounds(config) {
2555
3214
  maxZ: config.originZ + halfRoot
2556
3215
  };
2557
3216
  }
2558
- function terrainSignedDistanceFromBounds(query, worldX, worldY, worldZ) {
2559
- const tileBounds = query.getTileBounds(worldX, worldZ);
2560
- if (tileBounds) {
2561
- if (worldY > tileBounds.maxElevation) {
2562
- return worldY - tileBounds.maxElevation;
2563
- }
2564
- if (worldY < tileBounds.minElevation) {
2565
- return worldY - tileBounds.minElevation;
3217
+ function terrainSignedDistance(query, worldX, worldY, worldZ, skipBoundsFastPath) {
3218
+ if (!skipBoundsFastPath) {
3219
+ const tileBounds = query.getTileBounds(worldX, worldZ);
3220
+ if (tileBounds) {
3221
+ if (worldY > tileBounds.maxElevation) {
3222
+ return worldY - tileBounds.maxElevation;
3223
+ }
3224
+ if (worldY < tileBounds.minElevation) {
3225
+ return worldY - tileBounds.minElevation;
3226
+ }
2566
3227
  }
2567
3228
  }
2568
3229
  const elevation = query.getElevation(worldX, worldZ);
2569
3230
  if (!Number.isFinite(elevation)) return void 0;
2570
3231
  return worldY - elevation;
2571
3232
  }
2572
- function terrainSignedDistancePrecise(query, worldX, worldY, worldZ) {
2573
- const elevation = query.getElevation(worldX, worldZ);
2574
- if (!Number.isFinite(elevation)) return void 0;
2575
- return worldY - elevation;
2576
- }
2577
- function cpuRaycast(query, ray, config, options) {
2578
- const bounds = getTerrainBounds(config);
2579
- const segment = intersectRayAabb(
2580
- ray,
2581
- bounds.minX,
2582
- config.minY,
2583
- bounds.minZ,
2584
- bounds.maxX,
2585
- config.maxY,
2586
- bounds.maxZ
2587
- );
2588
- if (!segment) return null;
2589
- const maxDistance = options?.maxDistance ?? Number.POSITIVE_INFINITY;
2590
- let startT = Math.max(0, segment.tMin);
2591
- const endT = Math.min(segment.tMax, maxDistance);
2592
- if (endT < startT) return null;
2593
- const maxSteps = Math.max(8, options?.maxSteps ?? 128);
2594
- const refinementSteps = Math.max(1, options?.refinementSteps ?? 8);
2595
- const point = new Vector3();
3233
+ function marchSignedDistance(ray, startT, endT, stepSignedDistanceAt, refineSignedDistanceAt, options, point) {
2596
3234
  let prevT = startT;
2597
3235
  ray.at(prevT, point);
2598
- let prevSignedDistance = terrainSignedDistanceFromBounds(
2599
- query,
2600
- point.x,
2601
- point.y,
2602
- point.z
2603
- );
3236
+ let prevSignedDistance = stepSignedDistanceAt(point.x, point.y, point.z);
2604
3237
  if (prevSignedDistance !== void 0 && prevSignedDistance <= 0) {
2605
- const sample = query.sampleTerrain(point.x, point.z);
2606
- if (!sample.valid) return null;
2607
- point.y = sample.elevation;
2608
- return {
2609
- position: point.clone(),
2610
- normal: sample.normal.clone(),
2611
- distance: ray.origin.distanceTo(point)
2612
- };
3238
+ return startT;
2613
3239
  }
2614
- for (let i = 1; i <= maxSteps; i += 1) {
2615
- const t = startT + (endT - startT) * i / maxSteps;
3240
+ for (let i = 1; i <= options.maxSteps; i += 1) {
3241
+ const t = startT + (endT - startT) * i / options.maxSteps;
2616
3242
  ray.at(t, point);
2617
- const signedDistance = terrainSignedDistanceFromBounds(
2618
- query,
2619
- point.x,
2620
- point.y,
2621
- point.z
2622
- );
3243
+ const signedDistance = stepSignedDistanceAt(point.x, point.y, point.z);
2623
3244
  if (signedDistance === void 0) {
2624
3245
  prevSignedDistance = void 0;
2625
3246
  prevT = t;
@@ -2628,15 +3249,10 @@ function cpuRaycast(query, ray, config, options) {
2628
3249
  if (prevSignedDistance !== void 0 && prevSignedDistance > 0 && signedDistance <= 0) {
2629
3250
  let lo = prevT;
2630
3251
  let hi = t;
2631
- for (let r = 0; r < refinementSteps; r += 1) {
3252
+ for (let r = 0; r < options.refinementSteps; r += 1) {
2632
3253
  const mid = (lo + hi) * 0.5;
2633
3254
  ray.at(mid, point);
2634
- const midDistance = terrainSignedDistancePrecise(
2635
- query,
2636
- point.x,
2637
- point.y,
2638
- point.z
2639
- );
3255
+ const midDistance = refineSignedDistanceAt(point.x, point.y, point.z);
2640
3256
  if (midDistance === void 0) {
2641
3257
  lo = mid;
2642
3258
  continue;
@@ -2644,22 +3260,53 @@ function cpuRaycast(query, ray, config, options) {
2644
3260
  if (midDistance > 0) lo = mid;
2645
3261
  else hi = mid;
2646
3262
  }
2647
- const hitT = hi;
2648
- ray.at(hitT, point);
2649
- const sample = query.sampleTerrain(point.x, point.z);
2650
- if (!sample.valid) return null;
2651
- point.y = sample.elevation;
2652
- return {
2653
- position: point.clone(),
2654
- normal: sample.normal.clone(),
2655
- distance: ray.origin.distanceTo(point)
2656
- };
3263
+ return hi;
2657
3264
  }
2658
3265
  prevSignedDistance = signedDistance;
2659
3266
  prevT = t;
2660
3267
  }
2661
3268
  return null;
2662
3269
  }
3270
+ function cpuRaycast(query, ray, config, options) {
3271
+ const bounds = getTerrainBounds(config);
3272
+ const segment = intersectRayAabb(
3273
+ ray,
3274
+ bounds.minX,
3275
+ config.minY,
3276
+ bounds.minZ,
3277
+ bounds.maxX,
3278
+ config.maxY,
3279
+ bounds.maxZ
3280
+ );
3281
+ if (!segment) return null;
3282
+ const maxDistance = options?.maxDistance ?? Number.POSITIVE_INFINITY;
3283
+ const startT = Math.max(0, segment.tMin);
3284
+ const endT = Math.min(segment.tMax, maxDistance);
3285
+ if (endT < startT) return null;
3286
+ const point = new Vector3();
3287
+ const hitT = marchSignedDistance(
3288
+ ray,
3289
+ startT,
3290
+ endT,
3291
+ (px, py, pz) => terrainSignedDistance(query, px, py, pz, false),
3292
+ (px, py, pz) => terrainSignedDistance(query, px, py, pz, true),
3293
+ {
3294
+ maxSteps: Math.max(8, options?.maxSteps ?? 128),
3295
+ refinementSteps: Math.max(1, options?.refinementSteps ?? 8)
3296
+ },
3297
+ point
3298
+ );
3299
+ if (hitT === null) return null;
3300
+ ray.at(hitT, point);
3301
+ const sample = query.sampleTerrain(point.x, point.z);
3302
+ if (!sample.valid) return null;
3303
+ point.y = sample.elevation;
3304
+ return {
3305
+ position: point.clone(),
3306
+ normal: sample.normal.clone(),
3307
+ distance: ray.origin.distanceTo(point)
3308
+ };
3309
+ }
2663
3310
  function cpuRaycastBoundsOnly(ray, config, options) {
2664
3311
  const bounds = getTerrainBounds(config);
2665
3312
  const planeY = (config.minY + config.maxY) * 0.5;
@@ -2680,12 +3327,102 @@ function cpuRaycastBoundsOnly(ray, config, options) {
2680
3327
  distance: ray.origin.distanceTo(point)
2681
3328
  };
2682
3329
  }
3330
+ function intersectRaySphere(ray, cx, cy, cz, radius) {
3331
+ const ox = ray.origin.x - cx;
3332
+ const oy = ray.origin.y - cy;
3333
+ const oz = ray.origin.z - cz;
3334
+ const dx = ray.direction.x;
3335
+ const dy = ray.direction.y;
3336
+ const dz = ray.direction.z;
3337
+ const a = dx * dx + dy * dy + dz * dz;
3338
+ const b = 2 * (ox * dx + oy * dy + oz * dz);
3339
+ const c = ox * ox + oy * oy + oz * oz - radius * radius;
3340
+ const disc = b * b - 4 * a * c;
3341
+ if (disc < 0) return null;
3342
+ const sqrtDisc = Math.sqrt(disc);
3343
+ const inv2a = 1 / (2 * a);
3344
+ return { t0: (-b - sqrtDisc) * inv2a, t1: (-b + sqrtDisc) * inv2a };
3345
+ }
3346
+ function sphereSignedDistance(query, config, px, py, pz, scratchDir) {
3347
+ const cx = config.centerX ?? 0;
3348
+ const cy = config.centerY ?? 0;
3349
+ const cz = config.centerZ ?? 0;
3350
+ const radius = config.radius ?? 0;
3351
+ const dx = px - cx;
3352
+ const dy = py - cy;
3353
+ const dz = pz - cz;
3354
+ const dist = Math.hypot(dx, dy, dz);
3355
+ scratchDir.set(dx, dy, dz);
3356
+ const elevation = query.getElevationByDirection(scratchDir);
3357
+ if (elevation === null) return void 0;
3358
+ return dist - (radius + elevation);
3359
+ }
3360
+ function cubeSphereRaycast(query, ray, config, options) {
3361
+ const cx = config.centerX ?? 0;
3362
+ const cy = config.centerY ?? 0;
3363
+ const cz = config.centerZ ?? 0;
3364
+ const radius = config.radius ?? 0;
3365
+ const outerRadius = config.maxRadius ?? radius;
3366
+ const shell = intersectRaySphere(ray, cx, cy, cz, outerRadius);
3367
+ if (!shell) return null;
3368
+ const maxDistance = options?.maxDistance ?? Number.POSITIVE_INFINITY;
3369
+ const startT = Math.max(0, shell.t0);
3370
+ const endT = Math.min(shell.t1, maxDistance);
3371
+ if (endT < startT) return null;
3372
+ const scratchDir = new Vector3();
3373
+ const point = new Vector3();
3374
+ const signedDistanceAt = (px, py, pz) => sphereSignedDistance(query, config, px, py, pz, scratchDir);
3375
+ const hitT = marchSignedDistance(
3376
+ ray,
3377
+ startT,
3378
+ endT,
3379
+ signedDistanceAt,
3380
+ signedDistanceAt,
3381
+ {
3382
+ maxSteps: Math.max(8, options?.maxSteps ?? 256),
3383
+ refinementSteps: Math.max(1, options?.refinementSteps ?? 12)
3384
+ },
3385
+ point
3386
+ );
3387
+ if (hitT === null) return null;
3388
+ ray.at(hitT, point);
3389
+ const sample = query.sampleTerrainByPosition(point);
3390
+ if (!sample.valid) return null;
3391
+ return {
3392
+ position: sample.position.clone(),
3393
+ normal: sample.normal.clone(),
3394
+ distance: ray.origin.distanceTo(sample.position)
3395
+ };
3396
+ }
3397
+ function cubeSphereRaycastBoundsOnly(ray, config, options) {
3398
+ const cx = config.centerX ?? 0;
3399
+ const cy = config.centerY ?? 0;
3400
+ const cz = config.centerZ ?? 0;
3401
+ const radius = config.radius ?? 0;
3402
+ const shell = intersectRaySphere(ray, cx, cy, cz, radius);
3403
+ if (!shell) return null;
3404
+ const maxDistance = options?.maxDistance ?? Number.POSITIVE_INFINITY;
3405
+ const t = shell.t0 >= 0 ? shell.t0 : shell.t1;
3406
+ if (t < 0 || t > maxDistance) return null;
3407
+ const point = new Vector3();
3408
+ ray.at(t, point);
3409
+ const normal = new Vector3(point.x - cx, point.y - cy, point.z - cz).normalize();
3410
+ return { position: point, normal, distance: ray.origin.distanceTo(point) };
3411
+ }
2683
3412
 
2684
3413
  function createTerrainRaycast(params) {
2685
3414
  return {
2686
3415
  pick(ray, options) {
2687
3416
  const config = params.getConfig();
2688
3417
  const terrainQuery = params.getTerrainQuery();
3418
+ if (config.projection === "cubeSphere") {
3419
+ const sphereQuery = params.getSphereQuery();
3420
+ if (sphereQuery) {
3421
+ const precise = cubeSphereRaycast(sphereQuery, ray, config, options);
3422
+ if (precise) return precise;
3423
+ }
3424
+ return cubeSphereRaycastBoundsOnly(ray, config, options);
3425
+ }
2689
3426
  if (terrainQuery) {
2690
3427
  const precise = cpuRaycast(terrainQuery, ray, config, options);
2691
3428
  if (precise) return precise;
@@ -2710,29 +3447,47 @@ const BOUNDS_PADDING = 1;
2710
3447
  const RAYCAST_STATE = Symbol("terrainRaycastTaskState");
2711
3448
  const terrainRaycastTask = task(
2712
3449
  (get, work) => {
2713
- const { query: terrainQuery } = get(terrainQueryTask);
3450
+ const { query: terrainQuery, sphereQuery } = get(terrainQueryTask);
2714
3451
  const rootSizeValue = get(rootSize);
2715
3452
  const originValue = get(origin);
2716
3453
  const elevationScaleValue = get(elevationScale);
3454
+ const radiusValue = get(radius);
3455
+ const topologyValue = get(topologyTask);
3456
+ const projection = topologyValue.projection ?? "flat";
3457
+ const sphereRadius = topologyValue.radius ?? radiusValue;
2717
3458
  return work((prev) => {
2718
3459
  let raycast = prev;
2719
3460
  let state = raycast?.[RAYCAST_STATE];
2720
3461
  if (!state) {
2721
3462
  state = {
2722
3463
  terrainQuery: null,
3464
+ sphereQuery: null,
2723
3465
  bounds: {
2724
3466
  rootSize: 0,
2725
3467
  originX: 0,
2726
3468
  originZ: 0,
2727
3469
  minY: 0,
2728
- maxY: 0
3470
+ maxY: 0,
3471
+ projection: "flat",
3472
+ centerX: 0,
3473
+ centerY: 0,
3474
+ centerZ: 0,
3475
+ radius: 0,
3476
+ minRadius: 0,
3477
+ maxRadius: 0
2729
3478
  }
2730
3479
  };
2731
3480
  }
2732
3481
  state.terrainQuery = terrainQuery;
3482
+ state.sphereQuery = sphereQuery;
2733
3483
  state.bounds.rootSize = rootSizeValue;
2734
3484
  state.bounds.originX = originValue.x;
2735
3485
  state.bounds.originZ = originValue.z;
3486
+ state.bounds.projection = projection;
3487
+ state.bounds.centerX = originValue.x;
3488
+ state.bounds.centerY = originValue.y;
3489
+ state.bounds.centerZ = originValue.z;
3490
+ state.bounds.radius = sphereRadius;
2736
3491
  const range = terrainQuery.getGlobalElevationRange();
2737
3492
  if (range) {
2738
3493
  state.bounds.minY = range.min - BOUNDS_PADDING;
@@ -2742,9 +3497,19 @@ const terrainRaycastTask = task(
2742
3497
  state.bounds.minY = originValue.y - verticalExtent;
2743
3498
  state.bounds.maxY = originValue.y + verticalExtent;
2744
3499
  }
3500
+ const elevationExtent = Math.max(1, Math.abs(elevationScaleValue));
3501
+ let dispMin = -elevationExtent;
3502
+ let dispMax = elevationExtent;
3503
+ if (range) {
3504
+ dispMin = range.min - originValue.y;
3505
+ dispMax = range.max - originValue.y;
3506
+ }
3507
+ state.bounds.minRadius = Math.max(0, sphereRadius + dispMin - BOUNDS_PADDING);
3508
+ state.bounds.maxRadius = sphereRadius + dispMax + BOUNDS_PADDING;
2745
3509
  if (!raycast) {
2746
3510
  raycast = createTerrainRaycast({
2747
3511
  getTerrainQuery: () => state.terrainQuery,
3512
+ getSphereQuery: () => state.sphereQuery,
2748
3513
  getConfig: () => state.bounds
2749
3514
  });
2750
3515
  }
@@ -2754,15 +3519,12 @@ const terrainRaycastTask = task(
2754
3519
  }
2755
3520
  ).displayName("terrainRaycastTask");
2756
3521
 
2757
- function terrainGraph() {
2758
- return graph().add(instanceIdTask).add(quadtreeConfigTask).add(quadtreeUpdateTask).add(leafStorageTask).add(surfaceTask).add(leafGpuBufferTask).add(gpuSpatialIndexStorageTask).add(gpuSpatialIndexUploadTask).add(createUniformsTask).add(updateUniformsTask).add(positionNodeTask).add(createElevationFieldContextTask).add(tileNodesTask).add(createTerrainFieldTextureTask).add(createTerrainSamplerTask).add(elevationFieldStageTask).add(terrainFieldStageTask).add(compileComputeTask).add(executeComputeTask).add(tileBoundsContextTask).add(tileBoundsReductionTask).add(terrainQueryTask).add(terrainReadbackTask).add(terrainRaycastTask);
2759
- }
2760
3522
  const terrainTasks = {
2761
3523
  instanceId: instanceIdTask,
2762
3524
  quadtreeConfig: quadtreeConfigTask,
2763
3525
  quadtreeUpdate: quadtreeUpdateTask,
2764
3526
  leafStorage: leafStorageTask,
2765
- surface: surfaceTask,
3527
+ topology: topologyTask,
2766
3528
  leafGpuBuffer: leafGpuBufferTask,
2767
3529
  gpuSpatialIndexStorage: gpuSpatialIndexStorageTask,
2768
3530
  gpuSpatialIndexUpload: gpuSpatialIndexUploadTask,
@@ -2783,6 +3545,13 @@ const terrainTasks = {
2783
3545
  terrainReadback: terrainReadbackTask,
2784
3546
  terrainRaycast: terrainRaycastTask
2785
3547
  };
3548
+ function terrainGraph() {
3549
+ const g = graph();
3550
+ for (const t of Object.values(terrainTasks)) {
3551
+ g.add(t);
3552
+ }
3553
+ return g;
3554
+ }
2786
3555
 
2787
3556
  const textureSpaceToVectorSpace = Fn(([value]) => {
2788
3557
  return remap(value, float(0), float(1), float(-1), float(1));
@@ -2793,7 +3562,7 @@ const vectorSpaceToTextureSpace = Fn(([value]) => {
2793
3562
  const blendAngleCorrectedNormals = Fn(([n1, n2]) => {
2794
3563
  const t = vec3(n1.x, n1.y, n1.z.add(1));
2795
3564
  const u = vec3(n2.x.negate(), n2.y.negate(), n2.z);
2796
- const r = t.mul(dot(t, u)).sub(u.mul(t.z)).normalize();
3565
+ const r = t.mul(dot$1(t, u)).sub(u.mul(t.z)).normalize();
2797
3566
  return r;
2798
3567
  });
2799
3568
  const deriveNormalZ = Fn(([normalXY]) => {
@@ -2836,4 +3605,4 @@ const voronoiCells = Fn((params) => {
2836
3605
  return k;
2837
3606
  });
2838
3607
 
2839
- export { ArrayTextureBackend, AtlasBackend, Dir, TerrainGeometry, TerrainMesh, Texture3DBackend, U32_EMPTY, allocLeafSet, allocSeamTable, beginUpdate, blendAngleCorrectedNormals, buildLeafIndex, buildSeams2to1, compileComputeTask, createComputePipelineTasks, createCubeSphereSurface, createElevationFieldContextTask, createFlatSurface, createInfiniteFlatSurface, createSpatialIndex, createState, createTerrainFieldStorage, createTerrainFieldTextureTask, createTerrainQuery, createTerrainRaycast, createTerrainSampler, createTerrainSamplerTask, createTerrainUniforms, createUniformsTask, deriveNormalZ, elevationFieldStageTask, elevationFn, elevationScale, executeComputeTask, getDeviceComputeLimits, gpuSpatialIndexStorageTask, gpuSpatialIndexUploadTask, innerTileSegments, instanceIdTask, isSkirtUV, isSkirtVertex, leafGpuBufferTask, leafStorageTask, loadTerrainField, loadTerrainFieldElevation, loadTerrainFieldNormal, maxLevel, maxNodes, origin, packTerrainFieldSample, positionNodeTask, quadtreeConfigTask, quadtreeUpdate, quadtreeUpdateTask, resetLeafSet, resetSeamTable, rootSize, sampleTerrainField, sampleTerrainFieldElevation, sampleTerrainFieldNormal, skirtScale, storeTerrainField, surface, surfaceTask, terrainFieldFilter, terrainFieldStageTask, terrainGraph, terrainQueryTask, terrainRaycastTask, terrainReadbackTask, terrainTasks, textureSpaceToVectorSpace, tileNodesTask, update, updateUniformsTask, vElevation, vGlobalVertexIndex, vectorSpaceToTextureSpace, voronoiCells };
3608
+ export { ArrayTextureBackend, AtlasBackend, CUBE_FACES, CUBE_FACE_COUNT, Dir, TerrainGeometry, TerrainMesh, U32_EMPTY, allocLeafSet, allocSeamTable, beginUpdate, blendAngleCorrectedNormals, buildLeafIndex, buildSeams2to1, compileComputeTask, createComputePipelineTasks, createCubeSphereTopology, createElevationFieldContextTask, createFlatTopology, createInfiniteFlatTopology, createSpatialIndex, createState, createTerrainFieldStorage, createTerrainFieldTextureTask, createTerrainQuery, createTerrainRaycast, createTerrainSampler, createTerrainSamplerTask, createTerrainSphereQuery, createTerrainUniforms, createUniformsTask, cubeFaceBasis, cubeFaceDirection, cubeFaceFromDirection, cubeFacePoint, cubeFaceUVFromDirection, deriveNormalZ, directionToFace, directionToFaceUV, directionToLatLong, elevationFieldStageTask, elevationFn, elevationScale, executeComputeTask, faceUVToCube, getDeviceComputeLimits, gpuSpatialIndexStorageTask, gpuSpatialIndexUploadTask, innerTileSegments, instanceIdTask, isSkirtUV, isSkirtVertex, latLongToDirection, leafGpuBufferTask, leafStorageTask, loadTerrainField, loadTerrainFieldElevation, loadTerrainFieldNormal, maxLevel, maxNodes, origin, packTerrainFieldSample, positionNodeTask, quadtreeConfigTask, quadtreeUpdate, quadtreeUpdateTask, radius, resetLeafSet, resetSeamTable, rootSize, sampleTerrainField, sampleTerrainFieldElevation, skirtScale, sphereTangentFrameNormal, storeTerrainField, tangentFromAxis, terrainFieldFilter, terrainFieldStageTask, terrainGraph, terrainQueryTask, terrainRaycastTask, terrainReadbackTask, terrainTasks, textureSpaceToVectorSpace, tileNodesTask, topology, topologyTask, unpackTangentNormal, update, updateUniformsTask, vElevation, vGlobalVertexIndex, vectorSpaceToTextureSpace, voronoiCells };