spoint 0.1.603 → 0.1.604
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/client/app.js +16 -3
- package/client/core/CaveMeshes.js +42 -0
- package/package.json +1 -1
- package/src/sdk/RoomOrchestrator.js +22 -1
- package/src/terrain/CaveSDF.js +96 -9
- package/src/terrain/TerrainPhysics.js +1 -1
package/client/app.js
CHANGED
|
@@ -43,6 +43,7 @@ import { createTerrainBackdrop } from './core/TerrainBackdrop.js'
|
|
|
43
43
|
import { createSculptOverlay } from './core/SculptOverlay.js'
|
|
44
44
|
import { createVegetation } from './core/Vegetation.js'
|
|
45
45
|
import { createRocks } from './core/Rocks.js'
|
|
46
|
+
import { createCaveMeshes } from './core/CaveMeshes.js'
|
|
46
47
|
import { createSceneOcclusion } from './core/SceneOcclusion.js'
|
|
47
48
|
import { createOcclusionQueryBudget } from './core/OcclusionQueryBudget.js'
|
|
48
49
|
import { createGrass } from './core/Grass.js'
|
|
@@ -502,6 +503,16 @@ function _ensureRocks(tb) {
|
|
|
502
503
|
.then(r => { rocks = r; if (window.__app) window.__app.rocks = r; sceneOcclusion.register('rocks', r) })
|
|
503
504
|
.catch(e => console.error('[rocks] init failed:', e?.message || e))
|
|
504
505
|
}
|
|
506
|
+
function _ensureCaves(tb) {
|
|
507
|
+
if (caveMeshes || !tb || !_terrainCfg) return null
|
|
508
|
+
const caveCfg = _terrainCfg.caveCarve
|
|
509
|
+
if (!Array.isArray(caveCfg) || caveCfg.length === 0) return null
|
|
510
|
+
try {
|
|
511
|
+
caveMeshes = createCaveMeshes({ scene, cfg: caveCfg })
|
|
512
|
+
if (window.__app) window.__app.caveMeshes = caveMeshes
|
|
513
|
+
} catch (e) { console.error('[caves] init failed:', e?.message || e) }
|
|
514
|
+
return null
|
|
515
|
+
}
|
|
505
516
|
function _ensureGrass(tb) {
|
|
506
517
|
if (grass || !tb || !_terrainCfg) return null
|
|
507
518
|
const vcfg = _terrainCfg.vegetation || {}
|
|
@@ -580,6 +591,7 @@ async function _buildWorldScenery() {
|
|
|
580
591
|
const rp = _ensureRocks(tb); if (rp) { await rp; _hp('after-rocks') }
|
|
581
592
|
const gp = _ensureGrass(tb); if (gp) { await gp; _hp('after-grass') }
|
|
582
593
|
const vp = _ensureVegetation(tb); if (vp) { await vp; _hp('after-veg') }
|
|
594
|
+
_ensureCaves(tb); _hp('after-caves')
|
|
583
595
|
_ensureWeather(tb); _hp('after-weather')
|
|
584
596
|
} else {
|
|
585
597
|
_dbgTerrain('planet backdrop unavailable (init failed) -> skipping vegetation/rocks/grass to avoid a broken-shader GPU leak')
|
|
@@ -666,7 +678,7 @@ const clickPrompt = document.getElementById('click-prompt')
|
|
|
666
678
|
if (deviceInfo.isMobile && clickPrompt) clickPrompt.style.display = 'none'
|
|
667
679
|
const _pids = new Set(), _eids = new Set()
|
|
668
680
|
let worldConfig={}, vrmBuffer=null, animAssets=null, assetsLoaded=false, firstSnapshotReceived=false, _fitShadowTimer=null
|
|
669
|
-
let terrainBackdrop=null, _terrainCfg=null, vegetation=null, rocks=null, grass=null, colliderDebug=null, weather=null, sculptOverlay=null
|
|
681
|
+
let terrainBackdrop=null, _terrainCfg=null, vegetation=null, rocks=null, grass=null, colliderDebug=null, weather=null, sculptOverlay=null, caveMeshes=null
|
|
670
682
|
// Late-join GPU-visible sculpt backfill (terrain-sculpt-late-join-gpu-resync): MSG.TERRAIN_SCULPT_SYNC
|
|
671
683
|
// can (and on a real connection reliably does -- terrain scenery build is async, WORLD_DEF/SNAPSHOT/this
|
|
672
684
|
// sync all arrive near-instantly on the same connection) reach the client BEFORE _buildWorldScenery has
|
|
@@ -1008,7 +1020,8 @@ const engineCtx = {
|
|
|
1008
1020
|
try { rocks && rocks.dispose && rocks.dispose() } catch (e) { _dbgTerrain('rocks dispose failed on reseed:', e?.message || e) }
|
|
1009
1021
|
sceneOcclusion.unregister('vegetation'); sceneOcclusion.unregister('rocks'); sceneOcclusion.unregister('grass')
|
|
1010
1022
|
try { grass && grass.dispose && grass.dispose() } catch (e) { _dbgTerrain('grass dispose failed on reseed:', e?.message || e) }
|
|
1011
|
-
|
|
1023
|
+
try { caveMeshes && caveMeshes.dispose && caveMeshes.dispose() } catch (e) { _dbgTerrain('caveMeshes dispose failed on reseed:', e?.message || e) }
|
|
1024
|
+
vegetation = null; rocks = null; grass = null; caveMeshes = null
|
|
1012
1025
|
// Weather itself isn't seed-derived (camera-relative, no placement hash), but it holds the OLD
|
|
1013
1026
|
// terrain frame closed over for ground-height sampling -- a stale frame after reseed would sample
|
|
1014
1027
|
// splash contact against the pre-reseed terrain. Dispose + let _ensureWeather below rebuild
|
|
@@ -1024,7 +1037,7 @@ const engineCtx = {
|
|
|
1024
1037
|
try { const f = tb.frame; if (f) setSeaLevelY((f.offsetY || 0) - (f.anchorHeight || 0), scene) } catch (_) {}
|
|
1025
1038
|
if (seedChanged && tb && window.__terrain) {
|
|
1026
1039
|
const rp = _ensureRocks(tb); const gp = _ensureGrass(tb); const vp = _ensureVegetation(tb)
|
|
1027
|
-
_ensureWeather(tb)
|
|
1040
|
+
_ensureCaves(tb); _ensureWeather(tb)
|
|
1028
1041
|
return Promise.all([rp, gp, vp].filter(Boolean)).catch(e => console.error('[terrain] reseed veg/rock/grass rebuild failed:', e?.message || e))
|
|
1029
1042
|
}
|
|
1030
1043
|
})
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import * as THREE from 'three'
|
|
2
|
+
import { polygonizeCaveVolume, loadCaveCarveLayer } from '/src/terrain/CaveSDF.js'
|
|
3
|
+
|
|
4
|
+
function buildVolumeMesh(vol, res, material) {
|
|
5
|
+
const mesh = polygonizeCaveVolume(vol, res)
|
|
6
|
+
if (mesh.vc === 0) return null
|
|
7
|
+
const g = new THREE.BufferGeometry()
|
|
8
|
+
g.setAttribute('position', new THREE.BufferAttribute(mesh.positions.slice(), 3))
|
|
9
|
+
g.setIndex(new THREE.BufferAttribute(mesh.indices.slice(0, mesh.ic), 1))
|
|
10
|
+
g.computeVertexNormals()
|
|
11
|
+
g.computeBoundingBox()
|
|
12
|
+
g.computeBoundingSphere()
|
|
13
|
+
const obj = new THREE.Mesh(g, material)
|
|
14
|
+
obj.matrixAutoUpdate = false
|
|
15
|
+
obj.updateMatrix()
|
|
16
|
+
return obj
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function createCaveMeshes({ scene, cfg, res = 24 }) {
|
|
20
|
+
const group = new THREE.Group()
|
|
21
|
+
group.name = 'cave-meshes'
|
|
22
|
+
const material = new THREE.MeshStandardMaterial({ color: 0x2a2a2c, roughness: 0.95, metalness: 0.0, side: THREE.BackSide })
|
|
23
|
+
const objects = []
|
|
24
|
+
const volumesSpec = Array.isArray(cfg) ? cfg : []
|
|
25
|
+
const layer = loadCaveCarveLayer({ version: 2, volumes: volumesSpec })
|
|
26
|
+
for (const vol of layer.volumes) {
|
|
27
|
+
const obj = buildVolumeMesh(vol, res, material)
|
|
28
|
+
if (obj) { group.add(obj); objects.push(obj) }
|
|
29
|
+
}
|
|
30
|
+
if (scene) scene.add(group)
|
|
31
|
+
|
|
32
|
+
function dispose() {
|
|
33
|
+
for (const obj of objects) { obj.geometry.dispose(); scene && scene.remove(obj) }
|
|
34
|
+
objects.length = 0
|
|
35
|
+
scene && scene.remove(group)
|
|
36
|
+
material.dispose()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const api = { group, objects, dispose, get volumeCount() { return objects.length } }
|
|
40
|
+
if (typeof window !== 'undefined') window.__caveMeshes = api
|
|
41
|
+
return api
|
|
42
|
+
}
|
package/package.json
CHANGED
|
@@ -65,7 +65,7 @@ export class RoomOrchestrator {
|
|
|
65
65
|
* @param {number} [opts.maxRestarts] - max restarts per worker within restartWindowMs; default 3
|
|
66
66
|
* @param {number} [opts.restartWindowMs] - sliding window for crash rate limiting; default 60000
|
|
67
67
|
*/
|
|
68
|
-
constructor({ sdkRoot, projectRoot, workerCount = 2, portRange = [19000, 19999], elasticScaling = false, elasticScaleUpThreshold = 0.8, elasticScaleDownCooldownMs = 120000, elasticScaleCheckIntervalMs = 30000, workerHosts = {}, restartOnCrash = true, maxRestarts = 3, restartWindowMs = 60000 } = {}) {
|
|
68
|
+
constructor({ sdkRoot, projectRoot, workerCount = 2, portRange = [19000, 19999], elasticScaling = false, elasticScaleUpThreshold = 0.8, elasticScaleDownCooldownMs = 120000, elasticScaleCheckIntervalMs = 30000, workerHosts = {}, restartOnCrash = true, maxRestarts = 3, restartWindowMs = 60000, externalWorkerHeartbeatIntervalMs = 10000 } = {}) {
|
|
69
69
|
if (!sdkRoot) throw new Error('RoomOrchestrator requires { sdkRoot }')
|
|
70
70
|
this.sdkRoot = sdkRoot
|
|
71
71
|
this.projectRoot = projectRoot || sdkRoot
|
|
@@ -97,6 +97,8 @@ export class RoomOrchestrator {
|
|
|
97
97
|
this._emptiedAt = new Map() // workerIndex -> timestamp when it last became empty (for scale-down cooldown)
|
|
98
98
|
this._retiring = new Set() // workerIndexes currently draining (reject new room placement, retiring handled by _onWorkerRetired once empty)
|
|
99
99
|
this._elasticStats = { spawns: 0, retires: 0, lastCheck: 0, lastDecision: '' }
|
|
100
|
+
this._externalWorkerHeartbeatIntervalMs = externalWorkerHeartbeatIntervalMs
|
|
101
|
+
this._externalHeartbeatTimer = null
|
|
100
102
|
}
|
|
101
103
|
|
|
102
104
|
// Non-overlapping sub-range per worker so N independent RoomDirectory instances (each with zero
|
|
@@ -268,6 +270,25 @@ export class RoomOrchestrator {
|
|
|
268
270
|
if (this._elasticTimer) { clearInterval(this._elasticTimer); this._elasticTimer = null }
|
|
269
271
|
}
|
|
270
272
|
|
|
273
|
+
startExternalWorkerHeartbeat() {
|
|
274
|
+
if (this._externalHeartbeatTimer) return
|
|
275
|
+
this._externalHeartbeatTimer = setInterval(() => this._externalHeartbeatCheck(), this._externalWorkerHeartbeatIntervalMs)
|
|
276
|
+
this._externalHeartbeatTimer.unref?.()
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
stopExternalWorkerHeartbeat() {
|
|
280
|
+
if (this._externalHeartbeatTimer) { clearInterval(this._externalHeartbeatTimer); this._externalHeartbeatTimer = null }
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
async _externalHeartbeatCheck() {
|
|
284
|
+
const checks = []
|
|
285
|
+
for (let i = 0; i < this.workers.length; i++) {
|
|
286
|
+
const entry = this.workers[i]
|
|
287
|
+
if (entry && entry.ready && entry.isExternal) checks.push(this._sendExternal(entry, { type: 'GET_STATUS' }).catch(() => null))
|
|
288
|
+
}
|
|
289
|
+
await Promise.all(checks)
|
|
290
|
+
}
|
|
291
|
+
|
|
271
292
|
async _elasticCheck() {
|
|
272
293
|
const now = Date.now()
|
|
273
294
|
this._elasticStats.lastCheck = now
|
package/src/terrain/CaveSDF.js
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
1
|
import { marchRockSurface } from './RockShapes.js'
|
|
2
2
|
|
|
3
|
+
function tunnelHash(seed, i) {
|
|
4
|
+
let h = seed | 0
|
|
5
|
+
h = Math.imul(h ^ (i | 0), 0x27d4eb2d) >>> 0
|
|
6
|
+
h ^= h >>> 15; h = Math.imul(h, 0x2c1b3c6d) >>> 0
|
|
7
|
+
h ^= h >>> 12; h = Math.imul(h, 0x297a2d39) >>> 0
|
|
8
|
+
h ^= h >>> 15
|
|
9
|
+
return (h >>> 0) / 4294967296
|
|
10
|
+
}
|
|
11
|
+
|
|
3
12
|
export function makeSphereCaveSDF(cx, cy, cz, radius) {
|
|
4
13
|
return (x, y, z) => {
|
|
5
14
|
const dx = x - cx, dy = y - cy, dz = z - cz
|
|
@@ -7,23 +16,65 @@ export function makeSphereCaveSDF(cx, cy, cz, radius) {
|
|
|
7
16
|
}
|
|
8
17
|
}
|
|
9
18
|
|
|
19
|
+
export function makeCylinderCaveSDF(cx, cy, cz, radius, halfHeight, axis = 'y') {
|
|
20
|
+
return (x, y, z) => {
|
|
21
|
+
const dx = x - cx, dy = y - cy, dz = z - cz
|
|
22
|
+
let radial, along, halfSpan
|
|
23
|
+
if (axis === 'x') { radial = Math.hypot(dy, dz); along = dx; halfSpan = halfHeight }
|
|
24
|
+
else if (axis === 'z') { radial = Math.hypot(dx, dy); along = dz; halfSpan = halfHeight }
|
|
25
|
+
else { radial = Math.hypot(dx, dz); along = dy; halfSpan = halfHeight }
|
|
26
|
+
const dRadial = radial - radius
|
|
27
|
+
const dAlong = Math.abs(along) - halfSpan
|
|
28
|
+
const outsideX = Math.max(dRadial, 0), outsideY = Math.max(dAlong, 0)
|
|
29
|
+
const outsideDist = Math.hypot(outsideX, outsideY)
|
|
30
|
+
const insideDist = Math.min(Math.max(dRadial, dAlong), 0)
|
|
31
|
+
return outsideDist + insideDist
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function makeTunnelCaveSDF(seed, radius, length, axis = 'y', warpAmp = 0.15, warpFreq = 2.5) {
|
|
36
|
+
return (x, y, z) => {
|
|
37
|
+
let along, u, v
|
|
38
|
+
if (axis === 'x') { along = x; u = y; v = z }
|
|
39
|
+
else if (axis === 'z') { along = z; u = x; v = y }
|
|
40
|
+
else { along = y; u = x; v = z }
|
|
41
|
+
const t = Math.max(-1, Math.min(1, along / (length * 0.5)))
|
|
42
|
+
const sampleIdx = Math.round((t + 1) * 0.5 * 1000)
|
|
43
|
+
const warpU = (tunnelHash(seed, sampleIdx * 2) * 2 - 1) * warpAmp * Math.sin(t * Math.PI * warpFreq + tunnelHash(seed, 1) * 6.28318)
|
|
44
|
+
const warpV = (tunnelHash(seed, sampleIdx * 2 + 1) * 2 - 1) * warpAmp * Math.cos(t * Math.PI * warpFreq + tunnelHash(seed, 2) * 6.28318)
|
|
45
|
+
const du = u - warpU, dv = v - warpV
|
|
46
|
+
const dRadial = Math.hypot(du, dv) - radius
|
|
47
|
+
const dAlong = Math.abs(along) - length * 0.5
|
|
48
|
+
const outsideX = Math.max(dRadial, 0), outsideY = Math.max(dAlong, 0)
|
|
49
|
+
const outsideDist = Math.hypot(outsideX, outsideY)
|
|
50
|
+
const insideDist = Math.min(Math.max(dRadial, dAlong), 0)
|
|
51
|
+
return outsideDist + insideDist
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
10
55
|
export function polygonizeCaveSDF(sdf, res = 24) {
|
|
11
56
|
return marchRockSurface(res, sdf)
|
|
12
57
|
}
|
|
13
58
|
|
|
14
|
-
|
|
15
|
-
|
|
59
|
+
function localSDFFor(shape) {
|
|
60
|
+
if (shape.kind === 'cylinder') return makeCylinderCaveSDF(0, 0, 0, 1, shape.halfHeight ?? 1, shape.axis ?? 'y')
|
|
61
|
+
if (shape.kind === 'tunnel') return makeTunnelCaveSDF(shape.seed ?? 0, 1, shape.length ?? 2, shape.axis ?? 'y', shape.warpAmp ?? 0.15, shape.warpFreq ?? 2.5)
|
|
62
|
+
return makeSphereCaveSDF(0, 0, 0, 1)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function createCaveVolume(worldX, worldY, worldZ, worldRadius, shape = { kind: 'sphere' }) {
|
|
66
|
+
const sdf = localSDFFor(shape)
|
|
16
67
|
const localToWorld = (lx, ly, lz) => [worldX + lx * worldRadius, worldY + ly * worldRadius, worldZ + lz * worldRadius]
|
|
17
68
|
const worldToLocal = (wx, wy, wz) => [(wx - worldX) / worldRadius, (wy - worldY) / worldRadius, (wz - worldZ) / worldRadius]
|
|
18
69
|
const worldSDF = (wx, wy, wz) => {
|
|
19
70
|
const [lx, ly, lz] = worldToLocal(wx, wy, wz)
|
|
20
71
|
return sdf(lx, ly, lz) * worldRadius
|
|
21
72
|
}
|
|
22
|
-
return { sdf: worldSDF, localToWorld, worldToLocal, worldX, worldY, worldZ, worldRadius }
|
|
73
|
+
return { sdf: worldSDF, localSDF: sdf, localToWorld, worldToLocal, worldX, worldY, worldZ, worldRadius, shape }
|
|
23
74
|
}
|
|
24
75
|
|
|
25
76
|
export function polygonizeCaveVolume(caveVolume, res = 24) {
|
|
26
|
-
const local = polygonizeCaveSDF(
|
|
77
|
+
const local = polygonizeCaveSDF(caveVolume.localSDF, res)
|
|
27
78
|
const positions = new Float32Array(local.positions.length)
|
|
28
79
|
for (let v = 0; v < local.vc; v++) {
|
|
29
80
|
const [wx, wy, wz] = caveVolume.localToWorld(local.positions[v * 3], local.positions[v * 3 + 1], local.positions[v * 3 + 2])
|
|
@@ -34,13 +85,37 @@ export function polygonizeCaveVolume(caveVolume, res = 24) {
|
|
|
34
85
|
return { positions: positions.subarray(0, local.vc * 3), vc: local.vc, indices: local.indices, ic: local.ic }
|
|
35
86
|
}
|
|
36
87
|
|
|
88
|
+
function volumeVerticalSpan(vol, horizontalDist) {
|
|
89
|
+
if (vol.shape.kind === 'cylinder' || vol.shape.kind === 'tunnel') {
|
|
90
|
+
if (horizontalDist > vol.worldRadius) return 0
|
|
91
|
+
return vol.worldRadius * (vol.shape.halfHeight ?? 1)
|
|
92
|
+
}
|
|
93
|
+
return Math.sqrt(Math.max(0, vol.worldRadius * vol.worldRadius - horizontalDist * horizontalDist))
|
|
94
|
+
}
|
|
95
|
+
|
|
37
96
|
export function createCaveCarveLayer() {
|
|
38
97
|
const volumes = []
|
|
39
98
|
|
|
40
99
|
function addSphereCave(worldX, worldY, worldZ, worldRadius) {
|
|
41
100
|
if (!Number.isFinite(worldX) || !Number.isFinite(worldY) || !Number.isFinite(worldZ)) return { added: false }
|
|
42
101
|
if (!Number.isFinite(worldRadius) || worldRadius <= 0) return { added: false }
|
|
43
|
-
volumes.push(createCaveVolume(worldX, worldY, worldZ, worldRadius))
|
|
102
|
+
volumes.push(createCaveVolume(worldX, worldY, worldZ, worldRadius, { kind: 'sphere' }))
|
|
103
|
+
return { added: true, count: volumes.length }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function addCylinderCave(worldX, worldY, worldZ, worldRadius, halfHeight, axis = 'y') {
|
|
107
|
+
if (!Number.isFinite(worldX) || !Number.isFinite(worldY) || !Number.isFinite(worldZ)) return { added: false }
|
|
108
|
+
if (!Number.isFinite(worldRadius) || worldRadius <= 0) return { added: false }
|
|
109
|
+
if (!Number.isFinite(halfHeight) || halfHeight <= 0) return { added: false }
|
|
110
|
+
volumes.push(createCaveVolume(worldX, worldY, worldZ, worldRadius, { kind: 'cylinder', halfHeight, axis }))
|
|
111
|
+
return { added: true, count: volumes.length }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function addTunnelCave(worldX, worldY, worldZ, worldRadius, length, axis = 'y', seed = 0, warpAmp = 0.15, warpFreq = 2.5) {
|
|
115
|
+
if (!Number.isFinite(worldX) || !Number.isFinite(worldY) || !Number.isFinite(worldZ)) return { added: false }
|
|
116
|
+
if (!Number.isFinite(worldRadius) || worldRadius <= 0) return { added: false }
|
|
117
|
+
if (!Number.isFinite(length) || length <= 0) return { added: false }
|
|
118
|
+
volumes.push(createCaveVolume(worldX, worldY, worldZ, worldRadius, { kind: 'tunnel', length: length / worldRadius, axis, seed, warpAmp, warpFreq }))
|
|
44
119
|
return { added: true, count: volumes.length }
|
|
45
120
|
}
|
|
46
121
|
|
|
@@ -51,7 +126,8 @@ export function createCaveCarveLayer() {
|
|
|
51
126
|
const dx = x - vol.worldX, dz = z - vol.worldZ
|
|
52
127
|
const horizontalDist = Math.hypot(dx, dz)
|
|
53
128
|
if (horizontalDist > vol.worldRadius) continue
|
|
54
|
-
const verticalSpan =
|
|
129
|
+
const verticalSpan = volumeVerticalSpan(vol, horizontalDist)
|
|
130
|
+
if (verticalSpan <= 0) continue
|
|
55
131
|
const caveTop = vol.worldY + verticalSpan
|
|
56
132
|
if (caveTop < surfaceY) continue
|
|
57
133
|
delta -= (caveTop - surfaceY) + verticalSpan
|
|
@@ -69,13 +145,22 @@ export function createCaveCarveLayer() {
|
|
|
69
145
|
}
|
|
70
146
|
|
|
71
147
|
function toJSON() {
|
|
72
|
-
return {
|
|
148
|
+
return {
|
|
149
|
+
version: 2,
|
|
150
|
+
volumes: volumes.map(v => ({
|
|
151
|
+
worldX: v.worldX, worldY: v.worldY, worldZ: v.worldZ, worldRadius: v.worldRadius,
|
|
152
|
+
kind: v.shape.kind, halfHeight: v.shape.halfHeight, axis: v.shape.axis,
|
|
153
|
+
length: v.shape.length != null ? v.shape.length * v.worldRadius : undefined,
|
|
154
|
+
seed: v.shape.seed, warpAmp: v.shape.warpAmp, warpFreq: v.shape.warpFreq,
|
|
155
|
+
})),
|
|
156
|
+
}
|
|
73
157
|
}
|
|
74
158
|
|
|
75
159
|
function clear() { volumes.length = 0 }
|
|
76
160
|
|
|
77
161
|
return {
|
|
78
|
-
addSphereCave, heightDeltaAt, wrapHeightFn, toJSON, clear,
|
|
162
|
+
addSphereCave, addCylinderCave, addTunnelCave, heightDeltaAt, wrapHeightFn, toJSON, clear,
|
|
163
|
+
get volumes() { return volumes.slice() },
|
|
79
164
|
get volumeCount() { return volumes.length },
|
|
80
165
|
}
|
|
81
166
|
}
|
|
@@ -85,7 +170,9 @@ export function loadCaveCarveLayer(json) {
|
|
|
85
170
|
if (json && Array.isArray(json.volumes)) {
|
|
86
171
|
for (const v of json.volumes) {
|
|
87
172
|
if (!v || !Number.isFinite(v.worldX) || !Number.isFinite(v.worldY) || !Number.isFinite(v.worldZ) || !Number.isFinite(v.worldRadius)) continue
|
|
88
|
-
layer.
|
|
173
|
+
if (v.kind === 'cylinder') layer.addCylinderCave(v.worldX, v.worldY, v.worldZ, v.worldRadius, v.halfHeight ?? 1, v.axis ?? 'y')
|
|
174
|
+
else if (v.kind === 'tunnel') layer.addTunnelCave(v.worldX, v.worldY, v.worldZ, v.worldRadius, v.length ?? v.worldRadius * 2, v.axis ?? 'y', v.seed ?? 0, v.warpAmp ?? 0.15, v.warpFreq ?? 2.5)
|
|
175
|
+
else layer.addSphereCave(v.worldX, v.worldY, v.worldZ, v.worldRadius)
|
|
89
176
|
}
|
|
90
177
|
}
|
|
91
178
|
return layer
|
|
@@ -286,7 +286,7 @@ export async function setupTerrainStreaming({ physics, playerManager, worldDef,
|
|
|
286
286
|
// stays byte-identical, sculpting is purely an on-top layer. heightDeltaJSON (if any) replays prior
|
|
287
287
|
// strokes so a rebuild (reseed/streamer restart) keeps existing sculpting intact.
|
|
288
288
|
const heightDelta = loadHeightDelta(heightDeltaJSON, baseHeightFn)
|
|
289
|
-
const caveCarve = loadCaveCarveLayer(caveCarveJSON)
|
|
289
|
+
const caveCarve = loadCaveCarveLayer(caveCarveJSON || (Array.isArray(tcfg.caveCarve) ? { version: 2, volumes: tcfg.caveCarve } : null))
|
|
290
290
|
const heightFn = caveCarve.wrapHeightFn(splineCarve.wrapHeightFn(heightDelta.wrapHeightFn(baseHeightFn)))
|
|
291
291
|
const getCenter = () => {
|
|
292
292
|
let sx = 0, sz = 0, n = 0
|