@aethervtt/shard-terrain 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +3 -0
- package/dist/biomes.d.ts +65 -0
- package/dist/biomes.js +163 -0
- package/dist/chunk.d.ts +99 -0
- package/dist/chunk.js +304 -0
- package/dist/colliders.d.ts +86 -0
- package/dist/colliders.js +356 -0
- package/dist/components.d.ts +49 -0
- package/dist/components.js +137 -0
- package/dist/cube.d.ts +51 -0
- package/dist/cube.js +87 -0
- package/dist/frame.d.ts +21 -0
- package/dist/frame.js +63 -0
- package/dist/heights.d.ts +51 -0
- package/dist/heights.js +137 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +193 -0
- package/dist/kernel.d.ts +47 -0
- package/dist/kernel.js +243 -0
- package/dist/lod.d.ts +22 -0
- package/dist/lod.js +44 -0
- package/dist/material.d.ts +14 -0
- package/dist/material.js +426 -0
- package/dist/methods.d.ts +12 -0
- package/dist/methods.js +259 -0
- package/dist/nav.d.ts +14 -0
- package/dist/nav.js +114 -0
- package/dist/overlays.d.ts +6 -0
- package/dist/overlays.js +67 -0
- package/dist/planet.d.ts +106 -0
- package/dist/planet.js +313 -0
- package/dist/plugin.d.ts +23 -0
- package/dist/plugin.js +98 -0
- package/dist/points.d.ts +62 -0
- package/dist/points.js +207 -0
- package/dist/quadtree.d.ts +153 -0
- package/dist/quadtree.js +587 -0
- package/dist/render.d.ts +179 -0
- package/dist/render.js +1222 -0
- package/dist/test-planet.d.ts +59 -0
- package/dist/test-planet.js +189 -0
- package/dist/testing.d.ts +42 -0
- package/dist/testing.js +96 -0
- package/dist/view.d.ts +12 -0
- package/dist/view.js +61 -0
- package/package.json +50 -0
package/dist/nav.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type Entity, type Query, type World } from '@aethervtt/shard-core';
|
|
2
|
+
import type { PlanetRuntime } from './planet.js';
|
|
3
|
+
/** NavAgents near a planet (positions in its frame), for the navmesh's frame. */
|
|
4
|
+
export declare function planetAgents(rt: PlanetRuntime, query: Query, out: Float64Array[]): number;
|
|
5
|
+
/**
|
|
6
|
+
* Keeps a planet's navmesh (spec 0043): a NavMesh in a tangent frame (up is radial) at the ground
|
|
7
|
+
* under its NavAgents, baked from the collider chunks around them (which get NavSource), within
|
|
8
|
+
* PlanetNav.radius. The frame moves (and the navmesh rebakes) once the agents' center drifts
|
|
9
|
+
* half the radius away, so its tiles stay put while agents walk and while the origin moves.
|
|
10
|
+
*/
|
|
11
|
+
export declare function updatePlanetNav(world: World, rt: PlanetRuntime, query: Query): void;
|
|
12
|
+
export declare function clearPlanetNav(world: World, rt: PlanetRuntime): void;
|
|
13
|
+
/** The planet's NavMesh entity, if it has one. */
|
|
14
|
+
export declare function planetNavMesh(rt: PlanetRuntime): Entity | undefined;
|
package/dist/nav.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { ChildOf, Derived, quat } from "@aethervtt/shard-core";
|
|
2
|
+
import { NavMesh, NavSource } from "@aethervtt/shard-nav";
|
|
3
|
+
import {
|
|
4
|
+
GlobalTransform,
|
|
5
|
+
placeInGrid,
|
|
6
|
+
propagateSubtree,
|
|
7
|
+
Transform
|
|
8
|
+
} from "@aethervtt/shard-transform";
|
|
9
|
+
import { collidersOf } from "./colliders.js";
|
|
10
|
+
import { PlanetNav } from "./components.js";
|
|
11
|
+
import { heightAt } from "./heights.js";
|
|
12
|
+
function stateOf(rt) {
|
|
13
|
+
return rt.parts.get("nav");
|
|
14
|
+
}
|
|
15
|
+
const p = new Float64Array(3);
|
|
16
|
+
const sum = new Float64Array(3);
|
|
17
|
+
function planetAgents(rt, query, out) {
|
|
18
|
+
let n = 0;
|
|
19
|
+
const s = rt.settings;
|
|
20
|
+
const reach = s.radius * Math.max(s.shape[0], s.shape[1], s.shape[2]) + rt.highest + 1e3;
|
|
21
|
+
for (const table of query.tables) {
|
|
22
|
+
const m = table.column(GlobalTransform, "matrix");
|
|
23
|
+
for (let row = 0; row < table.count; row++) {
|
|
24
|
+
rt.frame.pointToPlanet(m[row * 12 + 3], m[row * 12 + 7], m[row * 12 + 11], p);
|
|
25
|
+
if (Math.hypot(p[0], p[1], p[2]) > reach) continue;
|
|
26
|
+
if (!out[n]) out[n] = new Float64Array(3);
|
|
27
|
+
out[n].set(p);
|
|
28
|
+
n++;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return n;
|
|
32
|
+
}
|
|
33
|
+
const agents = [];
|
|
34
|
+
function updatePlanetNav(world, rt, query) {
|
|
35
|
+
const state = stateOf(rt);
|
|
36
|
+
if (!world.has(rt.entity, PlanetNav)) {
|
|
37
|
+
if (state) clearPlanetNav(world, rt);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const v = world.get(rt.entity, PlanetNav);
|
|
41
|
+
for (const chunk of collidersOf(rt).chunks.values()) {
|
|
42
|
+
if (chunk.entity >= 0 && !world.has(chunk.entity, NavSource)) world.add(chunk.entity, NavSource);
|
|
43
|
+
}
|
|
44
|
+
const n = planetAgents(rt, query, agents);
|
|
45
|
+
if (n === 0) return;
|
|
46
|
+
sum.fill(0);
|
|
47
|
+
for (let i = 0; i < n; i++) {
|
|
48
|
+
sum[0] = sum[0] + agents[i][0];
|
|
49
|
+
sum[1] = sum[1] + agents[i][1];
|
|
50
|
+
sum[2] = sum[2] + agents[i][2];
|
|
51
|
+
}
|
|
52
|
+
const key = JSON.stringify([v.agentRadius, v.agentHeight, v.maxSlope, v.radius]);
|
|
53
|
+
const cx = sum[0] / n;
|
|
54
|
+
const cy = sum[1] / n;
|
|
55
|
+
const cz = sum[2] / n;
|
|
56
|
+
if (state) {
|
|
57
|
+
const d = Math.hypot(cx - state.anchor[0], cy - state.anchor[1], cz - state.anchor[2]);
|
|
58
|
+
if (d < v.radius * 0.5 && state.key === key && world.isAlive(state.frame)) return;
|
|
59
|
+
}
|
|
60
|
+
const l = Math.hypot(cx, cy, cz) || 1;
|
|
61
|
+
const ux = cx / l;
|
|
62
|
+
const uy = cy / l;
|
|
63
|
+
const uz = cz / l;
|
|
64
|
+
const r = rt.settings.radius + heightAt(rt, ux, uy, uz);
|
|
65
|
+
const anchor = new Float64Array([ux * r, uy * r, uz * r]);
|
|
66
|
+
const side = Math.abs(uy) < 0.9 ? [0, 1, 0] : [1, 0, 0];
|
|
67
|
+
const forward = [
|
|
68
|
+
uy * side[2] - uz * side[1],
|
|
69
|
+
uz * side[0] - ux * side[2],
|
|
70
|
+
ux * side[1] - uy * side[0]
|
|
71
|
+
];
|
|
72
|
+
const rotation = quat.lookRotation([0, 0, 0, 1], forward, [ux, uy, uz]);
|
|
73
|
+
let frame = state?.frame;
|
|
74
|
+
if (frame === void 0 || !world.isAlive(frame)) {
|
|
75
|
+
frame = world.spawn([ChildOf, { parent: rt.entity }], Transform, Derived);
|
|
76
|
+
}
|
|
77
|
+
placeInGrid(world, frame, rt.entity, anchor);
|
|
78
|
+
world.set(frame, Transform, { ...world.get(frame, Transform), rotation });
|
|
79
|
+
propagateSubtree(world, frame);
|
|
80
|
+
const settings = {
|
|
81
|
+
agentRadius: v.agentRadius,
|
|
82
|
+
agentHeight: v.agentHeight,
|
|
83
|
+
maxSlope: v.maxSlope,
|
|
84
|
+
maxClimb: Math.max(0.3, v.agentHeight * 0.3),
|
|
85
|
+
// Coarser voxels than a level's: the area is hundreds of metres across.
|
|
86
|
+
cellSize: Math.max(0.3, v.agentRadius / 1.5),
|
|
87
|
+
cellHeight: 0.15,
|
|
88
|
+
tileSize: 64,
|
|
89
|
+
boundsMin: [-v.radius, -v.radius, -v.radius],
|
|
90
|
+
boundsMax: [v.radius, v.radius, v.radius],
|
|
91
|
+
frame
|
|
92
|
+
};
|
|
93
|
+
let navmesh = state?.navmesh;
|
|
94
|
+
if (navmesh === void 0 || !world.isAlive(navmesh))
|
|
95
|
+
navmesh = world.spawn([NavMesh, settings], Derived);
|
|
96
|
+
else world.set(navmesh, NavMesh, settings);
|
|
97
|
+
rt.parts.set("nav", { frame, navmesh, anchor, key });
|
|
98
|
+
}
|
|
99
|
+
function clearPlanetNav(world, rt) {
|
|
100
|
+
const state = stateOf(rt);
|
|
101
|
+
if (!state) return;
|
|
102
|
+
if (world.isAlive(state.navmesh)) world.despawn(state.navmesh);
|
|
103
|
+
if (world.isAlive(state.frame)) world.despawn(state.frame);
|
|
104
|
+
rt.parts.delete("nav");
|
|
105
|
+
}
|
|
106
|
+
function planetNavMesh(rt) {
|
|
107
|
+
return stateOf(rt)?.navmesh;
|
|
108
|
+
}
|
|
109
|
+
export {
|
|
110
|
+
clearPlanetNav,
|
|
111
|
+
planetAgents,
|
|
112
|
+
planetNavMesh,
|
|
113
|
+
updatePlanetNav
|
|
114
|
+
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Chunk shading by depth: what `terrain-lod` turns on (the material's debug mode 3). */
|
|
2
|
+
export declare const terrainLodOverlay: import("@aethervtt/shard-render").OverlayDef;
|
|
3
|
+
/** Chunk shading by dominant biome: what `terrain-biomes` turns on (debug mode 1). */
|
|
4
|
+
export declare const terrainBiomesOverlay: import("@aethervtt/shard-render").OverlayDef;
|
|
5
|
+
/** Collider chunks (their borders, on the surface) and the anchors that keep them. */
|
|
6
|
+
export declare const terrainCollidersOverlay: import("@aethervtt/shard-render").OverlayDef;
|
package/dist/overlays.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { defineOverlay } from "@aethervtt/shard-render";
|
|
2
|
+
import { chunkLayout } from "./chunk.js";
|
|
3
|
+
import { collidersOf } from "./colliders.js";
|
|
4
|
+
import { Terrain } from "./heights.js";
|
|
5
|
+
const terrainLodOverlay = defineOverlay({
|
|
6
|
+
name: "terrain-lod",
|
|
7
|
+
description: "Planet terrain shaded by LOD depth (a hue per level, darker toward edges locked to a coarser neighbor), to see where chunks split.",
|
|
8
|
+
draw: () => {
|
|
9
|
+
}
|
|
10
|
+
});
|
|
11
|
+
const terrainBiomesOverlay = defineOverlay({
|
|
12
|
+
name: "terrain-biomes",
|
|
13
|
+
description: "Planet terrain shaded flat by its dominant biome\u2019s tint, without textures.",
|
|
14
|
+
draw: () => {
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
const COLLIDER = [0.2, 1, 0.4, 1];
|
|
18
|
+
const PENDING = [1, 0.8, 0.2, 1];
|
|
19
|
+
const ANCHOR = [0.3, 0.7, 1, 1];
|
|
20
|
+
const a = new Float64Array(3);
|
|
21
|
+
const b = new Float64Array(3);
|
|
22
|
+
const terrainCollidersOverlay = defineOverlay({
|
|
23
|
+
name: "terrain-colliders",
|
|
24
|
+
description: "Planet collider chunks: each one\u2019s border on the surface (green; yellow while cached but not in use), and a circle per anchor showing the radius it keeps colliders within.",
|
|
25
|
+
draw: (world, g) => {
|
|
26
|
+
const state = world.tryResource(Terrain);
|
|
27
|
+
if (!state) return;
|
|
28
|
+
for (const rt of state.planets.values()) {
|
|
29
|
+
if (!rt.ready) continue;
|
|
30
|
+
const layout = chunkLayout(rt.settings.resolution);
|
|
31
|
+
const rp = layout.ringPoints;
|
|
32
|
+
for (const chunk of collidersOf(rt).chunks.values()) {
|
|
33
|
+
const m = chunk.mesh;
|
|
34
|
+
const p = m.data.positions;
|
|
35
|
+
const color = chunk.entity >= 0 ? COLLIDER : PENDING;
|
|
36
|
+
for (let r = 0; r < layout.ring; r++) {
|
|
37
|
+
const s = (r + 1) % layout.ring;
|
|
38
|
+
const va = layout.index[rp[r * 2] + rp[r * 2 + 1] * layout.resolution];
|
|
39
|
+
const vb = layout.index[rp[s * 2] + rp[s * 2 + 1] * layout.resolution];
|
|
40
|
+
rt.frame.pointToOrigin(
|
|
41
|
+
p[va * 3] + m.center[0],
|
|
42
|
+
p[va * 3 + 1] + m.center[1],
|
|
43
|
+
p[va * 3 + 2] + m.center[2],
|
|
44
|
+
a
|
|
45
|
+
);
|
|
46
|
+
rt.frame.pointToOrigin(
|
|
47
|
+
p[vb * 3] + m.center[0],
|
|
48
|
+
p[vb * 3 + 1] + m.center[1],
|
|
49
|
+
p[vb * 3 + 2] + m.center[2],
|
|
50
|
+
b
|
|
51
|
+
);
|
|
52
|
+
g.line(a, b, color);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
for (let i = 0; i < rt.anchors; i++) {
|
|
56
|
+
const o = i * 3;
|
|
57
|
+
rt.frame.pointToOrigin(rt.anchorPos[o], rt.anchorPos[o + 1], rt.anchorPos[o + 2], a);
|
|
58
|
+
g.sphere(a, rt.anchorRadius[i], ANCHOR);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
export {
|
|
64
|
+
terrainBiomesOverlay,
|
|
65
|
+
terrainCollidersOverlay,
|
|
66
|
+
terrainLodOverlay
|
|
67
|
+
};
|
package/dist/planet.d.ts
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { type AssetRef, type Entity, ShardError, type World } from '@aethervtt/shard-core';
|
|
2
|
+
import { type NoiseGraph } from '@aethervtt/shard-noise';
|
|
3
|
+
import { type BiomeSetValue, type BiomeTable, type BiomeValue } from './biomes.js';
|
|
4
|
+
import { PlanetFrame } from './frame.js';
|
|
5
|
+
import { NodeTree } from './quadtree.js';
|
|
6
|
+
/** Collider chunks are the first depth whose vertices are at most this far apart (m). */
|
|
7
|
+
export declare const COLLIDER_SPACING = 1;
|
|
8
|
+
/** A planet's settings as numbers, read from its component when it changes. */
|
|
9
|
+
export interface PlanetSettings {
|
|
10
|
+
radius: number;
|
|
11
|
+
shape: Float64Array;
|
|
12
|
+
heightScale: number;
|
|
13
|
+
seed: number;
|
|
14
|
+
ocean: boolean;
|
|
15
|
+
seaLevel: number;
|
|
16
|
+
resolution: number;
|
|
17
|
+
minSpacing: number;
|
|
18
|
+
errorPixels: number;
|
|
19
|
+
vertexPixels: number;
|
|
20
|
+
colliderRadius: number;
|
|
21
|
+
skirts: boolean;
|
|
22
|
+
height: AssetRef | null;
|
|
23
|
+
climate: AssetRef | null;
|
|
24
|
+
biomes: AssetRef | null;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Everything the terrain keeps per Planet entity: resolved graphs and biomes, per-depth errors, the
|
|
28
|
+
* terrain and ocean quadtrees, the planet's frame, and the collider and render state (added by
|
|
29
|
+
* those modules). `version` bumps whenever what a chunk contains changes (a graph edit, a new seed),
|
|
30
|
+
* which makes every generated chunk stale; stale chunks keep rendering until their replacement is
|
|
31
|
+
* ready, so an edit never shows a hole.
|
|
32
|
+
*/
|
|
33
|
+
export declare class PlanetRuntime {
|
|
34
|
+
readonly entity: Entity;
|
|
35
|
+
settings: PlanetSettings | undefined;
|
|
36
|
+
/** Component change tick last read. */
|
|
37
|
+
tick: number;
|
|
38
|
+
/** Bumps when chunk contents change. */
|
|
39
|
+
version: number;
|
|
40
|
+
/** What the quadtree's shape depends on (radius, shape, resolution, depth). */
|
|
41
|
+
private structureKey;
|
|
42
|
+
private contentKey;
|
|
43
|
+
height: NoiseGraph | undefined;
|
|
44
|
+
climate: NoiseGraph | undefined;
|
|
45
|
+
set: BiomeSetValue | undefined;
|
|
46
|
+
biomes: (BiomeValue | undefined)[];
|
|
47
|
+
table: BiomeTable;
|
|
48
|
+
/** Bumps when the biome table changes. */
|
|
49
|
+
biomeVersion: number;
|
|
50
|
+
private biomeKey;
|
|
51
|
+
/**
|
|
52
|
+
* Geometric error per depth (terrain), and for the flat ocean surface, as selection and morphing
|
|
53
|
+
* use them: measured, then capped by vertex spacing (`vertexPixels`).
|
|
54
|
+
*/
|
|
55
|
+
errors: Float32Array;
|
|
56
|
+
oceanErrors: Float32Array;
|
|
57
|
+
/**
|
|
58
|
+
* Multiplies errorPixels for selection and morphing: above 1 while the planet is over
|
|
59
|
+
* TerrainBudget.triangles (adjusted about 1% a frame, so morphs stay smooth).
|
|
60
|
+
*/
|
|
61
|
+
lodBias: number;
|
|
62
|
+
/** The measured errors, uncapped: how far skirts must hang. */
|
|
63
|
+
rawErrors: Float32Array;
|
|
64
|
+
rawOceanErrors: Float32Array;
|
|
65
|
+
maxDepth: number;
|
|
66
|
+
colliderDepth: number;
|
|
67
|
+
readonly tree: NodeTree;
|
|
68
|
+
readonly ocean: NodeTree;
|
|
69
|
+
readonly frame: PlanetFrame;
|
|
70
|
+
readonly selection: import("./quadtree.js").Selection;
|
|
71
|
+
readonly oceanSelection: import("./quadtree.js").Selection;
|
|
72
|
+
/** Why the planet can't generate, if it can't (bad settings). */
|
|
73
|
+
problem: ShardError | null;
|
|
74
|
+
/** What it's waiting for (a graph or biome loading), if anything. */
|
|
75
|
+
waiting: string | null;
|
|
76
|
+
/** Anchors this frame, in the planet frame. */
|
|
77
|
+
anchorPos: Float64Array<ArrayBuffer>;
|
|
78
|
+
anchorRadius: Float64Array<ArrayBuffer>;
|
|
79
|
+
anchorEntity: Entity[];
|
|
80
|
+
anchors: number;
|
|
81
|
+
/** Module state (colliders, render, nav) keyed by module. */
|
|
82
|
+
readonly parts: Map<string, unknown>;
|
|
83
|
+
constructor(entity: Entity);
|
|
84
|
+
/** Whether the planet has what it needs to generate chunks. */
|
|
85
|
+
get ready(): boolean;
|
|
86
|
+
/** Lowest the surface gets (m above radius): the horizon occluder and default bounds. */
|
|
87
|
+
get lowest(): number;
|
|
88
|
+
get highest(): number;
|
|
89
|
+
/**
|
|
90
|
+
* Brings the runtime up to date with the component and the assets it names. Cheap when nothing
|
|
91
|
+
* changed: compares a change tick and graph versions.
|
|
92
|
+
*/
|
|
93
|
+
refresh(world: World, dirty: boolean): void;
|
|
94
|
+
private heightVersion;
|
|
95
|
+
private climateVersion;
|
|
96
|
+
private report;
|
|
97
|
+
/**
|
|
98
|
+
* Caps each depth's error at the spacing that projects to `vertexPixels` wherever it projects to
|
|
99
|
+
* `errorPixels`: a node then splits only while its children's vertices stay that far apart on
|
|
100
|
+
* screen. Rough terrain otherwise keeps splitting into sub-pixel triangles. Selection and
|
|
101
|
+
* morphing read the same capped table, so morph bands still end where splits happen.
|
|
102
|
+
*/
|
|
103
|
+
private capErrors;
|
|
104
|
+
/** Vertex spacing at a depth (m). */
|
|
105
|
+
spacing(depth: number): number;
|
|
106
|
+
}
|
package/dist/planet.js
ADDED
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
import { assetServer } from "@aethervtt/shard-assets";
|
|
2
|
+
import { ShardError } from "@aethervtt/shard-core";
|
|
3
|
+
import { NoiseGraphs } from "@aethervtt/shard-noise";
|
|
4
|
+
import { LogResource } from "@aethervtt/shard-runtime";
|
|
5
|
+
import {
|
|
6
|
+
Biome,
|
|
7
|
+
BiomeSet,
|
|
8
|
+
biomeTable
|
|
9
|
+
} from "./biomes.js";
|
|
10
|
+
import { checkResolution } from "./chunk.js";
|
|
11
|
+
import { Planet } from "./components.js";
|
|
12
|
+
import { MAX_RADIUS, maxDepthFor, nodeSpacing } from "./cube.js";
|
|
13
|
+
import { PlanetFrame } from "./frame.js";
|
|
14
|
+
import { measureErrors } from "./lod.js";
|
|
15
|
+
import { createSelection, NODE_BOUNDS, NodeTree } from "./quadtree.js";
|
|
16
|
+
const COLLIDER_SPACING = 1;
|
|
17
|
+
class PlanetRuntime {
|
|
18
|
+
entity;
|
|
19
|
+
settings;
|
|
20
|
+
/** Component change tick last read. */
|
|
21
|
+
tick = -1;
|
|
22
|
+
/** Bumps when chunk contents change. */
|
|
23
|
+
version = 0;
|
|
24
|
+
/** What the quadtree's shape depends on (radius, shape, resolution, depth). */
|
|
25
|
+
structureKey = "";
|
|
26
|
+
contentKey = "";
|
|
27
|
+
height;
|
|
28
|
+
climate;
|
|
29
|
+
set;
|
|
30
|
+
biomes = [];
|
|
31
|
+
table = biomeTable(void 0, []);
|
|
32
|
+
/** Bumps when the biome table changes. */
|
|
33
|
+
biomeVersion = 0;
|
|
34
|
+
biomeKey = "";
|
|
35
|
+
/**
|
|
36
|
+
* Geometric error per depth (terrain), and for the flat ocean surface, as selection and morphing
|
|
37
|
+
* use them: measured, then capped by vertex spacing (`vertexPixels`).
|
|
38
|
+
*/
|
|
39
|
+
errors = new Float32Array(2);
|
|
40
|
+
oceanErrors = new Float32Array(2);
|
|
41
|
+
/**
|
|
42
|
+
* Multiplies errorPixels for selection and morphing: above 1 while the planet is over
|
|
43
|
+
* TerrainBudget.triangles (adjusted about 1% a frame, so morphs stay smooth).
|
|
44
|
+
*/
|
|
45
|
+
lodBias = 1;
|
|
46
|
+
/** The measured errors, uncapped: how far skirts must hang. */
|
|
47
|
+
rawErrors = new Float32Array(2);
|
|
48
|
+
rawOceanErrors = new Float32Array(2);
|
|
49
|
+
maxDepth = 0;
|
|
50
|
+
colliderDepth = 0;
|
|
51
|
+
tree = new NodeTree();
|
|
52
|
+
ocean = new NodeTree(256);
|
|
53
|
+
frame = new PlanetFrame();
|
|
54
|
+
selection = createSelection();
|
|
55
|
+
oceanSelection = createSelection();
|
|
56
|
+
/** Why the planet can't generate, if it can't (bad settings). */
|
|
57
|
+
problem = null;
|
|
58
|
+
/** What it's waiting for (a graph or biome loading), if anything. */
|
|
59
|
+
waiting = null;
|
|
60
|
+
/** Anchors this frame, in the planet frame. */
|
|
61
|
+
anchorPos = new Float64Array(0);
|
|
62
|
+
anchorRadius = new Float64Array(0);
|
|
63
|
+
anchorEntity = [];
|
|
64
|
+
anchors = 0;
|
|
65
|
+
/** Module state (colliders, render, nav) keyed by module. */
|
|
66
|
+
parts = /* @__PURE__ */ new Map();
|
|
67
|
+
constructor(entity) {
|
|
68
|
+
this.entity = entity;
|
|
69
|
+
}
|
|
70
|
+
/** Whether the planet has what it needs to generate chunks. */
|
|
71
|
+
get ready() {
|
|
72
|
+
return this.problem === null && this.waiting === null && this.settings !== void 0;
|
|
73
|
+
}
|
|
74
|
+
/** Lowest the surface gets (m above radius): the horizon occluder and default bounds. */
|
|
75
|
+
get lowest() {
|
|
76
|
+
const s = this.settings;
|
|
77
|
+
if (!s) return 0;
|
|
78
|
+
return this.height ? -s.heightScale : 0;
|
|
79
|
+
}
|
|
80
|
+
get highest() {
|
|
81
|
+
const s = this.settings;
|
|
82
|
+
if (!s) return 0;
|
|
83
|
+
return this.height ? s.heightScale : 0;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Brings the runtime up to date with the component and the assets it names. Cheap when nothing
|
|
87
|
+
* changed: compares a change tick and graph versions.
|
|
88
|
+
*/
|
|
89
|
+
refresh(world, dirty) {
|
|
90
|
+
const table = world.entityTable(this.entity);
|
|
91
|
+
const tick = table.changedTicks(Planet)[world.entityRow(this.entity)];
|
|
92
|
+
if (tick !== this.tick || !this.settings) {
|
|
93
|
+
this.tick = tick;
|
|
94
|
+
this.settings = readSettings(world.get(this.entity, Planet));
|
|
95
|
+
dirty = true;
|
|
96
|
+
}
|
|
97
|
+
const s = this.settings;
|
|
98
|
+
if (!dirty && this.waiting === null) {
|
|
99
|
+
const h = this.height;
|
|
100
|
+
const c = this.climate;
|
|
101
|
+
if ((!h || h.version === this.heightVersion) && (!c || c.version === this.climateVersion))
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
this.problem = null;
|
|
105
|
+
this.waiting = null;
|
|
106
|
+
try {
|
|
107
|
+
validate(s);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
this.report(world, err);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const height = resolve(world, s.height, (w) => w.initResource(NoiseGraphs));
|
|
113
|
+
const climate = resolve(world, s.climate, (w) => w.initResource(NoiseGraphs));
|
|
114
|
+
if (height === null || climate === null) {
|
|
115
|
+
this.waiting = height === null ? `height graph ${refName(s.height)}` : `climate graph ${refName(s.climate)}`;
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (climate && (!climate.graph.nodes.has("temperature") || !climate.graph.nodes.has("moisture"))) {
|
|
119
|
+
this.report(
|
|
120
|
+
world,
|
|
121
|
+
new ShardError(
|
|
122
|
+
"terrain/climate-outputs",
|
|
123
|
+
`Climate graph ${refName(s.climate)} needs nodes named "temperature" and "moisture"`,
|
|
124
|
+
{
|
|
125
|
+
path: refName(s.climate),
|
|
126
|
+
hint: 'Name the two climate layers "temperature" and "moisture"; each should stay in [\u22121, 1].'
|
|
127
|
+
}
|
|
128
|
+
)
|
|
129
|
+
);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const set = resolve(world, s.biomes, (w) => w.initResource(BiomeSet.store));
|
|
133
|
+
if (set === null) {
|
|
134
|
+
this.waiting = `biome set ${refName(s.biomes)}`;
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const biomes = [];
|
|
138
|
+
for (const ref of set?.biomes ?? []) {
|
|
139
|
+
const b = resolve(world, ref, (w) => w.initResource(Biome.store));
|
|
140
|
+
if (b === null) {
|
|
141
|
+
this.waiting = `biome ${refName(ref)}`;
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
biomes.push(b);
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
const biomeKey = JSON.stringify([set ?? null, biomes]);
|
|
148
|
+
if (biomeKey !== this.biomeKey) {
|
|
149
|
+
this.table = biomeTable(set, biomes);
|
|
150
|
+
this.biomeKey = biomeKey;
|
|
151
|
+
this.biomeVersion++;
|
|
152
|
+
}
|
|
153
|
+
} catch (err) {
|
|
154
|
+
this.report(world, err);
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
this.set = set;
|
|
158
|
+
this.biomes = biomes;
|
|
159
|
+
this.height = height;
|
|
160
|
+
this.climate = climate;
|
|
161
|
+
this.heightVersion = height?.version ?? -1;
|
|
162
|
+
this.climateVersion = climate?.version ?? -1;
|
|
163
|
+
const maxDepth = maxDepthFor(s.radius, s.resolution, s.minSpacing);
|
|
164
|
+
const structureKey = JSON.stringify([s.radius, [...s.shape], s.resolution, maxDepth]);
|
|
165
|
+
const contentKey = JSON.stringify([
|
|
166
|
+
structureKey,
|
|
167
|
+
height?.hash ?? null,
|
|
168
|
+
climate?.hash ?? null,
|
|
169
|
+
s.heightScale,
|
|
170
|
+
s.seed,
|
|
171
|
+
s.ocean,
|
|
172
|
+
s.seaLevel
|
|
173
|
+
]);
|
|
174
|
+
if (contentKey === this.contentKey) {
|
|
175
|
+
this.capErrors();
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
this.maxDepth = maxDepth;
|
|
179
|
+
this.colliderDepth = Math.min(
|
|
180
|
+
maxDepth,
|
|
181
|
+
maxDepthFor(s.radius, s.resolution, Math.max(COLLIDER_SPACING, s.minSpacing))
|
|
182
|
+
);
|
|
183
|
+
this.rawErrors = measureErrors({
|
|
184
|
+
radius: s.radius,
|
|
185
|
+
shape: s.shape,
|
|
186
|
+
heightScale: s.heightScale,
|
|
187
|
+
seed: s.seed,
|
|
188
|
+
resolution: s.resolution,
|
|
189
|
+
maxDepth,
|
|
190
|
+
height
|
|
191
|
+
});
|
|
192
|
+
this.rawOceanErrors = measureErrors({
|
|
193
|
+
radius: s.radius,
|
|
194
|
+
shape: s.shape,
|
|
195
|
+
heightScale: 0,
|
|
196
|
+
seed: s.seed,
|
|
197
|
+
resolution: s.resolution,
|
|
198
|
+
maxDepth,
|
|
199
|
+
height: void 0,
|
|
200
|
+
heightOffset: s.seaLevel
|
|
201
|
+
});
|
|
202
|
+
this.capErrors(false);
|
|
203
|
+
if (structureKey !== this.structureKey) {
|
|
204
|
+
this.tree.reset(s.radius, s.shape, this.lowest, this.highest);
|
|
205
|
+
this.ocean.reset(s.radius, s.shape, s.seaLevel, s.seaLevel);
|
|
206
|
+
this.structureKey = structureKey;
|
|
207
|
+
} else {
|
|
208
|
+
this.tree.lowest = this.lowest;
|
|
209
|
+
this.tree.highest = this.highest;
|
|
210
|
+
this.ocean.lowest = s.seaLevel;
|
|
211
|
+
this.ocean.highest = s.seaLevel;
|
|
212
|
+
widen(this.tree);
|
|
213
|
+
widen(this.ocean);
|
|
214
|
+
}
|
|
215
|
+
this.contentKey = contentKey;
|
|
216
|
+
this.version++;
|
|
217
|
+
}
|
|
218
|
+
heightVersion = -1;
|
|
219
|
+
climateVersion = -1;
|
|
220
|
+
report(world, err) {
|
|
221
|
+
this.problem = err;
|
|
222
|
+
world.tryResource(LogResource)?.error(err);
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Caps each depth's error at the spacing that projects to `vertexPixels` wherever it projects to
|
|
226
|
+
* `errorPixels`: a node then splits only while its children's vertices stay that far apart on
|
|
227
|
+
* screen. Rough terrain otherwise keeps splitting into sub-pixel triangles. Selection and
|
|
228
|
+
* morphing read the same capped table, so morph bands still end where splits happen.
|
|
229
|
+
*/
|
|
230
|
+
capErrors(bump = true) {
|
|
231
|
+
const s = this.settings;
|
|
232
|
+
if (!s) return;
|
|
233
|
+
const k = s.vertexPixels > 0 ? s.errorPixels / s.vertexPixels : Number.POSITIVE_INFINITY;
|
|
234
|
+
const cap = (raw, out) => {
|
|
235
|
+
const o = out.length === raw.length ? out : new Float32Array(raw.length);
|
|
236
|
+
for (let d = 0; d < raw.length; d++) o[d] = Math.min(raw[d], k * this.spacing(d));
|
|
237
|
+
for (let d = raw.length - 2; d >= 0; d--) o[d] = Math.max(o[d], o[d + 1]);
|
|
238
|
+
return o;
|
|
239
|
+
};
|
|
240
|
+
const before = this.errors;
|
|
241
|
+
const same = (a, b) => a.length === b.length && a.every((v, i) => v === b[i]);
|
|
242
|
+
const errors = cap(this.rawErrors, new Float32Array(this.rawErrors.length));
|
|
243
|
+
const ocean = cap(this.rawOceanErrors, new Float32Array(this.rawOceanErrors.length));
|
|
244
|
+
if (same(errors, before) && same(ocean, this.oceanErrors)) return;
|
|
245
|
+
this.errors = errors;
|
|
246
|
+
this.oceanErrors = ocean;
|
|
247
|
+
if (bump) this.version++;
|
|
248
|
+
}
|
|
249
|
+
/** Vertex spacing at a depth (m). */
|
|
250
|
+
spacing(depth) {
|
|
251
|
+
return nodeSpacing(this.settings?.radius ?? 1, depth, this.settings?.resolution ?? 33);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
function widen(tree) {
|
|
255
|
+
for (let n = 0; n < tree.count; n++) {
|
|
256
|
+
tree.flags[n] &= ~NODE_BOUNDS;
|
|
257
|
+
tree.minH[n] = tree.lowest;
|
|
258
|
+
tree.maxH[n] = tree.highest;
|
|
259
|
+
tree.updateSphere(n);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
function readSettings(v) {
|
|
263
|
+
return {
|
|
264
|
+
radius: v.radius,
|
|
265
|
+
shape: Float64Array.from(v.shape),
|
|
266
|
+
heightScale: v.heightScale,
|
|
267
|
+
seed: v.seed >>> 0,
|
|
268
|
+
ocean: v.ocean,
|
|
269
|
+
seaLevel: v.seaLevel,
|
|
270
|
+
resolution: v.resolution,
|
|
271
|
+
minSpacing: v.minSpacing,
|
|
272
|
+
errorPixels: v.errorPixels,
|
|
273
|
+
vertexPixels: v.vertexPixels,
|
|
274
|
+
colliderRadius: v.colliderRadius,
|
|
275
|
+
skirts: v.skirts,
|
|
276
|
+
height: v.height,
|
|
277
|
+
climate: v.climate,
|
|
278
|
+
biomes: v.biomes
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
function validate(s) {
|
|
282
|
+
if (s.radius > MAX_RADIUS) {
|
|
283
|
+
throw new ShardError(
|
|
284
|
+
"terrain/radius-too-large",
|
|
285
|
+
`Planet radius ${s.radius} m is over ${MAX_RADIUS} m (50 000 km)`,
|
|
286
|
+
{
|
|
287
|
+
hint: "Rocky planets go up to about 16 000 km; gas giants have no surface (spec 0046 renders them)."
|
|
288
|
+
}
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
checkResolution(s.resolution);
|
|
292
|
+
}
|
|
293
|
+
function refName(ref) {
|
|
294
|
+
return ref?.path ?? ref?.guid ?? "(none)";
|
|
295
|
+
}
|
|
296
|
+
function resolve(world, ref, store) {
|
|
297
|
+
if (!ref || ref.guid === void 0 && ref.path === void 0) return void 0;
|
|
298
|
+
const value = ref.guid !== void 0 ? store(world).get(ref) : void 0;
|
|
299
|
+
if (value !== void 0) return value;
|
|
300
|
+
const server = assetServer(world);
|
|
301
|
+
const entry = server.entry(ref);
|
|
302
|
+
if (entry) {
|
|
303
|
+
if (entry.state !== "failed") void server.request(entry.guid).catch(() => {
|
|
304
|
+
});
|
|
305
|
+
const loaded = store(world).get({ guid: entry.guid });
|
|
306
|
+
if (loaded !== void 0) return loaded;
|
|
307
|
+
}
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
export {
|
|
311
|
+
COLLIDER_SPACING,
|
|
312
|
+
PlanetRuntime
|
|
313
|
+
};
|
package/dist/plugin.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type World } from '@aethervtt/shard-core';
|
|
2
|
+
import type { Workers } from '@aethervtt/shard-platform';
|
|
3
|
+
import { type Plugin } from '@aethervtt/shard-runtime';
|
|
4
|
+
import { PlanetRuntime } from './planet.js';
|
|
5
|
+
export interface TerrainPluginOptions {
|
|
6
|
+
/** Where collider chunks sample (default: inline on the main thread). */
|
|
7
|
+
workers?: Workers;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Keeps a runtime per Planet (spec 0043): resolves its graphs and biomes, measures per-depth
|
|
11
|
+
* errors, tracks its frame against the floating origin, gathers anchors, and builds the collider
|
|
12
|
+
* chunks around them. Runs with or without a GPU; the render side adds chunk selection on top.
|
|
13
|
+
*/
|
|
14
|
+
export declare const updatePlanets: import("@aethervtt/shard-core").SystemDef<{
|
|
15
|
+
planets: import("@aethervtt/shard-core").Query;
|
|
16
|
+
anchors: import("./colliders.js").AnchorQueries;
|
|
17
|
+
}>;
|
|
18
|
+
/** Everything the terrain needs without a GPU: runtimes, colliders, heights. */
|
|
19
|
+
export declare function terrainPlugin(options?: TerrainPluginOptions): Plugin;
|
|
20
|
+
/** Other packages hook into the plugin here (render, overlays, and nav are built in). */
|
|
21
|
+
export declare const terrainExtensions: ((app: Parameters<Plugin['build']>[0]) => void)[];
|
|
22
|
+
export declare const terrainReadyHooks: ((app: Parameters<Plugin['build']>[0]) => Promise<void> | void)[];
|
|
23
|
+
export declare const terrainCleanups: ((world: World, rt: PlanetRuntime) => void)[];
|