spoint 0.1.602 → 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/SplineCarve.js +37 -11
- 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
|
|
@@ -6,6 +6,15 @@ function cellKey(cx, cz) {
|
|
|
6
6
|
return (cx + OFF) * BIG + (cz + OFF)
|
|
7
7
|
}
|
|
8
8
|
|
|
9
|
+
function carveHash(seed, i) {
|
|
10
|
+
let h = seed | 0
|
|
11
|
+
h = Math.imul(h ^ (i | 0), 0x27d4eb2d) >>> 0
|
|
12
|
+
h ^= h >>> 15; h = Math.imul(h, 0x2c1b3c6d) >>> 0
|
|
13
|
+
h ^= h >>> 12; h = Math.imul(h, 0x297a2d39) >>> 0
|
|
14
|
+
h ^= h >>> 15
|
|
15
|
+
return (h >>> 0) / 4294967296
|
|
16
|
+
}
|
|
17
|
+
|
|
9
18
|
function catmullRom(p0, p1, p2, p3, t) {
|
|
10
19
|
const getX = (p) => Array.isArray(p) ? p[0] : p.x
|
|
11
20
|
const getZ = (p) => Array.isArray(p) ? p[1] : p.z
|
|
@@ -134,30 +143,47 @@ export function createSplineCarveLayer() {
|
|
|
134
143
|
const cells = new Map()
|
|
135
144
|
const appliedSplines = []
|
|
136
145
|
|
|
137
|
-
function carve(controlPoints, width, depth, kind, baseHeightFn) {
|
|
146
|
+
function carve(controlPoints, width, depth, kind, baseHeightFn, opts = {}) {
|
|
138
147
|
if (!Array.isArray(controlPoints) || controlPoints.length < 2) return { touched: 0 }
|
|
139
148
|
if (!Number.isFinite(width) || width <= 0 || !Number.isFinite(depth)) return { touched: 0 }
|
|
140
149
|
if (kind !== 'river' && kind !== 'road') return { touched: 0 }
|
|
150
|
+
const { seed = 0, widthVariance = 0, bankErosion = 0 } = opts
|
|
151
|
+
const isRiver = kind === 'river'
|
|
141
152
|
const stepSize = Math.max(1, width * 0.5)
|
|
142
153
|
const spinePoints = sampleSpline(controlPoints, stepSize)
|
|
143
154
|
if (spinePoints.length === 0) return { touched: 0 }
|
|
144
|
-
const
|
|
155
|
+
const maxHalfWidth = (width * 0.5) * (1 + (isRiver ? widthVariance : 0)) + (isRiver ? bankErosion : 0)
|
|
145
156
|
let touched = 0
|
|
146
|
-
|
|
157
|
+
let arcLen = 0
|
|
158
|
+
for (let si = 0; si < spinePoints.length; si++) {
|
|
159
|
+
const sp = spinePoints[si]
|
|
147
160
|
const sx = sp.x, sz = sp.z
|
|
148
|
-
const
|
|
149
|
-
|
|
161
|
+
if (si > 0) { const px = spinePoints[si - 1].x, pz = spinePoints[si - 1].z; arcLen += Math.hypot(sx - px, sz - pz) }
|
|
162
|
+
let halfWidth = width * 0.5
|
|
163
|
+
if (isRiver && widthVariance > 0) {
|
|
164
|
+
const t = carveHash(seed, Math.round(arcLen * 0.1))
|
|
165
|
+
halfWidth *= 1 + (t * 2 - 1) * widthVariance
|
|
166
|
+
}
|
|
167
|
+
const cx0 = Math.floor((sx - maxHalfWidth) / CELL_M), cx1 = Math.ceil((sx + maxHalfWidth) / CELL_M)
|
|
168
|
+
const cz0 = Math.floor((sz - maxHalfWidth) / CELL_M), cz1 = Math.ceil((sz + maxHalfWidth) / CELL_M)
|
|
150
169
|
for (let cz = cz0; cz <= cz1; cz++) {
|
|
151
170
|
for (let cx = cx0; cx <= cx1; cx++) {
|
|
152
171
|
const cMinX = cx * CELL_M, cMinZ = cz * CELL_M
|
|
172
|
+
const ccx = cMinX + CELL_M * 0.5, ccz = cMinZ + CELL_M * 0.5
|
|
173
|
+
let bankOffset = 0
|
|
174
|
+
if (isRiver && bankErosion > 0) {
|
|
175
|
+
const angle = carveHash(seed ^ 0x5bd1e995, cx * 92821 + cz) * Math.PI * 2
|
|
176
|
+
bankOffset = (carveHash(seed ^ 0x1b873593, cx * 15485863 + cz) * 2 - 1) * bankErosion * (0.5 + 0.5 * Math.cos(angle))
|
|
177
|
+
}
|
|
178
|
+
const cellHalfWidth = halfWidth + bankOffset
|
|
179
|
+
if (cellHalfWidth <= 0) continue
|
|
153
180
|
const nearestX = Math.max(cMinX, Math.min(sx, cMinX + CELL_M))
|
|
154
181
|
const nearestZ = Math.max(cMinZ, Math.min(sz, cMinZ + CELL_M))
|
|
155
182
|
const d = Math.hypot(nearestX - sx, nearestZ - sz)
|
|
156
|
-
if (d >
|
|
157
|
-
const ccx = cMinX + CELL_M * 0.5, ccz = cMinZ + CELL_M * 0.5
|
|
183
|
+
if (d > cellHalfWidth) continue
|
|
158
184
|
const dCenter = Math.hypot(ccx - sx, ccz - sz)
|
|
159
|
-
const falloffDist = Math.min(dCenter,
|
|
160
|
-
const falloff = 0.5 * (1 + Math.cos((falloffDist /
|
|
185
|
+
const falloffDist = Math.min(dCenter, cellHalfWidth)
|
|
186
|
+
const falloff = 0.5 * (1 + Math.cos((falloffDist / cellHalfWidth) * Math.PI))
|
|
161
187
|
const key = cellKey(cx, cz)
|
|
162
188
|
const base = typeof baseHeightFn === 'function' ? baseHeightFn(ccx, ccz) : null
|
|
163
189
|
const targetDelta = Number.isFinite(base) ? -depth * falloff : 0
|
|
@@ -169,7 +195,7 @@ export function createSplineCarveLayer() {
|
|
|
169
195
|
}
|
|
170
196
|
}
|
|
171
197
|
}
|
|
172
|
-
if (touched > 0) appliedSplines.push({ controlPoints: controlPoints.map(p => Array.isArray(p) ? [p[0], p[1]] : [p.x, p.z]), width, depth, kind })
|
|
198
|
+
if (touched > 0) appliedSplines.push({ controlPoints: controlPoints.map(p => Array.isArray(p) ? [p[0], p[1]] : [p.x, p.z]), width, depth, kind, seed, widthVariance, bankErosion })
|
|
173
199
|
return { touched }
|
|
174
200
|
}
|
|
175
201
|
|
|
@@ -224,7 +250,7 @@ export function loadSplineCarveLayer(json, baseHeightFn) {
|
|
|
224
250
|
if (json && Array.isArray(json.splines)) {
|
|
225
251
|
for (const s of json.splines) {
|
|
226
252
|
if (!s || !Array.isArray(s.controlPoints) || !Number.isFinite(s.width) || !Number.isFinite(s.depth)) continue
|
|
227
|
-
layer.carve(s.controlPoints, s.width, s.depth, s.kind, baseHeightFn)
|
|
253
|
+
layer.carve(s.controlPoints, s.width, s.depth, s.kind, baseHeightFn, { seed: s.seed ?? 0, widthVariance: s.widthVariance ?? 0, bankErosion: s.bankErosion ?? 0 })
|
|
228
254
|
}
|
|
229
255
|
}
|
|
230
256
|
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
|