lecodes-cli 0.18.1 → 0.19.0
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.js +2096 -506
- package/package.json +3 -2
- package/runtime/scene-harness.json +1 -1
- package/runtime/sdk/core/Aspect.ts +65 -9
- package/runtime/sdk/core/StateMachine.ts +308 -0
- package/runtime/sdk/core/time.ts +3 -0
- package/runtime/sdk/gl/Camera.ts +39 -0
- package/runtime/sdk/gl/Light.ts +12 -4
- package/runtime/sdk/gl/Lightmap.ts +87 -10
- package/runtime/sdk/gl/Material.ts +15 -0
- package/runtime/sdk/gl/Model.ts +68 -8
- package/runtime/sdk/gl/NavAgent.ts +337 -0
- package/runtime/sdk/gl/NavMesh.ts +397 -0
- package/runtime/sdk/gl/Ragdoll.ts +270 -0
- package/runtime/sdk/gl/Scene.ts +20 -0
- package/runtime/sdk/gl/Shape.ts +37 -5
- package/runtime/sdk/gl/Terrain.ts +1092 -0
- package/runtime/sdk/gl/Texture.ts +17 -0
- package/runtime/sdk/gl/animation/Animator.ts +35 -2
- package/runtime/sdk/gl/animation/Layer.ts +3 -1
- package/runtime/sdk/gl/animation/Loop.ts +5 -0
- package/runtime/sdk/gl/animation/Playback.ts +4 -3
- package/runtime/sdk/gl/animation/core.ts +52 -10
- package/runtime/sdk/gl/terrainMesh.ts +219 -0
- package/runtime/sdk/inject.ts +12 -1
- package/runtime/sdk/net/codec.ts +119 -0
- package/runtime/sdk/net/core.ts +384 -0
- package/runtime/sdk/net/index.ts +181 -0
- package/runtime/sdk/net/replication.ts +622 -0
- package/runtime/sdk/runtime/device.ts +102 -1
- package/runtime/sdk/scene/defineScene.ts +1353 -1227
- package/runtime/sdk/scene/editorPlugins.ts +18 -0
- package/runtime/sdk/ui/UINode.ts +4 -0
- package/runtime/sdk-types.json +1 -1
|
@@ -0,0 +1,1092 @@
|
|
|
1
|
+
// Terrain — a heightmap ground (docs/terrain-plan.md). The SDK OWNS the data: `heights` (metres per
|
|
2
|
+
// sample), `holes` (a byte per sample) and `control` (RGBA layer weights per sample) are plain typed
|
|
3
|
+
// arrays the developer edits directly or through the helpers below; `commit()` pushes the dirty
|
|
4
|
+
// rectangle to the engine (chunk renderables, the Jolt height field, the control texture). Queries
|
|
5
|
+
// (`heightAt`, `normalAt`) run in JS on the same triangle split every host draws and collides, so a
|
|
6
|
+
// game reads the ground identically on desktop, web-lite and headless.
|
|
7
|
+
//
|
|
8
|
+
// const t = Terrain.create({ size: 513, cellSize: 1, heights: (x, z) => noise(x, z) * 40, layers: [
|
|
9
|
+
// { albedo: asset('./tex/grass.ktx2'), tiling: 4 },
|
|
10
|
+
// { albedo: asset('./tex/rock.ktx2'), tiling: 6, triplanar: true },
|
|
11
|
+
// { albedo: asset('./tex/dirt.ktx2'), tiling: 5 },
|
|
12
|
+
// { albedo: asset('./tex/snow.ktx2'), tiling: 6 },
|
|
13
|
+
// ], normals: [asset('./tex/normals01.ktx2'), asset('./tex/normals23.ktx2')] })
|
|
14
|
+
// t.paintAuto({ base: 0, slope: { layer: 1, from: 30, to: 45 }, height: { layer: 3, from: 60, to: 70 } })
|
|
15
|
+
// t.conform(road, { offset: 0.05, falloff: 6 }) // the ground hugs the road mesh: cut AND fill
|
|
16
|
+
// t.hole({ circle: { x: 120, z: 80, r: 4 } }) // no triangles, no collision, no navmesh there
|
|
17
|
+
// scene.add(t.node.aspect(Shape, { heightfield: true }).aspect(Physics, { motion: 'static' }))
|
|
18
|
+
// t.commit()
|
|
19
|
+
// const y = t.heightAt(x, z) // NaN inside a hole / off the grid
|
|
20
|
+
//
|
|
21
|
+
// Coordinates: sample (0, 0) sits at the node's origin, +X across columns, +Z across rows, heights on
|
|
22
|
+
// +Y — LOCAL to `node`; move/rotate the node to place the terrain (keep its scale 1). World-space
|
|
23
|
+
// callers use `worldHeightAt` / `toLocal`.
|
|
24
|
+
//
|
|
25
|
+
// Layers: four PBR layers (albedo + per-layer tiling / roughness / triplanar) blended by `control`;
|
|
26
|
+
// normal maps come PACKED two per texture (`normals[0]` = layers 0+1: XY in RG / BA, `normals[1]` =
|
|
27
|
+
// layers 2+3) because the material's sampler budget is nine on every host — `lecodes assets` packs
|
|
28
|
+
// them; `normalScale` per layer switches the read on.
|
|
29
|
+
//
|
|
30
|
+
// Hosts: `_creator.terrainCreate` (creator-gl) builds the chunks natively; a host without it (web-lite,
|
|
31
|
+
// headless, an older build) gets the same chunks as child Mesh nodes from gl/terrainMesh.ts — the
|
|
32
|
+
// project runs everywhere, normal maps just read flat there.
|
|
33
|
+
|
|
34
|
+
import { Node } from "./Node"
|
|
35
|
+
import { Mesh } from "./Mesh"
|
|
36
|
+
import { Model } from "./Model"
|
|
37
|
+
import { Material } from "./Material"
|
|
38
|
+
import { Texture } from "./Texture"
|
|
39
|
+
import { Geometry } from "./Geometry"
|
|
40
|
+
import { Shape } from "./Shape"
|
|
41
|
+
import { Color, type ColorInput } from "../core/color"
|
|
42
|
+
import { fetch } from "../runtime/fetch"
|
|
43
|
+
import { Vec3, cx, cy, cz, type Vec3Like } from "../math/vec"
|
|
44
|
+
import {
|
|
45
|
+
buildTerrainChunk, chunkCounts, faceNormalAt, gridRaycast, heightAt as gridHeightAt, normalAt as gridNormalAt, type TerrainGrid,
|
|
46
|
+
} from "./terrainMesh"
|
|
47
|
+
|
|
48
|
+
export type TerrainLayer = {
|
|
49
|
+
/** Albedo texture (a URL / `asset()` handle, or a loaded Texture). Unset = white. */
|
|
50
|
+
albedo?: string | Texture
|
|
51
|
+
/** Metres per texture repeat. Default 8. */
|
|
52
|
+
tiling?: number
|
|
53
|
+
/** Perceptual roughness of the layer. Default 1. */
|
|
54
|
+
roughness?: number
|
|
55
|
+
/** Normal-map strength (0 = the layer's slot of the pack is not read). Default 0 — set 1 when the
|
|
56
|
+
* layer has a normal map in `normals`. */
|
|
57
|
+
normalScale?: number
|
|
58
|
+
/** Sample this layer triplanar in world space (cliff rock): no stretching on steep slopes; its
|
|
59
|
+
* normal map is then skipped. Default false. */
|
|
60
|
+
triplanar?: boolean
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type TerrainOptions = {
|
|
64
|
+
/** Samples per side (square). 257 / 513 / 1025 are the usual sizes. Default 129. */
|
|
65
|
+
size?: number
|
|
66
|
+
sizeX?: number
|
|
67
|
+
sizeZ?: number
|
|
68
|
+
/** Metres between samples. Default 1. */
|
|
69
|
+
cellSize?: number
|
|
70
|
+
/** Cells per chunk side (≤ 255). Default 64. */
|
|
71
|
+
chunk?: number
|
|
72
|
+
/** Heights in metres: an array of sizeX × sizeZ (row-major, z rows), or a function of the sample's
|
|
73
|
+
* LOCAL position in metres. Default flat 0. */
|
|
74
|
+
heights?: Float32Array | ((x: number, z: number) => number)
|
|
75
|
+
/** A byte per sample, 1 = hole (a triangle needs its three samples valid). */
|
|
76
|
+
holes?: Uint8Array
|
|
77
|
+
/** RGBA layer weights per sample (normalised in the shader). Default: all layer 0. */
|
|
78
|
+
control?: Uint8Array
|
|
79
|
+
layers?: TerrainLayer[]
|
|
80
|
+
/** The two normal-map packs: `[layers 0+1, layers 2+3]` — XY of the even layer in RG, the odd in BA. */
|
|
81
|
+
normals?: [string | Texture | undefined, (string | Texture)?]
|
|
82
|
+
/** Multiplier on every layer's albedo. */
|
|
83
|
+
tint?: ColorInput
|
|
84
|
+
name?: string
|
|
85
|
+
/** A material to use instead of `Material.terrain()` (a custom terrain shader with the same uniforms). */
|
|
86
|
+
material?: Material
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A region of the terrain in LOCAL metres (XZ). `node` = the XZ footprint of a node's triangles. */
|
|
90
|
+
export type TerrainRegion =
|
|
91
|
+
| { circle: { x: number, z: number, r: number } }
|
|
92
|
+
| { rect: { x: number, z: number, w: number, h: number } }
|
|
93
|
+
| { polygon: readonly (readonly [number, number])[] }
|
|
94
|
+
| { node: Node }
|
|
95
|
+
|
|
96
|
+
export type ConformOptions = {
|
|
97
|
+
/** How far below the target surface the ground sits (keeps bilinear terrain strictly under a
|
|
98
|
+
* curved road between samples). Default 0.05. */
|
|
99
|
+
offset?: number
|
|
100
|
+
/** Metres over which the surrounding ground blends back to its old height. Default 4; 0 = hard edge. */
|
|
101
|
+
falloff?: number
|
|
102
|
+
/** `both` (default) cuts and fills; `lower` only cuts; `raise` only fills. */
|
|
103
|
+
mode?: "both" | "lower" | "raise"
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export type RibbonOptions = {
|
|
107
|
+
/** Width in metres. */
|
|
108
|
+
width: number
|
|
109
|
+
/** Metres of ribbon per texture repeat along its length. Default = width. */
|
|
110
|
+
uvScale?: number
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** A brush dab for `sculpt` — LOCAL metres. */
|
|
114
|
+
export type SculptOptions = {
|
|
115
|
+
x: number
|
|
116
|
+
z: number
|
|
117
|
+
/** Brush radius in metres. */
|
|
118
|
+
radius: number
|
|
119
|
+
/** `raise` / `lower` by `amount` metres at full weight; `smooth` blends toward the 3×3 average
|
|
120
|
+
* (`amount` 0…1 = how far); `flatten` pulls toward `target` (default: the height under the
|
|
121
|
+
* centre) by `amount` 0…1. Default raise. */
|
|
122
|
+
mode?: "raise" | "lower" | "smooth" | "flatten"
|
|
123
|
+
amount?: number
|
|
124
|
+
/** 0…1 — the share of the radius at full weight; the rest feathers out (default 0.5). */
|
|
125
|
+
hardness?: number
|
|
126
|
+
target?: number
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** `snapshot()` — the three sample arrays, copied; `restore()` puts them back (undo, a level reset). */
|
|
130
|
+
export type TerrainSnapshot = { heights: Float32Array, holes: Uint8Array, control: Uint8Array }
|
|
131
|
+
|
|
132
|
+
/** A ray hit on the terrain (`raycast` local, `raycastWorld` world). */
|
|
133
|
+
export type TerrainHit = { point: Vec3, normal: Vec3, distance: number }
|
|
134
|
+
|
|
135
|
+
export type AutoPaintRules = {
|
|
136
|
+
/** The layer everything starts as. Default 0. */
|
|
137
|
+
base?: number
|
|
138
|
+
/** Slope rule: the layer takes over between `from` and `to` degrees of slope. */
|
|
139
|
+
slope?: { layer: number, from: number, to: number }
|
|
140
|
+
/** Height rule: the layer takes over between `from` and `to` metres (snow line). */
|
|
141
|
+
height?: { layer: number, from: number, to: number }
|
|
142
|
+
/** Low rule: the layer takes over BELOW `to` metres, fading in from `from` (valley floors). */
|
|
143
|
+
low?: { layer: number, from: number, to: number }
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** What Shape { heightfield } reads off a terrain node (`node._terrain`). */
|
|
147
|
+
export type TerrainData = {
|
|
148
|
+
sizeX: number, sizeZ: number, cellSize: number,
|
|
149
|
+
heights: Float32Array, holes: Uint8Array | null,
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
type Rect = { x0: number, z0: number, x1: number, z1: number } // inclusive sample rect
|
|
153
|
+
|
|
154
|
+
const smoothstep = (a: number, b: number, t: number): number => {
|
|
155
|
+
const x = Math.min(1, Math.max(0, (t - a) / (b - a || 1e-9)))
|
|
156
|
+
return x * x * (3 - 2 * x)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const FILE_MAGIC = 0x5245544c // 'LTER' little-endian
|
|
160
|
+
const FILE_VERSION = 1
|
|
161
|
+
|
|
162
|
+
let warnedPixels = false
|
|
163
|
+
let warnedHost = false
|
|
164
|
+
|
|
165
|
+
export class Terrain {
|
|
166
|
+
/** The node the terrain hangs off: transform it to place the ground; add it to the scene. */
|
|
167
|
+
readonly node: Node
|
|
168
|
+
readonly sizeX: number
|
|
169
|
+
readonly sizeZ: number
|
|
170
|
+
readonly cellSize: number
|
|
171
|
+
readonly chunk: number
|
|
172
|
+
/** sizeX × sizeZ heights in metres, row-major (z rows). Edit, then `commit()`. */
|
|
173
|
+
readonly heights: Float32Array
|
|
174
|
+
/** A byte per sample, 1 = hole. */
|
|
175
|
+
readonly holes: Uint8Array
|
|
176
|
+
/** RGBA layer weights per sample. */
|
|
177
|
+
readonly control: Uint8Array
|
|
178
|
+
/** The splat material (`Material.terrain()` unless one was passed). */
|
|
179
|
+
readonly material: Material
|
|
180
|
+
/** Resolves when every layer texture has loaded (the terrain renders white until then). */
|
|
181
|
+
readonly ready: Promise<void>
|
|
182
|
+
layers: TerrainLayer[]
|
|
183
|
+
|
|
184
|
+
private _native = false
|
|
185
|
+
private _chunkMeshes: (Mesh | null)[] = []
|
|
186
|
+
private _controlTex: Texture | null = null
|
|
187
|
+
private _dirty: Rect | null = null
|
|
188
|
+
private _holesTouched = false
|
|
189
|
+
private _controlDirty: Rect | null = null
|
|
190
|
+
private _anyHole = false
|
|
191
|
+
private _cast = true
|
|
192
|
+
private _receive = true
|
|
193
|
+
|
|
194
|
+
private constructor(opts: TerrainOptions) {
|
|
195
|
+
this.sizeX = Math.max(2, Math.floor(opts.sizeX ?? opts.size ?? 129))
|
|
196
|
+
this.sizeZ = Math.max(2, Math.floor(opts.sizeZ ?? opts.size ?? 129))
|
|
197
|
+
this.cellSize = opts.cellSize ?? 1
|
|
198
|
+
this.chunk = Math.min(255, Math.max(1, Math.floor(opts.chunk ?? 64)))
|
|
199
|
+
const n = this.sizeX * this.sizeZ
|
|
200
|
+
this.heights = new Float32Array(n)
|
|
201
|
+
if (opts.heights instanceof Float32Array) this.heights.set(opts.heights.subarray(0, n))
|
|
202
|
+
else if (typeof opts.heights === "function") {
|
|
203
|
+
const f = opts.heights
|
|
204
|
+
for (let z = 0; z < this.sizeZ; z++) for (let x = 0; x < this.sizeX; x++) this.heights[z * this.sizeX + x] = f(x * this.cellSize, z * this.cellSize)
|
|
205
|
+
}
|
|
206
|
+
this.holes = new Uint8Array(n)
|
|
207
|
+
if (opts.holes) { this.holes.set(opts.holes.subarray(0, n)); this._anyHole = this.holes.some((v) => v !== 0) }
|
|
208
|
+
this.control = new Uint8Array(n * 4)
|
|
209
|
+
if (opts.control) this.control.set(opts.control.subarray(0, n * 4))
|
|
210
|
+
else for (let i = 0; i < n; i++) this.control[i * 4] = 255
|
|
211
|
+
this.layers = (opts.layers ?? []).slice(0, 4)
|
|
212
|
+
this.material = opts.material ?? Material.terrain()
|
|
213
|
+
this.material.set("gridSize", [ this.sizeX, this.sizeZ ]).set("cellSize", this.cellSize)
|
|
214
|
+
if (opts.tint !== undefined) this.material.set("tint", Color.toHexString(opts.tint))
|
|
215
|
+
|
|
216
|
+
this.node = new Node()
|
|
217
|
+
if (opts.name !== undefined) this.node.name = opts.name
|
|
218
|
+
;(this.node as unknown as { _terrain: Terrain })._terrain = this
|
|
219
|
+
this._build()
|
|
220
|
+
this._uploadControl(null)
|
|
221
|
+
this.ready = this._applyLayers(opts.normals)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Build a terrain from data / a generator (synchronous; `ready` resolves once its textures loaded). */
|
|
225
|
+
static create(opts: TerrainOptions = {}): Terrain { return new Terrain(opts) }
|
|
226
|
+
|
|
227
|
+
/** The terrain a node carries (the node `Terrain.create` returned), or null. */
|
|
228
|
+
static of(node: Node | null | undefined): Terrain | null {
|
|
229
|
+
return (node as unknown as { _terrain?: Terrain } | null | undefined)?._terrain ?? null
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** Load a `.terrain` file (`Terrain.serialize` / the editor / `lecodes terrain import`); layer textures
|
|
233
|
+
* are not in the file — pass them in `opts` (they are asset handles the file cannot hold). */
|
|
234
|
+
static async load(url: string, opts: Omit<TerrainOptions, "size" | "sizeX" | "sizeZ" | "heights" | "holes" | "control"> = {}): Promise<Terrain> {
|
|
235
|
+
const resp = await fetch(url, { useOnce: true })
|
|
236
|
+
if (resp.status >= 400) throw new Error(`Terrain: could not fetch ${url} (HTTP ${resp.status})`)
|
|
237
|
+
if (!_creatorUtils.fetchToBytes) { resp.dispose(); throw new Error("Terrain.load needs a newer host (no binary fetch reader)") }
|
|
238
|
+
const bytes = _creatorUtils.fetchToBytes((resp as unknown as { _id: number })._id)
|
|
239
|
+
resp.dispose()
|
|
240
|
+
const data = Terrain.parse(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer)
|
|
241
|
+
const layers = opts.layers ?? data.layers
|
|
242
|
+
return new Terrain({ ...opts, ...data, layers, cellSize: opts.cellSize ?? data.cellSize, chunk: opts.chunk ?? data.chunk })
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ---- queries (LOCAL metres) --------------------------------------------------------------------
|
|
246
|
+
|
|
247
|
+
private get _grid(): TerrainGrid {
|
|
248
|
+
return { sizeX: this.sizeX, sizeZ: this.sizeZ, cellSize: this.cellSize, heights: this.heights, holes: this._anyHole ? this.holes : null }
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Height at a local (x, z) on the drawn triangles; NaN in a hole or outside the grid. */
|
|
252
|
+
heightAt(x: number, z: number): number { return gridHeightAt(this._grid, x, z) }
|
|
253
|
+
|
|
254
|
+
/** The drawn triangle's normal at a local (x, z) (unit Vec3, +Y up). */
|
|
255
|
+
normalAt(x: number, z: number): Vec3 {
|
|
256
|
+
const n = faceNormalAt(this._grid, x, z)
|
|
257
|
+
return new Vec3(n[0], n[1], n[2])
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** The smooth (vertex) normal at integer sample (ix, iz). */
|
|
261
|
+
sampleNormal(ix: number, iz: number): Vec3 {
|
|
262
|
+
const n = gridNormalAt(this._grid, ix, iz)
|
|
263
|
+
return new Vec3(n[0], n[1], n[2])
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Extent in local metres: `[width, depth]`. */
|
|
267
|
+
get extent(): [number, number] { return [ (this.sizeX - 1) * this.cellSize, (this.sizeZ - 1) * this.cellSize ] }
|
|
268
|
+
|
|
269
|
+
/** Min / max height over the grid. */
|
|
270
|
+
get heightRange(): [number, number] {
|
|
271
|
+
let lo = Infinity, hi = -Infinity
|
|
272
|
+
for (let i = 0; i < this.heights.length; i++) { const h = this.heights[i]; if (h < lo) lo = h; if (h > hi) hi = h }
|
|
273
|
+
return [ lo, hi ]
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** A world-space point into the terrain's local frame. */
|
|
277
|
+
toLocal(p: Vec3Like): Vec3 {
|
|
278
|
+
const inv = invertAffine(this._world())
|
|
279
|
+
return transformPoint(inv, cx(p), cy(p), cz(p))
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** A local point into world space. */
|
|
283
|
+
toWorld(p: Vec3Like): Vec3 { return transformPoint(this._world(), cx(p), cy(p), cz(p)) }
|
|
284
|
+
|
|
285
|
+
/** The ground's WORLD height under a world (x, z): NaN off the grid / in a hole. */
|
|
286
|
+
worldHeightAt(x: number, z: number): number {
|
|
287
|
+
const l = this.toLocal([ x, 0, z ])
|
|
288
|
+
const h = this.heightAt(l.x, l.z)
|
|
289
|
+
if (Number.isNaN(h)) return NaN
|
|
290
|
+
return this.toWorld([ l.x, h, l.z ]).y
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Nearest hit of a LOCAL ray on the drawn triangles (holes let it through), null on a miss.
|
|
294
|
+
* Exact against what is rendered — the editor's brush and a host without physics pick with it. */
|
|
295
|
+
raycast(origin: Vec3Like, dir: Vec3Like, maxDistance = 1e5): TerrainHit | null {
|
|
296
|
+
let dx = cx(dir), dy = cy(dir), dz = cz(dir)
|
|
297
|
+
const l = Math.hypot(dx, dy, dz)
|
|
298
|
+
if (!(l > 0)) return null
|
|
299
|
+
dx /= l; dy /= l; dz /= l
|
|
300
|
+
const h = gridRaycast(this._grid, cx(origin), cy(origin), cz(origin), dx, dy, dz, maxDistance)
|
|
301
|
+
return h ? { point: new Vec3(h.x, h.y, h.z), normal: new Vec3(h.nx, h.ny, h.nz), distance: h.distance } : null
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** `raycast` in WORLD space — through the node's transform (rotation + uniform scale). */
|
|
305
|
+
raycastWorld(origin: Vec3Like, dir: Vec3Like, maxDistance = 1e5): TerrainHit | null {
|
|
306
|
+
const o = this.toLocal(origin)
|
|
307
|
+
const p = this.toLocal([ cx(origin) + cx(dir), cy(origin) + cy(dir), cz(origin) + cz(dir) ])
|
|
308
|
+
const ldx = p.x - o.x, ldy = p.y - o.y, ldz = p.z - o.z
|
|
309
|
+
const scale = Math.hypot(ldx, ldy, ldz) / (Math.hypot(cx(dir), cy(dir), cz(dir)) || 1) // local units per world metre
|
|
310
|
+
const hit = this.raycast(o, [ ldx, ldy, ldz ], maxDistance * (scale || 1))
|
|
311
|
+
if (!hit) return null
|
|
312
|
+
const wp = this.toWorld(hit.point)
|
|
313
|
+
const wn = this.toWorld([ hit.point.x + hit.normal.x, hit.point.y + hit.normal.y, hit.point.z + hit.normal.z ])
|
|
314
|
+
const nx = wn.x - wp.x, ny = wn.y - wp.y, nz = wn.z - wp.z
|
|
315
|
+
const nl = Math.hypot(nx, ny, nz) || 1
|
|
316
|
+
return {
|
|
317
|
+
point: wp,
|
|
318
|
+
normal: new Vec3(nx / nl, ny / nl, nz / nl),
|
|
319
|
+
distance: Math.hypot(wp.x - cx(origin), wp.y - cy(origin), wp.z - cz(origin)),
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ---- edits (mark dirty; nothing reaches the engine until commit) --------------------------------
|
|
324
|
+
|
|
325
|
+
/** Write a rectangle of samples (`src` = w × h heights, row-major). */
|
|
326
|
+
setHeights(x0: number, z0: number, w: number, h: number, src: ArrayLike<number>): this {
|
|
327
|
+
for (let z = 0; z < h; z++) {
|
|
328
|
+
const zz = z0 + z
|
|
329
|
+
if (zz < 0 || zz >= this.sizeZ) continue
|
|
330
|
+
for (let x = 0; x < w; x++) {
|
|
331
|
+
const xx = x0 + x
|
|
332
|
+
if (xx < 0 || xx >= this.sizeX) continue
|
|
333
|
+
this.heights[zz * this.sizeX + xx] = src[z * w + x]
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
this._touch(x0, z0, x0 + w - 1, z0 + h - 1)
|
|
337
|
+
return this
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Apply `fn(oldHeight, weight, x, z)` over a region; `weight` is 1 inside, fading to 0 over
|
|
341
|
+
* `falloff` metres outside it. The building block of flatten / raise / hole. */
|
|
342
|
+
shape(region: TerrainRegion, fn: (h: number, w: number, x: number, z: number) => number, falloff = 0): this {
|
|
343
|
+
const r = this._regionRect(region, falloff)
|
|
344
|
+
if (!r) return this
|
|
345
|
+
const weight = this._regionWeights(region, r, falloff)
|
|
346
|
+
let k = 0
|
|
347
|
+
for (let iz = r.z0; iz <= r.z1; iz++) {
|
|
348
|
+
for (let ix = r.x0; ix <= r.x1; ix++, k++) {
|
|
349
|
+
const w = weight[k]
|
|
350
|
+
if (w <= 0) continue
|
|
351
|
+
const i = iz * this.sizeX + ix
|
|
352
|
+
this.heights[i] = fn(this.heights[i], w, ix * this.cellSize, iz * this.cellSize)
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
this._touch(r.x0, r.z0, r.x1, r.z1)
|
|
356
|
+
return this
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** Flatten a region to `height` (a plateau / a building pad), blending over `falloff` metres. */
|
|
360
|
+
flatten(region: TerrainRegion, height: number, opts: { falloff?: number } = {}): this {
|
|
361
|
+
return this.shape(region, (h, w) => h + (height - h) * w, opts.falloff ?? 4)
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/** Raise (or lower, negative) a region by `delta` metres, blending over `falloff` metres. */
|
|
365
|
+
raise(region: TerrainRegion, delta: number, opts: { falloff?: number } = {}): this {
|
|
366
|
+
return this.shape(region, (h, w) => h + delta * w, opts.falloff ?? 4)
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** Copies of heights / holes / control — an undo point or a level reset. */
|
|
370
|
+
snapshot(): TerrainSnapshot {
|
|
371
|
+
return { heights: this.heights.slice(), holes: this.holes.slice(), control: this.control.slice() }
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Put a `snapshot()` back (same grid size) — everything marked dirty for the next `commit()`. */
|
|
375
|
+
restore(s: TerrainSnapshot): this {
|
|
376
|
+
const n = this.sizeX * this.sizeZ
|
|
377
|
+
if (s.heights.length !== n || s.holes.length !== n || s.control.length !== n * 4) throw new Error("Terrain.restore: snapshot size mismatch")
|
|
378
|
+
this.heights.set(s.heights)
|
|
379
|
+
this.holes.set(s.holes)
|
|
380
|
+
this.control.set(s.control)
|
|
381
|
+
this._anyHole = this.holes.some((v) => v !== 0)
|
|
382
|
+
this._holesTouched = true
|
|
383
|
+
this._touch(0, 0, this.sizeX - 1, this.sizeZ - 1)
|
|
384
|
+
this._touchControl(0, 0, this.sizeX - 1, this.sizeZ - 1)
|
|
385
|
+
return this
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** One brush dab: raise / lower / smooth / flatten a disc of `radius` metres around (x, z), feathered
|
|
389
|
+
* by `hardness` — the editor's sculpt tool and runtime deformation (craters, digging) share it. */
|
|
390
|
+
sculpt(opts: SculptOptions): this {
|
|
391
|
+
const hardness = Math.min(1, Math.max(0, opts.hardness ?? 0.5))
|
|
392
|
+
const inner = opts.radius * hardness, falloff = Math.max(0, opts.radius - inner)
|
|
393
|
+
const region: TerrainRegion = { circle: { x: opts.x, z: opts.z, r: inner } }
|
|
394
|
+
const amount = opts.amount ?? 1
|
|
395
|
+
switch (opts.mode ?? "raise") {
|
|
396
|
+
case "raise": return this.shape(region, (h, w) => h + amount * w, falloff)
|
|
397
|
+
case "lower": return this.shape(region, (h, w) => h - amount * w, falloff)
|
|
398
|
+
case "flatten": {
|
|
399
|
+
let target = opts.target ?? this.heightAt(opts.x, opts.z)
|
|
400
|
+
if (Number.isNaN(target)) {
|
|
401
|
+
// the centre is in a hole / off-grid: level to the disc's mean instead
|
|
402
|
+
let sum = 0, n = 0
|
|
403
|
+
this.shape(region, (h) => { sum += h; n++; return h }, falloff)
|
|
404
|
+
target = n ? sum / n : 0
|
|
405
|
+
}
|
|
406
|
+
const k = Math.min(1, Math.max(0, amount))
|
|
407
|
+
return this.shape(region, (h, w) => h + (target - h) * k * w, falloff)
|
|
408
|
+
}
|
|
409
|
+
case "smooth": {
|
|
410
|
+
const r = this._regionRect(region, falloff)
|
|
411
|
+
if (!r) return this
|
|
412
|
+
// blur reads a snapshot (the rect + one ring), so the pass is order-independent
|
|
413
|
+
const x0 = Math.max(0, r.x0 - 1), z0 = Math.max(0, r.z0 - 1), x1 = Math.min(this.sizeX - 1, r.x1 + 1), z1 = Math.min(this.sizeZ - 1, r.z1 + 1)
|
|
414
|
+
const w = x1 - x0 + 1, hgt = z1 - z0 + 1
|
|
415
|
+
const src = new Float32Array(w * hgt)
|
|
416
|
+
for (let iz = z0; iz <= z1; iz++) for (let ix = x0; ix <= x1; ix++) src[(iz - z0) * w + (ix - x0)] = this.heights[iz * this.sizeX + ix]
|
|
417
|
+
const k = Math.min(1, Math.max(0, amount))
|
|
418
|
+
const c = this.cellSize
|
|
419
|
+
return this.shape(region, (h, wt, px, pz) => {
|
|
420
|
+
const ix = Math.round(px / c), iz = Math.round(pz / c)
|
|
421
|
+
let sum = 0, n = 0
|
|
422
|
+
for (let dz = -1; dz <= 1; dz++) for (let dx = -1; dx <= 1; dx++) {
|
|
423
|
+
const sx = ix + dx, sz = iz + dz
|
|
424
|
+
if (sx < x0 || sx > x1 || sz < z0 || sz > z1) continue
|
|
425
|
+
sum += src[(sz - z0) * w + (sx - x0)]; n++
|
|
426
|
+
}
|
|
427
|
+
return n ? h + (sum / n - h) * k * wt : h
|
|
428
|
+
}, falloff)
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
return this
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** Cut a hole (no triangles, no collision, no navmesh) — or fill one back with `on = false`. */
|
|
435
|
+
hole(region: TerrainRegion, on = true): this {
|
|
436
|
+
const r = this._regionRect(region, 0)
|
|
437
|
+
if (!r) return this
|
|
438
|
+
const weight = this._regionWeights(region, r, 0)
|
|
439
|
+
let k = 0
|
|
440
|
+
for (let iz = r.z0; iz <= r.z1; iz++) {
|
|
441
|
+
for (let ix = r.x0; ix <= r.x1; ix++, k++) {
|
|
442
|
+
if (weight[k] <= 0) continue
|
|
443
|
+
this.holes[iz * this.sizeX + ix] = on ? 1 : 0
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
this._holesTouched = true
|
|
447
|
+
this._anyHole = on || this.holes.some((v) => v !== 0)
|
|
448
|
+
this._touch(r.x0, r.z0, r.x1, r.z1)
|
|
449
|
+
return this
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Conform the ground to a mesh — the terrain takes the target's TOP surface minus `offset` under
|
|
454
|
+
* its footprint (a road sits exactly on it, nothing pokes through), and blends back to the old
|
|
455
|
+
* ground over `falloff` metres around it. `target` = a node (a Mesh, a Model, or a node holding
|
|
456
|
+
* them — every triangle in the subtree counts), a Geometry in local coordinates, or a raw soup
|
|
457
|
+
* (9 floats per triangle, local). Call again after the target moves.
|
|
458
|
+
*/
|
|
459
|
+
conform(target: Node | Geometry | Float32Array, opts: ConformOptions = {}): this {
|
|
460
|
+
const tris = this._triangles(target)
|
|
461
|
+
if (tris.length < 9) return this
|
|
462
|
+
const offset = opts.offset ?? 0.05
|
|
463
|
+
const falloff = Math.max(0, opts.falloff ?? 4)
|
|
464
|
+
const mode = opts.mode ?? "both"
|
|
465
|
+
const cell = this.cellSize
|
|
466
|
+
const sx = this.sizeX, sz = this.sizeZ
|
|
467
|
+
const inside = new Uint8Array(sx * sz)
|
|
468
|
+
const target2 = new Float32Array(sx * sz).fill(Infinity)
|
|
469
|
+
let fx0 = sx, fz0 = sz, fx1 = -1, fz1 = -1
|
|
470
|
+
// 1. every sample under a triangle takes the LOWEST covering surface: nothing of the target may
|
|
471
|
+
// end up below ground (overlapping road quads at a bend, a building's floor rather than its roof)
|
|
472
|
+
for (let t = 0; t + 8 < tris.length; t += 9) {
|
|
473
|
+
const ax = tris[t], ay = tris[t + 1], az = tris[t + 2]
|
|
474
|
+
const bx = tris[t + 3], by = tris[t + 4], bz = tris[t + 5]
|
|
475
|
+
const qx = tris[t + 6], qy = tris[t + 7], qz = tris[t + 8]
|
|
476
|
+
const minX = Math.max(0, Math.ceil(Math.min(ax, bx, qx) / cell)), maxX = Math.min(sx - 1, Math.floor(Math.max(ax, bx, qx) / cell))
|
|
477
|
+
const minZ = Math.max(0, Math.ceil(Math.min(az, bz, qz) / cell)), maxZ = Math.min(sz - 1, Math.floor(Math.max(az, bz, qz) / cell))
|
|
478
|
+
if (minX > maxX || minZ > maxZ) continue
|
|
479
|
+
const det = (bx - ax) * (qz - az) - (qx - ax) * (bz - az)
|
|
480
|
+
if (Math.abs(det) < 1e-12) continue // vertical / degenerate in XZ
|
|
481
|
+
for (let iz = minZ; iz <= maxZ; iz++) {
|
|
482
|
+
const pz = iz * cell
|
|
483
|
+
for (let ix = minX; ix <= maxX; ix++) {
|
|
484
|
+
const px = ix * cell
|
|
485
|
+
const l1 = ((bx - ax) * (pz - az) - (px - ax) * (bz - az)) / det // weight of q
|
|
486
|
+
const l2 = ((px - ax) * (qz - az) - (qx - ax) * (pz - az)) / det // weight of b
|
|
487
|
+
const l0 = 1 - l1 - l2
|
|
488
|
+
if (l0 < -1e-6 || l1 < -1e-6 || l2 < -1e-6) continue
|
|
489
|
+
const h = l0 * ay + l2 * by + l1 * qy
|
|
490
|
+
const i = iz * sx + ix
|
|
491
|
+
if (h < target2[i]) target2[i] = h
|
|
492
|
+
if (!inside[i]) { inside[i] = 1; if (ix < fx0) fx0 = ix; if (ix > fx1) fx1 = ix; if (iz < fz0) fz0 = iz; if (iz > fz1) fz1 = iz }
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
if (fx1 < 0) return this
|
|
497
|
+
const apply = (i: number, target: number): void => {
|
|
498
|
+
const h = this.heights[i]
|
|
499
|
+
this.heights[i] = mode === "lower" ? Math.min(h, target) : mode === "raise" ? Math.max(h, target) : target
|
|
500
|
+
}
|
|
501
|
+
// 2. the footprint itself
|
|
502
|
+
for (let iz = fz0; iz <= fz1; iz++) for (let ix = fx0; ix <= fx1; ix++) {
|
|
503
|
+
const i = iz * sx + ix
|
|
504
|
+
if (inside[i]) { target2[i] -= offset; apply(i, target2[i]) }
|
|
505
|
+
}
|
|
506
|
+
// 3. the ring: every sample outside the footprint but within `falloff` of the target's BOUNDARY
|
|
507
|
+
// blends from the boundary's own height (− offset) back to the old ground. The distance is
|
|
508
|
+
// measured to the triangle edges in XZ, not to the nearest inside sample — the first outside
|
|
509
|
+
// sample is usually a fraction of a cell from the road edge, and anchoring it there is what
|
|
510
|
+
// keeps the ground under the edge itself at road level (docs/terrain-plan.md §4.1). The first
|
|
511
|
+
// CELL outside is a flat shoulder (weight 0): the terrain triangle straddling the edge then
|
|
512
|
+
// interpolates between two samples at road level, so the edge is exact even where the natural
|
|
513
|
+
// ground is tens of metres away — a blend starting AT the edge would leak a few percent of that
|
|
514
|
+
// difference back onto the edge. Edges are binned on a coarse grid so a long road stays cheap.
|
|
515
|
+
const shoulder = cell
|
|
516
|
+
const ring = Math.max(falloff, shoulder)
|
|
517
|
+
const R = Math.ceil(ring / cell)
|
|
518
|
+
{
|
|
519
|
+
const coarse = Math.max(ring, cell * 2)
|
|
520
|
+
const bins = new Map<number, number[]>() // coarse cell → edge indices (start float offset)
|
|
521
|
+
const edges: number[] = [] // ax, az, ay, bx, bz, by per edge
|
|
522
|
+
const key = (cx0: number, cz0: number): number => cz0 * 65536 + cx0
|
|
523
|
+
const addEdge = (ax: number, ay: number, az: number, bx: number, by: number, bz: number): void => {
|
|
524
|
+
const e = edges.length
|
|
525
|
+
edges.push(ax, az, ay, bx, bz, by)
|
|
526
|
+
const cx0 = Math.floor(Math.min(ax, bx) / coarse), cx1 = Math.floor(Math.max(ax, bx) / coarse)
|
|
527
|
+
const cz0 = Math.floor(Math.min(az, bz) / coarse), cz1 = Math.floor(Math.max(az, bz) / coarse)
|
|
528
|
+
for (let cz = cz0; cz <= cz1; cz++) for (let cxx = cx0; cxx <= cx1; cxx++) {
|
|
529
|
+
const k = key(cxx, cz)
|
|
530
|
+
const list = bins.get(k)
|
|
531
|
+
if (list) list.push(e); else bins.set(k, [ e ])
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
for (let t = 0; t + 8 < tris.length; t += 9) {
|
|
535
|
+
addEdge(tris[t], tris[t + 1], tris[t + 2], tris[t + 3], tris[t + 4], tris[t + 5])
|
|
536
|
+
addEdge(tris[t + 3], tris[t + 4], tris[t + 5], tris[t + 6], tris[t + 7], tris[t + 8])
|
|
537
|
+
addEdge(tris[t + 6], tris[t + 7], tris[t + 8], tris[t], tris[t + 1], tris[t + 2])
|
|
538
|
+
}
|
|
539
|
+
const rx0 = Math.max(0, fx0 - R), rz0 = Math.max(0, fz0 - R), rx1 = Math.min(sx - 1, fx1 + R), rz1 = Math.min(sz - 1, fz1 + R)
|
|
540
|
+
for (let iz = rz0; iz <= rz1; iz++) {
|
|
541
|
+
const pz = iz * cell
|
|
542
|
+
for (let ix = rx0; ix <= rx1; ix++) {
|
|
543
|
+
const i = iz * sx + ix
|
|
544
|
+
if (inside[i]) continue
|
|
545
|
+
const px = ix * cell
|
|
546
|
+
let best = Infinity, bestH = 0
|
|
547
|
+
const ccx = Math.floor(px / coarse), ccz = Math.floor(pz / coarse)
|
|
548
|
+
for (let dz = -1; dz <= 1; dz++) for (let dx = -1; dx <= 1; dx++) {
|
|
549
|
+
const list = bins.get(key(ccx + dx, ccz + dz))
|
|
550
|
+
if (!list) continue
|
|
551
|
+
for (const e of list) {
|
|
552
|
+
const ax = edges[e], az = edges[e + 1], ay = edges[e + 2], bx = edges[e + 3], bz = edges[e + 4], by = edges[e + 5]
|
|
553
|
+
const ex = bx - ax, ez = bz - az
|
|
554
|
+
const l2 = ex * ex + ez * ez
|
|
555
|
+
const u = l2 > 0 ? Math.min(1, Math.max(0, ((px - ax) * ex + (pz - az) * ez) / l2)) : 0
|
|
556
|
+
const qx = ax + ex * u, qz = az + ez * u
|
|
557
|
+
const d2 = (px - qx) * (px - qx) + (pz - qz) * (pz - qz)
|
|
558
|
+
if (d2 < best) { best = d2; bestH = ay + (by - ay) * u }
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
if (best === Infinity) continue
|
|
562
|
+
const d = Math.sqrt(best)
|
|
563
|
+
if (d >= ring) continue
|
|
564
|
+
const target = bestH - offset
|
|
565
|
+
// 0 across the shoulder → 1 at falloff
|
|
566
|
+
const s = d <= shoulder ? 0 : falloff > shoulder ? smoothstep(0, 1, (d - shoulder) / (falloff - shoulder)) : 1
|
|
567
|
+
apply(i, target + (this.heights[i] - target) * s)
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
this._touch(rx0, rz0, rx1, rz1)
|
|
571
|
+
}
|
|
572
|
+
return this
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/** `conform` to a ribbon along `points` (world/local metres, see `ribbon`) — the road stamp. */
|
|
576
|
+
conformPath(points: readonly Vec3Like[], opts: ConformOptions & RibbonOptions): this {
|
|
577
|
+
const g = Terrain.ribbon(points, opts)
|
|
578
|
+
return this.conform(g, opts)
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* A flat ribbon Geometry along a polyline (the road mesh AND the conform target): `width` metres,
|
|
583
|
+
* vertices take the points' own Y, UV0 = (across 0…1, along / uvScale). Build it from heights you
|
|
584
|
+
* sampled with `heightAt` and smoothed, then `Mesh.from(geometry, { material })` + `conform`.
|
|
585
|
+
* It also carries a lightmap UV (uv1: along 0…1 × across 0…width/length — the strip's true aspect,
|
|
586
|
+
* so `lecodes lightmap bake` gives it uniform texels), which makes the road a bake receiver.
|
|
587
|
+
*/
|
|
588
|
+
static ribbon(points: readonly Vec3Like[], opts: RibbonOptions): Geometry {
|
|
589
|
+
const n = points.length
|
|
590
|
+
if (n < 2) throw new Error("Terrain.ribbon needs at least two points")
|
|
591
|
+
const half = opts.width / 2
|
|
592
|
+
const uvScale = opts.uvScale ?? opts.width
|
|
593
|
+
const verts = new Float32Array(n * 6), normals = new Float32Array(n * 6), uv = new Float32Array(n * 4)
|
|
594
|
+
const uv1 = new Float32Array(n * 4)
|
|
595
|
+
let along = 0
|
|
596
|
+
for (let i = 0; i < n; i++) {
|
|
597
|
+
const p = points[i]
|
|
598
|
+
const prev = points[Math.max(0, i - 1)], next = points[Math.min(n - 1, i + 1)]
|
|
599
|
+
let tx = cx(next) - cx(prev), tz = cz(next) - cz(prev)
|
|
600
|
+
const tl = Math.hypot(tx, tz) || 1
|
|
601
|
+
tx /= tl; tz /= tl
|
|
602
|
+
const sxv = -tz * half, szv = tx * half
|
|
603
|
+
if (i > 0) along += Math.hypot(cx(p) - cx(prev), cy(p) - cy(prev), cz(p) - cz(prev))
|
|
604
|
+
const o = i * 6
|
|
605
|
+
verts[o] = cx(p) - sxv; verts[o + 1] = cy(p); verts[o + 2] = cz(p) - szv
|
|
606
|
+
verts[o + 3] = cx(p) + sxv; verts[o + 4] = cy(p); verts[o + 5] = cz(p) + szv
|
|
607
|
+
normals[o + 1] = 1; normals[o + 4] = 1
|
|
608
|
+
uv[i * 4] = 0; uv[i * 4 + 1] = along / uvScale; uv[i * 4 + 2] = 1; uv[i * 4 + 3] = along / uvScale
|
|
609
|
+
uv1[i * 4] = along; uv1[i * 4 + 2] = along // scaled to 0…1 below, once the length is known
|
|
610
|
+
}
|
|
611
|
+
const length = along || 1, across = opts.width / length
|
|
612
|
+
for (let i = 0; i < n; i++) { uv1[i * 4] /= length; uv1[i * 4 + 1] = 0; uv1[i * 4 + 2] /= length; uv1[i * 4 + 3] = across }
|
|
613
|
+
const idx = new Uint16Array((n - 1) * 6)
|
|
614
|
+
for (let i = 0; i + 1 < n; i++) {
|
|
615
|
+
const a = i * 2, b = a + 1, c = a + 2, d = a + 3
|
|
616
|
+
// left = a/c, right = b/d; CCW seen from +Y: (a, d, c), (a, b, d)
|
|
617
|
+
idx.set([ a, d, c, a, b, d ], i * 6)
|
|
618
|
+
}
|
|
619
|
+
// segment normals from the geometry (a banked or climbing ribbon): (d − a) × (c − a) faces up
|
|
620
|
+
for (let i = 0; i + 1 < n; i++) {
|
|
621
|
+
const a = i * 2, c = a + 2, d = a + 3
|
|
622
|
+
const ux = verts[d * 3] - verts[a * 3], uy = verts[d * 3 + 1] - verts[a * 3 + 1], uz = verts[d * 3 + 2] - verts[a * 3 + 2]
|
|
623
|
+
const vx = verts[c * 3] - verts[a * 3], vy = verts[c * 3 + 1] - verts[a * 3 + 1], vz = verts[c * 3 + 2] - verts[a * 3 + 2]
|
|
624
|
+
let nx = uy * vz - uz * vy, ny = uz * vx - ux * vz, nz = ux * vy - uy * vx
|
|
625
|
+
const l = Math.hypot(nx, ny, nz) || 1
|
|
626
|
+
nx /= l; ny /= l; nz /= l
|
|
627
|
+
for (const v of [ a, a + 1, c, d ]) { normals[v * 3] = nx; normals[v * 3 + 1] = ny; normals[v * 3 + 2] = nz }
|
|
628
|
+
}
|
|
629
|
+
const g = new Geometry(verts, normals, idx, uv)
|
|
630
|
+
g.uv1 = uv1
|
|
631
|
+
return g
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/** Write RGBA layer weights for a rectangle of samples (`rgba` = w × h × 4 bytes). */
|
|
635
|
+
paint(x0: number, z0: number, w: number, h: number, rgba: ArrayLike<number>): this {
|
|
636
|
+
for (let z = 0; z < h; z++) {
|
|
637
|
+
const zz = z0 + z
|
|
638
|
+
if (zz < 0 || zz >= this.sizeZ) continue
|
|
639
|
+
for (let x = 0; x < w; x++) {
|
|
640
|
+
const xx = x0 + x
|
|
641
|
+
if (xx < 0 || xx >= this.sizeX) continue
|
|
642
|
+
const s = (zz * this.sizeX + xx) * 4, d = (z * w + x) * 4
|
|
643
|
+
this.control[s] = rgba[d]; this.control[s + 1] = rgba[d + 1]; this.control[s + 2] = rgba[d + 2]; this.control[s + 3] = rgba[d + 3]
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
this._touchControl(x0, z0, x0 + w - 1, z0 + h - 1)
|
|
647
|
+
return this
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/** Paint `layer` over a region with `strength` (0…1), feathered over `falloff` metres. */
|
|
651
|
+
paintLayer(region: TerrainRegion, layer: number, strength = 1, falloff = 2): this {
|
|
652
|
+
const r = this._regionRect(region, falloff)
|
|
653
|
+
if (!r) return this
|
|
654
|
+
const weight = this._regionWeights(region, r, falloff)
|
|
655
|
+
let k = 0
|
|
656
|
+
for (let iz = r.z0; iz <= r.z1; iz++) {
|
|
657
|
+
for (let ix = r.x0; ix <= r.x1; ix++, k++) {
|
|
658
|
+
const w = weight[k] * strength
|
|
659
|
+
if (w <= 0) continue
|
|
660
|
+
this._blendLayer(iz * this.sizeX + ix, layer, w)
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
this._touchControl(r.x0, r.z0, r.x1, r.z1)
|
|
664
|
+
return this
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/** Paint the whole terrain from slope / height rules (grass on the flats, rock on cliffs, snow on
|
|
668
|
+
* the peaks, dirt in the valleys) — the CPU pass a procedural level starts from. */
|
|
669
|
+
paintAuto(rules: AutoPaintRules = {}): this {
|
|
670
|
+
const base = rules.base ?? 0
|
|
671
|
+
const n = [ 0, 0, 0 ]
|
|
672
|
+
for (let iz = 0; iz < this.sizeZ; iz++) {
|
|
673
|
+
for (let ix = 0; ix < this.sizeX; ix++) {
|
|
674
|
+
const i = iz * this.sizeX + ix
|
|
675
|
+
const c = i * 4
|
|
676
|
+
this.control[c] = 0; this.control[c + 1] = 0; this.control[c + 2] = 0; this.control[c + 3] = 0
|
|
677
|
+
this.control[c + Math.min(3, Math.max(0, base))] = 255
|
|
678
|
+
gridNormalAt(this._grid, ix, iz, n)
|
|
679
|
+
const h = this.heights[i]
|
|
680
|
+
if (rules.low) this._blendLayer(i, rules.low.layer, 1 - smoothstep(rules.low.from, rules.low.to, h))
|
|
681
|
+
if (rules.slope) {
|
|
682
|
+
const deg = Math.acos(Math.min(1, Math.max(-1, n[1]))) * 180 / Math.PI
|
|
683
|
+
this._blendLayer(i, rules.slope.layer, smoothstep(rules.slope.from, rules.slope.to, deg))
|
|
684
|
+
}
|
|
685
|
+
if (rules.height) this._blendLayer(i, rules.height.layer, smoothstep(rules.height.from, rules.height.to, h))
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
this._touchControl(0, 0, this.sizeX - 1, this.sizeZ - 1)
|
|
689
|
+
return this
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
private _blendLayer(i: number, layer: number, w: number): void {
|
|
693
|
+
if (w <= 0) return
|
|
694
|
+
const c = i * 4
|
|
695
|
+
const L = Math.min(3, Math.max(0, layer | 0))
|
|
696
|
+
for (let k = 0; k < 4; k++) {
|
|
697
|
+
const target = k === L ? 255 : 0
|
|
698
|
+
this.control[c + k] = Math.round(this.control[c + k] + (target - this.control[c + k]) * Math.min(1, w))
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// ---- commit ------------------------------------------------------------------------------------
|
|
703
|
+
|
|
704
|
+
/** Push every pending edit to the engine: chunk renderables + the height-field collider for the
|
|
705
|
+
* dirty rectangle, the control texture. Call once after a batch of edits. */
|
|
706
|
+
commit(): this {
|
|
707
|
+
const d = this._dirty
|
|
708
|
+
if (d) {
|
|
709
|
+
const w = d.x1 - d.x0 + 1, h = d.z1 - d.z0 + 1
|
|
710
|
+
const holes = this._anyHole ? this.holes : null
|
|
711
|
+
if (this._native) _creator.terrainUpdate!(this.node.id, this.heights, holes, d.x0, d.z0, w, h)
|
|
712
|
+
else this._rebuildFallbackChunks(d)
|
|
713
|
+
const shape = this.node.get(Shape)
|
|
714
|
+
if (shape && shape.heightfield && shape.shapeId) {
|
|
715
|
+
const range = shape._heightfieldRange
|
|
716
|
+
const [ lo, hi ] = this.heightRange
|
|
717
|
+
if (range && (lo < range[0] || hi > range[1])) shape._rebuildShape()
|
|
718
|
+
else if (_creator.physicsUpdateHeightField) _creator.physicsUpdateHeightField(shape.shapeId, this.heights, this.sizeX, this.sizeZ, d.x0, d.z0, w, h, holes)
|
|
719
|
+
}
|
|
720
|
+
this._dirty = null
|
|
721
|
+
this._holesTouched = false
|
|
722
|
+
}
|
|
723
|
+
if (this._controlDirty) { this._uploadControl(this._controlDirty); this._controlDirty = null }
|
|
724
|
+
return this
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
set castShadows(v: boolean) { this._cast = v; this._applyShadows() }
|
|
728
|
+
get castShadows(): boolean { return this._cast }
|
|
729
|
+
set receiveShadows(v: boolean) { this._receive = v; this._applyShadows() }
|
|
730
|
+
get receiveShadows(): boolean { return this._receive }
|
|
731
|
+
|
|
732
|
+
/** The whole terrain as a `.terrain` file (LTER: heights quantised to 16 bits, holes, control). */
|
|
733
|
+
serialize(): ArrayBuffer {
|
|
734
|
+
const [ lo, hi ] = this.heightRange
|
|
735
|
+
const range = hi - lo > 1e-6 ? hi - lo : 1
|
|
736
|
+
const n = this.sizeX * this.sizeZ
|
|
737
|
+
const meta = {
|
|
738
|
+
sizeX: this.sizeX, sizeZ: this.sizeZ, cellSize: this.cellSize, chunk: this.chunk, heightMin: lo, heightMax: hi,
|
|
739
|
+
layers: this.layers.map((l) => ({ tiling: l.tiling, roughness: l.roughness, normalScale: l.normalScale, triplanar: l.triplanar })),
|
|
740
|
+
blobs: { heights: { fmt: "u16", off: 0, len: n * 2 }, holes: { off: n * 2, len: n }, control: { off: n * 3, len: n * 4 } },
|
|
741
|
+
}
|
|
742
|
+
const json = JSON.stringify(meta)
|
|
743
|
+
const jsonBytes = utf8(json)
|
|
744
|
+
const headerLen = 12 + jsonBytes.length
|
|
745
|
+
const blobStart = (headerLen + 3) & ~3
|
|
746
|
+
const out = new ArrayBuffer(blobStart + n * 7)
|
|
747
|
+
const dv = new DataView(out)
|
|
748
|
+
dv.setUint32(0, FILE_MAGIC, true)
|
|
749
|
+
dv.setUint32(4, FILE_VERSION, true)
|
|
750
|
+
dv.setUint32(8, jsonBytes.length, true)
|
|
751
|
+
new Uint8Array(out, 12, jsonBytes.length).set(jsonBytes)
|
|
752
|
+
const u16 = new Uint16Array(out, blobStart, n)
|
|
753
|
+
for (let i = 0; i < n; i++) u16[i] = Math.round(((this.heights[i] - lo) / range) * 65535)
|
|
754
|
+
new Uint8Array(out, blobStart + n * 2, n).set(this.holes)
|
|
755
|
+
new Uint8Array(out, blobStart + n * 3, n * 4).set(this.control)
|
|
756
|
+
return out
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/** Decode a `.terrain` file into create() options (heights back in metres). */
|
|
760
|
+
static parse(buf: ArrayBuffer): { sizeX: number, sizeZ: number, cellSize: number, chunk: number, heights: Float32Array, holes: Uint8Array, control: Uint8Array, layers: TerrainLayer[] } {
|
|
761
|
+
const dv = new DataView(buf)
|
|
762
|
+
if (buf.byteLength < 12 || dv.getUint32(0, true) !== FILE_MAGIC) throw new Error("Terrain: not a .terrain file")
|
|
763
|
+
const version = dv.getUint32(4, true)
|
|
764
|
+
if (version !== FILE_VERSION) throw new Error(`Terrain: unsupported file version ${version}`)
|
|
765
|
+
const jsonLen = dv.getUint32(8, true)
|
|
766
|
+
const meta = JSON.parse(fromUtf8(new Uint8Array(buf, 12, jsonLen))) as ReturnType<typeof JSON.parse>
|
|
767
|
+
const blobStart = (12 + jsonLen + 3) & ~3
|
|
768
|
+
const n = meta.sizeX * meta.sizeZ
|
|
769
|
+
const heights = new Float32Array(n)
|
|
770
|
+
const range = meta.heightMax - meta.heightMin > 1e-6 ? meta.heightMax - meta.heightMin : 1
|
|
771
|
+
const u16 = new Uint16Array(buf.slice(blobStart + meta.blobs.heights.off, blobStart + meta.blobs.heights.off + n * 2))
|
|
772
|
+
for (let i = 0; i < n; i++) heights[i] = meta.heightMin + (u16[i] / 65535) * range
|
|
773
|
+
const holes = new Uint8Array(buf.slice(blobStart + meta.blobs.holes.off, blobStart + meta.blobs.holes.off + n))
|
|
774
|
+
const control = new Uint8Array(buf.slice(blobStart + meta.blobs.control.off, blobStart + meta.blobs.control.off + n * 4))
|
|
775
|
+
return { sizeX: meta.sizeX, sizeZ: meta.sizeZ, cellSize: meta.cellSize, chunk: meta.chunk, heights, holes, control, layers: meta.layers ?? [] }
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// ---- internals ---------------------------------------------------------------------------------
|
|
779
|
+
|
|
780
|
+
/** @internal what Shape { heightfield } reads. */
|
|
781
|
+
get _data(): TerrainData {
|
|
782
|
+
return { sizeX: this.sizeX, sizeZ: this.sizeZ, cellSize: this.cellSize, heights: this.heights, holes: this._anyHole ? this.holes : null }
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
private _build(): void {
|
|
786
|
+
const holes = this._anyHole ? this.holes : null
|
|
787
|
+
if (_creator.terrainCreate) {
|
|
788
|
+
const n = _creator.terrainCreate(this.node.id, Material.idOf(this.material), this.heights, this.sizeX, this.sizeZ, this.cellSize, this.chunk, holes)
|
|
789
|
+
if (n > 0) { this._native = true; return }
|
|
790
|
+
}
|
|
791
|
+
if (!warnedHost) { warnedHost = true; console.warn("Terrain: this host has no native terrain — drawing the chunks as meshes (normal maps read flat)") }
|
|
792
|
+
const [ nx, nz ] = chunkCounts(this._grid, this.chunk)
|
|
793
|
+
this._chunkMeshes = new Array(nx * nz).fill(null)
|
|
794
|
+
for (let cz = 0; cz < nz; cz++) for (let cx2 = 0; cx2 < nx; cx2++) this._buildFallbackChunk(cx2, cz, nx)
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
private _buildFallbackChunk(cx2: number, cz: number, nx: number): void {
|
|
798
|
+
const k = cz * nx + cx2
|
|
799
|
+
const old = this._chunkMeshes[k]
|
|
800
|
+
if (old) { old.destroy(); this._chunkMeshes[k] = null } // destroy = engine subtree removal + registry cleanup
|
|
801
|
+
const geom = buildTerrainChunk(this._grid, cx2, cz, this.chunk)
|
|
802
|
+
if (!geom) return
|
|
803
|
+
const mesh = new Mesh(geom, this.material)
|
|
804
|
+
mesh.name = `chunk_${cx2}_${cz}`
|
|
805
|
+
mesh.castShadows = this._cast
|
|
806
|
+
mesh.receiveShadows = this._receive
|
|
807
|
+
;(mesh as unknown as { _terrainChunk: boolean })._terrainChunk = true
|
|
808
|
+
this.node.add(mesh)
|
|
809
|
+
this._chunkMeshes[k] = mesh
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
private _rebuildFallbackChunks(d: Rect): void {
|
|
813
|
+
const [ nx, nz ] = chunkCounts(this._grid, this.chunk)
|
|
814
|
+
const cx0 = Math.floor(Math.max(0, d.x0 - 1) / this.chunk), cz0 = Math.floor(Math.max(0, d.z0 - 1) / this.chunk)
|
|
815
|
+
const cx1 = Math.min(nx - 1, Math.floor(Math.min(this.sizeX - 1, d.x1 + 1) / this.chunk))
|
|
816
|
+
const cz1 = Math.min(nz - 1, Math.floor(Math.min(this.sizeZ - 1, d.z1 + 1) / this.chunk))
|
|
817
|
+
for (let cz = cz0; cz <= cz1; cz++) for (let cx2 = cx0; cx2 <= cx1; cx2++) this._buildFallbackChunk(cx2, cz, nx)
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
private _applyShadows(): void {
|
|
821
|
+
if (this._native) _creator.terrainSetShadows?.(this.node.id, this._cast, this._receive)
|
|
822
|
+
else for (const m of this._chunkMeshes) if (m) { m.castShadows = this._cast; m.receiveShadows = this._receive }
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
private _uploadControl(region: Rect | null): void {
|
|
826
|
+
if (!_creator.createTexturePixels || !_creator.updateTexturePixels) {
|
|
827
|
+
if (!warnedPixels) { warnedPixels = true; console.warn("Terrain: this host cannot upload a control map — every layer shows equally") }
|
|
828
|
+
return
|
|
829
|
+
}
|
|
830
|
+
if (!this._controlTex) {
|
|
831
|
+
const id = _creator.createTexturePixels(this.sizeX, this.sizeZ, 4, this.control, false)
|
|
832
|
+
if (id === 0xFFFFFFFF) return
|
|
833
|
+
this._controlTex = new Texture(this.sizeX, this.sizeZ, id)
|
|
834
|
+
this._controlTex.wrapS = 0
|
|
835
|
+
this._controlTex.wrapT = 0
|
|
836
|
+
this.material.set("control", this._controlTex)
|
|
837
|
+
return
|
|
838
|
+
}
|
|
839
|
+
const r = region ?? { x0: 0, z0: 0, x1: this.sizeX - 1, z1: this.sizeZ - 1 }
|
|
840
|
+
const w = r.x1 - r.x0 + 1, h = r.z1 - r.z0 + 1
|
|
841
|
+
const sub = new Uint8Array(w * h * 4)
|
|
842
|
+
for (let z = 0; z < h; z++) sub.set(this.control.subarray(((r.z0 + z) * this.sizeX + r.x0) * 4, ((r.z0 + z) * this.sizeX + r.x0 + w) * 4), z * w * 4)
|
|
843
|
+
_creator.updateTexturePixels(this._controlTex._id, r.x0, r.z0, w, h, sub)
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
private async _applyLayers(normals?: TerrainOptions["normals"]): Promise<void> {
|
|
847
|
+
const m = this.material
|
|
848
|
+
const tiling = [ 8, 8, 8, 8 ], rough = [ 1, 1, 1, 1 ], nscale = [ 0, 0, 0, 0 ], tri = [ 0, 0, 0, 0 ]
|
|
849
|
+
const waits: Promise<void>[] = []
|
|
850
|
+
const bind = (key: string, src: string | Texture | undefined): void => {
|
|
851
|
+
if (src === undefined) return
|
|
852
|
+
if (src instanceof Texture) { m.set(key, src); return }
|
|
853
|
+
waits.push(Texture.load(src).then((t) => { m.set(key, t) }, (e) => { console.warn(`Terrain: ${key}: ${e instanceof Error ? e.message : String(e)}`) }))
|
|
854
|
+
}
|
|
855
|
+
this.layers.forEach((l, i) => {
|
|
856
|
+
tiling[i] = l.tiling ?? 8
|
|
857
|
+
rough[i] = l.roughness ?? 1
|
|
858
|
+
nscale[i] = l.normalScale ?? 0
|
|
859
|
+
tri[i] = l.triplanar ? 1 : 0
|
|
860
|
+
bind(`albedo${i}`, l.albedo)
|
|
861
|
+
})
|
|
862
|
+
m.set("tiling", tiling).set("roughness", rough).set("normalScale", nscale).set("triplanar", tri)
|
|
863
|
+
bind("normals01", normals?.[0])
|
|
864
|
+
bind("normals23", normals?.[1])
|
|
865
|
+
await Promise.all(waits)
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/** Re-push layer scalars after editing `layers` (textures given as Texture apply too). */
|
|
869
|
+
applyLayers(): Promise<void> { return this._applyLayers() }
|
|
870
|
+
|
|
871
|
+
private _touch(x0: number, z0: number, x1: number, z1: number): void {
|
|
872
|
+
x0 = Math.max(0, x0); z0 = Math.max(0, z0); x1 = Math.min(this.sizeX - 1, x1); z1 = Math.min(this.sizeZ - 1, z1)
|
|
873
|
+
if (x0 > x1 || z0 > z1) return
|
|
874
|
+
const d = this._dirty
|
|
875
|
+
this._dirty = d ? { x0: Math.min(d.x0, x0), z0: Math.min(d.z0, z0), x1: Math.max(d.x1, x1), z1: Math.max(d.z1, z1) } : { x0, z0, x1, z1 }
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
private _touchControl(x0: number, z0: number, x1: number, z1: number): void {
|
|
879
|
+
x0 = Math.max(0, x0); z0 = Math.max(0, z0); x1 = Math.min(this.sizeX - 1, x1); z1 = Math.min(this.sizeZ - 1, z1)
|
|
880
|
+
if (x0 > x1 || z0 > z1) return
|
|
881
|
+
const d = this._controlDirty
|
|
882
|
+
this._controlDirty = d ? { x0: Math.min(d.x0, x0), z0: Math.min(d.z0, z0), x1: Math.max(d.x1, x1), z1: Math.max(d.z1, z1) } : { x0, z0, x1, z1 }
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/** The sample rectangle a region (+ falloff) touches, or null when it misses the grid. */
|
|
886
|
+
private _regionRect(region: TerrainRegion, falloff: number): Rect | null {
|
|
887
|
+
const c = this.cellSize
|
|
888
|
+
let minX: number, minZ: number, maxX: number, maxZ: number
|
|
889
|
+
if ("circle" in region) {
|
|
890
|
+
const { x, z, r } = region.circle
|
|
891
|
+
minX = x - r - falloff; maxX = x + r + falloff; minZ = z - r - falloff; maxZ = z + r + falloff
|
|
892
|
+
} else if ("rect" in region) {
|
|
893
|
+
const { x, z, w, h } = region.rect
|
|
894
|
+
minX = x - falloff; maxX = x + w + falloff; minZ = z - falloff; maxZ = z + h + falloff
|
|
895
|
+
} else if ("polygon" in region) {
|
|
896
|
+
minX = Infinity; maxX = -Infinity; minZ = Infinity; maxZ = -Infinity
|
|
897
|
+
for (const [ px, pz ] of region.polygon) { if (px < minX) minX = px; if (px > maxX) maxX = px; if (pz < minZ) minZ = pz; if (pz > maxZ) maxZ = pz }
|
|
898
|
+
minX -= falloff; maxX += falloff; minZ -= falloff; maxZ += falloff
|
|
899
|
+
} else {
|
|
900
|
+
const tris = this._triangles(region.node)
|
|
901
|
+
if (tris.length < 9) return null
|
|
902
|
+
minX = Infinity; maxX = -Infinity; minZ = Infinity; maxZ = -Infinity
|
|
903
|
+
for (let i = 0; i < tris.length; i += 3) { const px = tris[i], pz = tris[i + 2]; if (px < minX) minX = px; if (px > maxX) maxX = px; if (pz < minZ) minZ = pz; if (pz > maxZ) maxZ = pz }
|
|
904
|
+
minX -= falloff; maxX += falloff; minZ -= falloff; maxZ += falloff
|
|
905
|
+
}
|
|
906
|
+
const r = { x0: Math.max(0, Math.floor(minX / c)), z0: Math.max(0, Math.floor(minZ / c)), x1: Math.min(this.sizeX - 1, Math.ceil(maxX / c)), z1: Math.min(this.sizeZ - 1, Math.ceil(maxZ / c)) }
|
|
907
|
+
return r.x0 <= r.x1 && r.z0 <= r.z1 ? r : null
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
/** Per-sample weights over `r`: 1 inside the region, smooth 1→0 across `falloff` metres outside. */
|
|
911
|
+
private _regionWeights(region: TerrainRegion, r: Rect, falloff: number): Float32Array {
|
|
912
|
+
const w = r.x1 - r.x0 + 1, h = r.z1 - r.z0 + 1
|
|
913
|
+
const out = new Float32Array(w * h)
|
|
914
|
+
const c = this.cellSize
|
|
915
|
+
const fade = (d: number): number => (d <= 0 ? 1 : falloff > 0 && d < falloff ? 1 - smoothstep(0, 1, d / falloff) : 0)
|
|
916
|
+
if ("node" in region) {
|
|
917
|
+
// coverage by the node's triangles, no falloff
|
|
918
|
+
const tris = this._triangles(region.node)
|
|
919
|
+
for (let t = 0; t + 8 < tris.length; t += 9) {
|
|
920
|
+
const ax = tris[t], az = tris[t + 2], bx = tris[t + 3], bz = tris[t + 5], qx = tris[t + 6], qz = tris[t + 8]
|
|
921
|
+
const det = (bx - ax) * (qz - az) - (qx - ax) * (bz - az)
|
|
922
|
+
if (Math.abs(det) < 1e-12) continue
|
|
923
|
+
const minX = Math.max(r.x0, Math.ceil(Math.min(ax, bx, qx) / c)), maxX = Math.min(r.x1, Math.floor(Math.max(ax, bx, qx) / c))
|
|
924
|
+
const minZ = Math.max(r.z0, Math.ceil(Math.min(az, bz, qz) / c)), maxZ = Math.min(r.z1, Math.floor(Math.max(az, bz, qz) / c))
|
|
925
|
+
for (let iz = minZ; iz <= maxZ; iz++) for (let ix = minX; ix <= maxX; ix++) {
|
|
926
|
+
const px = ix * c, pz = iz * c
|
|
927
|
+
const l1 = ((bx - ax) * (pz - az) - (px - ax) * (bz - az)) / det
|
|
928
|
+
const l2 = ((px - ax) * (qz - az) - (qx - ax) * (pz - az)) / det
|
|
929
|
+
if (l1 < -1e-6 || l2 < -1e-6 || 1 - l1 - l2 < -1e-6) continue
|
|
930
|
+
out[(iz - r.z0) * w + (ix - r.x0)] = 1
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
return out
|
|
934
|
+
}
|
|
935
|
+
let k = 0
|
|
936
|
+
for (let iz = r.z0; iz <= r.z1; iz++) {
|
|
937
|
+
const pz = iz * c
|
|
938
|
+
for (let ix = r.x0; ix <= r.x1; ix++, k++) {
|
|
939
|
+
const px = ix * c
|
|
940
|
+
if ("circle" in region) {
|
|
941
|
+
const d = Math.hypot(px - region.circle.x, pz - region.circle.z) - region.circle.r
|
|
942
|
+
out[k] = fade(d)
|
|
943
|
+
} else if ("rect" in region) {
|
|
944
|
+
const { x, z, w: rw, h: rh } = region.rect
|
|
945
|
+
const dx = Math.max(x - px, 0, px - (x + rw)), dz = Math.max(z - pz, 0, pz - (z + rh))
|
|
946
|
+
out[k] = fade(Math.hypot(dx, dz))
|
|
947
|
+
} else {
|
|
948
|
+
const poly = region.polygon
|
|
949
|
+
out[k] = fade(pointInPolygon(poly, px, pz) ? 0 : distanceToPolygon(poly, px, pz))
|
|
950
|
+
}
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
return out
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
/** World matrix of the node (column-major). */
|
|
957
|
+
private _world(): Float32Array {
|
|
958
|
+
const m = new Float32Array(16)
|
|
959
|
+
_creator.getWorldMatrix(this.node.id, m)
|
|
960
|
+
return m
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/** A target's triangles in the terrain's LOCAL frame, 9 floats per triangle. */
|
|
964
|
+
private _triangles(target: Node | Geometry | Float32Array): Float32Array {
|
|
965
|
+
if (target instanceof Float32Array) return target
|
|
966
|
+
if (target instanceof Geometry) return soupFromGeometry(target, null)
|
|
967
|
+
const inv = invertAffine(this._world())
|
|
968
|
+
const parts: Float32Array[] = []
|
|
969
|
+
const scratch = new Float32Array(16)
|
|
970
|
+
const walk = (n: Node): void => {
|
|
971
|
+
if ((n as unknown as { _terrain?: unknown })._terrain) return
|
|
972
|
+
_creator.getWorldMatrix(n.id, scratch)
|
|
973
|
+
const toLocal = multiplyAffine(inv, scratch)
|
|
974
|
+
const geom = n.geometry
|
|
975
|
+
if (geom && geom._meshKind === 0) parts.push(soupFromGeometry(geom, toLocal))
|
|
976
|
+
else if (n instanceof Model && _creator.glbTriangles) {
|
|
977
|
+
const soup = _creator.glbTriangles(n.id)
|
|
978
|
+
if (soup.length >= 9) parts.push(transformSoup(soup, toLocal))
|
|
979
|
+
}
|
|
980
|
+
for (const c of n.children) walk(c)
|
|
981
|
+
}
|
|
982
|
+
walk(target)
|
|
983
|
+
const total = parts.reduce((s, p) => s + p.length, 0)
|
|
984
|
+
const out = new Float32Array(total)
|
|
985
|
+
let o = 0
|
|
986
|
+
for (const p of parts) { out.set(p, o); o += p.length }
|
|
987
|
+
return out
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// ---- helpers -------------------------------------------------------------------------------------
|
|
992
|
+
|
|
993
|
+
const soupFromGeometry = (g: Geometry, m: Float32Array | null): Float32Array => {
|
|
994
|
+
const idx = g.indices, v = g.vertices
|
|
995
|
+
const out = new Float32Array(idx.length * 3)
|
|
996
|
+
for (let i = 0; i < idx.length; i++) {
|
|
997
|
+
const vi = idx[i] * 3
|
|
998
|
+
out[i * 3] = v[vi]; out[i * 3 + 1] = v[vi + 1]; out[i * 3 + 2] = v[vi + 2]
|
|
999
|
+
}
|
|
1000
|
+
return m ? transformSoup(out, m) : out
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
const transformSoup = (s: Float32Array, m: Float32Array): Float32Array => {
|
|
1004
|
+
const out = new Float32Array(s.length)
|
|
1005
|
+
for (let i = 0; i + 2 < s.length; i += 3) {
|
|
1006
|
+
const x = s[i], y = s[i + 1], z = s[i + 2]
|
|
1007
|
+
out[i] = m[0] * x + m[4] * y + m[8] * z + m[12]
|
|
1008
|
+
out[i + 1] = m[1] * x + m[5] * y + m[9] * z + m[13]
|
|
1009
|
+
out[i + 2] = m[2] * x + m[6] * y + m[10] * z + m[14]
|
|
1010
|
+
}
|
|
1011
|
+
return out
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
const transformPoint = (m: Float32Array, x: number, y: number, z: number): Vec3 =>
|
|
1015
|
+
new Vec3(m[0] * x + m[4] * y + m[8] * z + m[12], m[1] * x + m[5] * y + m[9] * z + m[13], m[2] * x + m[6] * y + m[10] * z + m[14])
|
|
1016
|
+
|
|
1017
|
+
/** a × b for column-major affine 4×4s. */
|
|
1018
|
+
const multiplyAffine = (a: Float32Array, b: Float32Array): Float32Array => {
|
|
1019
|
+
const o = new Float32Array(16)
|
|
1020
|
+
for (let c = 0; c < 4; c++) for (let r = 0; r < 4; r++) {
|
|
1021
|
+
o[c * 4 + r] = a[r] * b[c * 4] + a[4 + r] * b[c * 4 + 1] + a[8 + r] * b[c * 4 + 2] + a[12 + r] * b[c * 4 + 3]
|
|
1022
|
+
}
|
|
1023
|
+
return o
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
/** Inverse of a column-major affine (TRS) 4×4. */
|
|
1027
|
+
const invertAffine = (m: Float32Array): Float32Array => {
|
|
1028
|
+
const a00 = m[0], a01 = m[1], a02 = m[2], a10 = m[4], a11 = m[5], a12 = m[6], a20 = m[8], a21 = m[9], a22 = m[10]
|
|
1029
|
+
const b01 = a22 * a11 - a12 * a21, b11 = -a22 * a10 + a12 * a20, b21 = a21 * a10 - a11 * a20
|
|
1030
|
+
let det = a00 * b01 + a01 * b11 + a02 * b21
|
|
1031
|
+
if (Math.abs(det) < 1e-20) det = 1e-20
|
|
1032
|
+
det = 1 / det
|
|
1033
|
+
const o = new Float32Array(16)
|
|
1034
|
+
o[0] = b01 * det; o[1] = (-a22 * a01 + a02 * a21) * det; o[2] = (a12 * a01 - a02 * a11) * det
|
|
1035
|
+
o[4] = b11 * det; o[5] = (a22 * a00 - a02 * a20) * det; o[6] = (-a12 * a00 + a02 * a10) * det
|
|
1036
|
+
o[8] = b21 * det; o[9] = (-a21 * a00 + a01 * a20) * det; o[10] = (a11 * a00 - a01 * a10) * det
|
|
1037
|
+
const tx = m[12], ty = m[13], tz = m[14]
|
|
1038
|
+
o[12] = -(o[0] * tx + o[4] * ty + o[8] * tz)
|
|
1039
|
+
o[13] = -(o[1] * tx + o[5] * ty + o[9] * tz)
|
|
1040
|
+
o[14] = -(o[2] * tx + o[6] * ty + o[10] * tz)
|
|
1041
|
+
o[15] = 1
|
|
1042
|
+
return o
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
const pointInPolygon = (poly: readonly (readonly [number, number])[], x: number, z: number): boolean => {
|
|
1046
|
+
let inside = false
|
|
1047
|
+
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
|
|
1048
|
+
const [ xi, zi ] = poly[i], [ xj, zj ] = poly[j]
|
|
1049
|
+
if ((zi > z) !== (zj > z) && x < ((xj - xi) * (z - zi)) / (zj - zi || 1e-12) + xi) inside = !inside
|
|
1050
|
+
}
|
|
1051
|
+
return inside
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
const distanceToPolygon = (poly: readonly (readonly [number, number])[], x: number, z: number): number => {
|
|
1055
|
+
let best = Infinity
|
|
1056
|
+
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
|
|
1057
|
+
const [ ax, az ] = poly[j], [ bx, bz ] = poly[i]
|
|
1058
|
+
const dx = bx - ax, dz = bz - az
|
|
1059
|
+
const l2 = dx * dx + dz * dz
|
|
1060
|
+
const t = l2 > 0 ? Math.min(1, Math.max(0, ((x - ax) * dx + (z - az) * dz) / l2)) : 0
|
|
1061
|
+
const d = Math.hypot(x - (ax + dx * t), z - (az + dz * t))
|
|
1062
|
+
if (d < best) best = d
|
|
1063
|
+
}
|
|
1064
|
+
return best
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
// QuickJS has no TextEncoder: the header is ASCII JSON, encoded by hand.
|
|
1068
|
+
const utf8 = (s: string): Uint8Array => {
|
|
1069
|
+
const out: number[] = []
|
|
1070
|
+
for (let i = 0; i < s.length; i++) {
|
|
1071
|
+
let c = s.charCodeAt(i)
|
|
1072
|
+
if (c < 0x80) out.push(c)
|
|
1073
|
+
else if (c < 0x800) out.push(0xc0 | (c >> 6), 0x80 | (c & 0x3f))
|
|
1074
|
+
else if (c >= 0xd800 && c < 0xdc00 && i + 1 < s.length) {
|
|
1075
|
+
c = 0x10000 + ((c - 0xd800) << 10) + (s.charCodeAt(++i) - 0xdc00)
|
|
1076
|
+
out.push(0xf0 | (c >> 18), 0x80 | ((c >> 12) & 0x3f), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f))
|
|
1077
|
+
} else out.push(0xe0 | (c >> 12), 0x80 | ((c >> 6) & 0x3f), 0x80 | (c & 0x3f))
|
|
1078
|
+
}
|
|
1079
|
+
return Uint8Array.from(out)
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
const fromUtf8 = (b: Uint8Array): string => {
|
|
1083
|
+
let s = ""
|
|
1084
|
+
for (let i = 0; i < b.length;) {
|
|
1085
|
+
const c = b[i++]
|
|
1086
|
+
if (c < 0x80) s += String.fromCharCode(c)
|
|
1087
|
+
else if (c < 0xe0) s += String.fromCharCode(((c & 0x1f) << 6) | (b[i++] & 0x3f))
|
|
1088
|
+
else if (c < 0xf0) { s += String.fromCharCode(((c & 0x0f) << 12) | ((b[i] & 0x3f) << 6) | (b[i + 1] & 0x3f)); i += 2 }
|
|
1089
|
+
else { const cp = ((c & 0x07) << 18) | ((b[i] & 0x3f) << 12) | ((b[i + 1] & 0x3f) << 6) | (b[i + 2] & 0x3f); i += 3; s += String.fromCodePoint(cp) }
|
|
1090
|
+
}
|
|
1091
|
+
return s
|
|
1092
|
+
}
|