@geoblocks/cesium-walk 0.0.0-stage → 0.1.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/LICENSE +29 -0
- package/README.md +108 -2
- package/cesium-walk.js +384 -0
- package/mesh-builder.js +109 -0
- package/package.json +33 -4
- package/walk-mesh.js +443 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024-present, camptocamp SA
|
|
4
|
+
All rights reserved.
|
|
5
|
+
|
|
6
|
+
Redistribution and use in source and binary forms, with or without
|
|
7
|
+
modification, are permitted provided that the following conditions are met:
|
|
8
|
+
|
|
9
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
10
|
+
list of conditions and the following disclaimer.
|
|
11
|
+
|
|
12
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
13
|
+
this list of conditions and the following disclaimer in the documentation
|
|
14
|
+
and/or other materials provided with the distribution.
|
|
15
|
+
|
|
16
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
17
|
+
contributors may be used to endorse or promote products derived from
|
|
18
|
+
this software without specific prior written permission.
|
|
19
|
+
|
|
20
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
21
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
22
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
23
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
24
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
25
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
26
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
27
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
28
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
29
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,109 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Cesium Walk
|
|
2
2
|
|
|
3
|
-
This
|
|
3
|
+
This package provides a camera mode for CesiumJS that lets the user walk around the scene
|
|
4
|
+
with the keyboard or a gamepad. The camera is kept at a fixed height above the terrain.
|
|
5
|
+
|
|
6
|
+
## Controls
|
|
7
|
+
|
|
8
|
+
| Action | Keyboard | Gamepad (standard mapping) |
|
|
9
|
+
|---|---|---|
|
|
10
|
+
| Walk | W, A, S, D or the arrows | left stick |
|
|
11
|
+
| Sprint, forward | Shift, held | Cross / A (button 0), held |
|
|
12
|
+
| Jump | Space | Square / X (button 2) |
|
|
13
|
+
|
|
14
|
+
The keys are matched by their position (`event.code`), so other keyboard layouts walk with the
|
|
15
|
+
keys in the same place.
|
|
16
|
+
|
|
17
|
+
These are the default bindings, `WALK_BINDINGS`, read with [cesium-input](../cesium-input). The walker
|
|
18
|
+
reads its intent from `walkMode.input` on each tick, `{move: {x, y}, sprint, jump}`, so the controls can
|
|
19
|
+
be remapped, or come from anywhere else, while the walk is not active:
|
|
20
|
+
|
|
21
|
+
```javascript
|
|
22
|
+
import Controls from '@geoblocks/cesium-input';
|
|
23
|
+
import CesiumWalk, {WALK_BINDINGS} from '@geoblocks/cesium-walk';
|
|
24
|
+
|
|
25
|
+
// J jumps instead of Space
|
|
26
|
+
walkMode.input = new Controls({...WALK_BINDINGS, jump: {type: 'button', keys: ['KeyJ'], buttons: [2]}});
|
|
27
|
+
// touch controls, an AI, a replay: `move` no longer than 1, x right and y forward
|
|
28
|
+
walkMode.input = {read: () => ({move: touchStick.vector, sprint: false, jump: touchButton.tapped})};
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Installation
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npm i --save @geoblocks/cesium-walk
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Demo
|
|
38
|
+
|
|
39
|
+
[Demo](https://geoblocks.github.io/cesium-helpers/cesium-walk.html)
|
|
40
|
+
|
|
41
|
+
## Usage
|
|
42
|
+
|
|
43
|
+
```javascript
|
|
44
|
+
import {Viewer} from '@cesium/engine';
|
|
45
|
+
import CesiumWalk from '@geoblocks/cesium-walk';
|
|
46
|
+
|
|
47
|
+
const viewer = new Viewer(...);
|
|
48
|
+
// speed in meters per second, height above the terrain in meters
|
|
49
|
+
const walkMode = new CesiumWalk(viewer, 1.6, 2.0);
|
|
50
|
+
|
|
51
|
+
// ...
|
|
52
|
+
walkMode.active = true;
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`walkMode.sprintSpeed` (default three times the speed) and `walkMode.jumpSpeed` (default 4 m/s)
|
|
56
|
+
can be changed at any time, as can these, all off or as on Earth by default:
|
|
57
|
+
|
|
58
|
+
- `walkMode.gravity`, in m/s² (default 9.81): the Moon's 1.62, or a game's snappier jumps.
|
|
59
|
+
- `walkMode.acceleration`, in m/s² (default `Infinity`, at once): how fast the walker reaches its
|
|
60
|
+
speed, stops and turns.
|
|
61
|
+
- `walkMode.stepHeight`, in meters (default `Infinity`, always followed): the deepest drop of the
|
|
62
|
+
ground stepped down; off a deeper one, a ledge or a roof, the walker falls.
|
|
63
|
+
|
|
64
|
+
In the air, jumping or falling, the walker keeps its height when the ground changes under it, and lands
|
|
65
|
+
on the ground it meets.
|
|
66
|
+
`walkMode.sprinting` tells whether the walker is sprinting. Set `walkMode.headBob = true` for the camera to bob at each step and dip on landing, as in GTA 5's
|
|
67
|
+
first person. Set `walkMode.collision = true` for the walker to stop at the obstacles in view at eye
|
|
68
|
+
height (buildings, trees, any opaque 3D tiles or primitives) and to slide along them; walking backward or
|
|
69
|
+
sideways, the obstacles out of view do not stop it. Translucent primitives, of an alpha below 0.995, are
|
|
70
|
+
not obstacles. The obstacles come from [cesium-obstacles](../cesium-obstacles), read in the depth
|
|
71
|
+
of the last frame, which makes the CPU wait for the GPU. For a world known on the CPU, set
|
|
72
|
+
`walkMode.obstacleAhead` to a function returning the obstacle ahead in a direction, `{point, normal}`
|
|
73
|
+
with the normal toward the walker, or `undefined`: a ray cast stops the walker in every direction,
|
|
74
|
+
without waiting.
|
|
75
|
+
|
|
76
|
+
The walker stands on the terrain by default. To walk on something else, a model's floors, stairs and
|
|
77
|
+
tunnels, set `walkMode.groundHeight` to a function returning the ground height in meters above the
|
|
78
|
+
ellipsoid at a `Cartographic`, or `undefined` while unknown.
|
|
79
|
+
|
|
80
|
+
## Walking on a mesh
|
|
81
|
+
|
|
82
|
+
`@geoblocks/cesium-walk/walk-mesh.js` makes triangles you know on the CPU, a building's floors, stairs and
|
|
83
|
+
walls, into both what is seen and what is walked on, with no wait for the GPU:
|
|
84
|
+
|
|
85
|
+
```javascript
|
|
86
|
+
import CesiumWalk from '@geoblocks/cesium-walk';
|
|
87
|
+
import {createWalkMesh, walkOn} from '@geoblocks/cesium-walk/walk-mesh.js';
|
|
88
|
+
|
|
89
|
+
import {Group} from '@geoblocks/cesium-walk/mesh-builder.js';
|
|
90
|
+
|
|
91
|
+
// groups of triangles of one color each, in meters, z up, in the frame of a model matrix
|
|
92
|
+
const floor = new Group([0.8, 0.8, 0.8, 1]);
|
|
93
|
+
floor.polygon([[0, 0], [10, 0], [10, 6], [0, 6]], [], () => 0);
|
|
94
|
+
const walls = new Group([0.9, 0.9, 0.85, 1]);
|
|
95
|
+
walls.box(0, 0, 0, 10, 0.2, 2.5);
|
|
96
|
+
const mesh = await createWalkMesh([floor.walkGroup, walls.walkGroup], modelMatrix);
|
|
97
|
+
viewer.scene.primitives.add(mesh.primitive);
|
|
98
|
+
|
|
99
|
+
const walkMode = new CesiumWalk(viewer);
|
|
100
|
+
// stand on the highest floor below the knees, climb steps up to 0.4 m, stop at the walls 0.5 m ahead
|
|
101
|
+
walkOn(walkMode, mesh, {eye: 1.6, step: 0.4, reach: 0.5});
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
A group can be walked on and not seen (`show: false`), seen and not walked on (`collide: false`), cast no
|
|
105
|
+
shadow (`shadows: false`), glow (`emissive`), carry texture coordinates for a custom shader (`uv`,
|
|
106
|
+
`customShader`) or a baked light's atlas (`lightmap`). The faces flatter than 60° are floors, the others
|
|
107
|
+
walls. `Group` (`mesh-builder.js`) builds the groups face by face: `face`, `polygon` (with holes), `sides`, `box`;
|
|
108
|
+
`charts: true` records each face, to lay them out in an atlas. `mesh.floorBelow(x, y, z)` and `mesh.raycast(origin, direction, maxDistance)` answer in the
|
|
109
|
+
mesh's frame and on the globe.
|
package/cesium-walk.js
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
import {Cartesian3, Math as CesiumMath} from "@cesium/core";
|
|
2
|
+
import Controls from "@geoblocks/cesium-input";
|
|
3
|
+
import {ObstacleProbe, blockedStep} from "@geoblocks/cesium-obstacles";
|
|
4
|
+
|
|
5
|
+
// in seconds: a frame after a backgrounded tab must not walk or fall far
|
|
6
|
+
const MAX_DELTA = 0.1;
|
|
7
|
+
// the head bob: a dip of DIP meters at each step of STEP_LENGTH meters, twice as deep when sprinting,
|
|
8
|
+
// and of LANDING meters when landing from a jump; both settle in about BOB_TIME seconds
|
|
9
|
+
const STEP_LENGTH = 0.8;
|
|
10
|
+
const DIP = 0.03;
|
|
11
|
+
const LANDING = 0.15;
|
|
12
|
+
const BOB_TIME = 0.15;
|
|
13
|
+
// in meters: nearer its height, the camera stays, so that a walker standing still renders no frame
|
|
14
|
+
const SETTLED = 1e-3;
|
|
15
|
+
|
|
16
|
+
// the default bindings: W, A, S, D or the arrows to walk, Shift to sprint and Space to jump; on a gamepad, the
|
|
17
|
+
// left stick, at any speed and in any direction, Cross to sprint and Square to jump, as in GTA 5. Keys by
|
|
18
|
+
// event.code, so that other keyboard layouts walk with the keys in the same place
|
|
19
|
+
/** @satisfies {Record<string, import("@geoblocks/cesium-input").Binding>} */
|
|
20
|
+
export const WALK_BINDINGS = {
|
|
21
|
+
move: {type: "vector", keys: {up: ["KeyW", "ArrowUp"], down: ["KeyS", "ArrowDown"], left: ["KeyA", "ArrowLeft"], right: ["KeyD", "ArrowRight"]}, stick: "left"},
|
|
22
|
+
sprint: {type: "button", keys: ["ShiftLeft", "ShiftRight"], buttons: [0]},
|
|
23
|
+
jump: {type: "button", keys: ["Space"], buttons: [2]},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/** @typedef {{move: {x: number, y: number}, sprint: boolean, jump: boolean}} WalkIntent */
|
|
27
|
+
|
|
28
|
+
// the camera controller's settings off while walking: its navigation, and its collision detection, which lifts
|
|
29
|
+
// the camera onto the 3D tiles under it, a roof or a coarse tile still loading over the street, whenever the
|
|
30
|
+
// camera moves; the walk keeps its own height
|
|
31
|
+
const CONTROLLER_SETTINGS = /** @type {const} */ (["enableTranslate", "enableZoom", "enableRotate", "enableTilt", "enableCollisionDetection"]);
|
|
32
|
+
|
|
33
|
+
const normalScratch = new Cartesian3();
|
|
34
|
+
const forwardScratch = new Cartesian3();
|
|
35
|
+
const stepScratch = new Cartesian3();
|
|
36
|
+
const allowedScratch = new Cartesian3();
|
|
37
|
+
const targetScratch = new Cartesian3();
|
|
38
|
+
const changeScratch = new Cartesian3();
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* One step of a jump or a fall under gravity.
|
|
42
|
+
* @param {number} offset Height above the walk height, in meters.
|
|
43
|
+
* @param {number} velocity Upward speed, in meters per second.
|
|
44
|
+
* @param {number} dt Seconds.
|
|
45
|
+
* @param {number} gravity In meters per second squared.
|
|
46
|
+
* @return {[number, number]} The next offset and velocity, [0, 0] once landed.
|
|
47
|
+
*/
|
|
48
|
+
function jumpStep(offset, velocity, dt, gravity) {
|
|
49
|
+
const next = velocity - gravity * dt;
|
|
50
|
+
const height = offset + ((velocity + next) / 2) * dt;
|
|
51
|
+
return height <= 0 && next <= 0 ? [0, 0] : [height, next];
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Whether a jump starts: on a new press only, so that holding the button does not jump again on landing.
|
|
56
|
+
* @param {boolean} pressed
|
|
57
|
+
* @param {boolean} wasPressed
|
|
58
|
+
* @param {boolean} grounded
|
|
59
|
+
* @return {boolean}
|
|
60
|
+
*/
|
|
61
|
+
function jumpStart(pressed, wasPressed, grounded) {
|
|
62
|
+
return pressed && !wasPressed && grounded;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export default class CesiumWalk {
|
|
66
|
+
/**
|
|
67
|
+
* @param {import('@cesium/engine').CesiumWidget} viewer
|
|
68
|
+
* @param {number} [speed=1.6] Walk speed in meters per second.
|
|
69
|
+
* @param {number} [height=2.0] Height of the camera above the terrain.
|
|
70
|
+
*/
|
|
71
|
+
constructor(viewer, speed = 1.6, height = 2.0) {
|
|
72
|
+
/**
|
|
73
|
+
* @type {import('@cesium/engine').CesiumWidget}
|
|
74
|
+
*/
|
|
75
|
+
this.viewer = viewer;
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* @type {number}
|
|
79
|
+
*/
|
|
80
|
+
this.speed = speed;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Speed while sprinting, in meters per second.
|
|
84
|
+
* @type {number}
|
|
85
|
+
*/
|
|
86
|
+
this.sprintSpeed = 3 * speed;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Upward speed at the start of a jump, in meters per second.
|
|
90
|
+
* @type {number}
|
|
91
|
+
*/
|
|
92
|
+
this.jumpSpeed = 4;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The gravity of jumps and falls, in meters per second squared.
|
|
96
|
+
* @type {number}
|
|
97
|
+
*/
|
|
98
|
+
this.gravity = 9.81;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* How fast the walker reaches its speed, stops and turns, in meters per second squared; at once by
|
|
102
|
+
* default.
|
|
103
|
+
* @type {number}
|
|
104
|
+
*/
|
|
105
|
+
this.acceleration = Infinity;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The deepest drop of the ground the walker steps down, in meters: deeper, off a ledge or a roof, it
|
|
109
|
+
* falls. By default it follows the ground whatever the drop.
|
|
110
|
+
* @type {number}
|
|
111
|
+
*/
|
|
112
|
+
this.stepHeight = Infinity;
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* @type {number}
|
|
116
|
+
*/
|
|
117
|
+
this.height = height;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* @type {boolean}
|
|
121
|
+
*/
|
|
122
|
+
this.active_ = false;
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* @type {number}
|
|
126
|
+
*/
|
|
127
|
+
this.lastTick_ = 0;
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Where the walker's intent comes from, read on each tick: {move: {x, y}, sprint, jump}, move no longer
|
|
131
|
+
* than 1, x right and y forward. The keyboard and the first gamepad by default, with WALK_BINDINGS; any
|
|
132
|
+
* object with a read() method otherwise: touch controls, an AI, a replay. The walk turns its active on
|
|
133
|
+
* and off with its own: replace it while the walk is not active.
|
|
134
|
+
* @type {{read(): WalkIntent, active?: boolean}}
|
|
135
|
+
*/
|
|
136
|
+
this.input = new Controls(WALK_BINDINGS);
|
|
137
|
+
|
|
138
|
+
// the walker's velocity, level, on the globe
|
|
139
|
+
this.velocity_ = new Cartesian3();
|
|
140
|
+
|
|
141
|
+
// the ground under the walker on the last tick, which it keeps its height above while in the air
|
|
142
|
+
/** @type {number | undefined} */
|
|
143
|
+
this.ground_ = undefined;
|
|
144
|
+
|
|
145
|
+
// the jump or the fall: height above the walk height and upward speed; whether the jump input was down
|
|
146
|
+
// last tick
|
|
147
|
+
this.jumpOffset_ = 0;
|
|
148
|
+
this.jumpVelocity_ = 0;
|
|
149
|
+
this.jumpHeld_ = false;
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Whether the camera bobs at each step and dips on landing, as in GTA 5's first person.
|
|
153
|
+
* @type {boolean}
|
|
154
|
+
*/
|
|
155
|
+
this.headBob = false;
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Whether the walker stops at the obstacles in view at eye height: opaque 3D tiles or primitives.
|
|
159
|
+
* @type {boolean}
|
|
160
|
+
*/
|
|
161
|
+
this.collision = false;
|
|
162
|
+
|
|
163
|
+
this.obstacleProbe_ = new ObstacleProbe(viewer.scene);
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The obstacle ahead of the walker in a direction, a point of its plane and its normal toward the walker,
|
|
167
|
+
* if any; asked for each step while collision is on. By default the obstacles in view, read in the depth
|
|
168
|
+
* of the last frame, which waits for the GPU; for a world known on the CPU, a ray cast in every direction.
|
|
169
|
+
* @type {(toward: Cartesian3, key: string) => import('@geoblocks/cesium-obstacles').Obstacle | undefined}
|
|
170
|
+
*/
|
|
171
|
+
this.obstacleAhead = (toward, key) => this.obstacleProbe_.ahead(toward, key);
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The height of the ground under a position, in meters above the ellipsoid, or undefined when unknown
|
|
175
|
+
* yet. The terrain by default; a model's floors otherwise, for a walker on several levels.
|
|
176
|
+
* @type {(position: import('@cesium/core').Cartographic) => number | undefined}
|
|
177
|
+
*/
|
|
178
|
+
this.groundHeight = position => viewer.scene.globe.getHeight(position);
|
|
179
|
+
|
|
180
|
+
// whether the walker sprinted on the last tick
|
|
181
|
+
this.sprinting_ = false;
|
|
182
|
+
|
|
183
|
+
// the camera controller's settings before walking
|
|
184
|
+
/** @type {Partial<Record<typeof CONTROLLER_SETTINGS[number], boolean>>} */
|
|
185
|
+
this.controllerSettings_ = {};
|
|
186
|
+
|
|
187
|
+
// the head bob: how deep, 0 standing to 2 sprinting, along the steps; the landing dip, 0 to 1
|
|
188
|
+
this.bob_ = 0;
|
|
189
|
+
this.stepPhase_ = 0;
|
|
190
|
+
this.landing_ = 0;
|
|
191
|
+
|
|
192
|
+
this.handleTickFunction_ = this.handleTick_.bind(this);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
get active() {
|
|
196
|
+
return this.active_;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Whether the walker is sprinting.
|
|
201
|
+
* @return {boolean}
|
|
202
|
+
*/
|
|
203
|
+
get sprinting() {
|
|
204
|
+
return this.active_ && this.sprinting_;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
set active(active) {
|
|
208
|
+
if (active === this.active_) {
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
this.active_ = active;
|
|
212
|
+
const controller = this.viewer.scene.screenSpaceCameraController;
|
|
213
|
+
if (this.active_) {
|
|
214
|
+
for (const setting of CONTROLLER_SETTINGS) {
|
|
215
|
+
this.controllerSettings_[setting] = controller[setting];
|
|
216
|
+
controller[setting] = false;
|
|
217
|
+
}
|
|
218
|
+
this.lastTick_ = performance.now();
|
|
219
|
+
this.input.active = true;
|
|
220
|
+
this.viewer.clock.onTick.addEventListener(this.handleTickFunction_);
|
|
221
|
+
this.clampCameraToTerrain_();
|
|
222
|
+
} else {
|
|
223
|
+
this.input.active = false;
|
|
224
|
+
this.viewer.clock.onTick.removeEventListener(this.handleTickFunction_);
|
|
225
|
+
this.jumpOffset_ = 0;
|
|
226
|
+
this.jumpVelocity_ = 0;
|
|
227
|
+
this.ground_ = undefined;
|
|
228
|
+
Cartesian3.clone(Cartesian3.ZERO, this.velocity_);
|
|
229
|
+
this.bob_ = 0;
|
|
230
|
+
this.landing_ = 0;
|
|
231
|
+
Object.assign(controller, this.controllerSettings_);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
handleTick_() {
|
|
236
|
+
const timestamp = performance.now();
|
|
237
|
+
const deltaTime = Math.min((timestamp - this.lastTick_) / 1000, MAX_DELTA);
|
|
238
|
+
this.lastTick_ = timestamp;
|
|
239
|
+
|
|
240
|
+
const {move, sprint, jump} = this.input.read();
|
|
241
|
+
let forward = move.y;
|
|
242
|
+
let right = move.x;
|
|
243
|
+
// walking diagonally is not faster
|
|
244
|
+
const magnitude = Math.hypot(forward, right);
|
|
245
|
+
if (magnitude > 1) {
|
|
246
|
+
forward /= magnitude;
|
|
247
|
+
right /= magnitude;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const grounded = this.jumpOffset_ === 0 && this.jumpVelocity_ === 0;
|
|
251
|
+
if (jumpStart(jump, this.jumpHeld_, grounded)) {
|
|
252
|
+
this.jumpVelocity_ = this.jumpSpeed;
|
|
253
|
+
}
|
|
254
|
+
this.jumpHeld_ = jump;
|
|
255
|
+
const airborne = this.jumpOffset_ > 0 || this.jumpVelocity_ > 0;
|
|
256
|
+
|
|
257
|
+
const moving = forward !== 0 || right !== 0;
|
|
258
|
+
// forward, no more sideways than diagonally, as in GTA 5
|
|
259
|
+
this.sprinting_ = sprint && forward > 0 && forward >= Math.abs(right);
|
|
260
|
+
const stopped = Cartesian3.equals(this.velocity_, Cartesian3.ZERO);
|
|
261
|
+
if (!moving && stopped && !airborne && this.bob_ === 0 && this.landing_ === 0) {
|
|
262
|
+
// standing: only the terrain refining under the walker moves it
|
|
263
|
+
this.clampCameraToTerrain_();
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const camera = this.viewer.camera;
|
|
268
|
+
const speed = this.sprinting_ ? this.sprintSpeed : this.speed;
|
|
269
|
+
// walk along the view direction projected onto the ground plane,
|
|
270
|
+
// so that looking down does not push the camera into the terrain
|
|
271
|
+
const normal = this.surfaceNormal_();
|
|
272
|
+
const along = Cartesian3.multiplyByScalar(
|
|
273
|
+
normal,
|
|
274
|
+
Cartesian3.dot(camera.direction, normal),
|
|
275
|
+
forwardScratch
|
|
276
|
+
);
|
|
277
|
+
Cartesian3.subtract(camera.direction, along, along);
|
|
278
|
+
const level = Cartesian3.magnitude(along) > CesiumMath.EPSILON6;
|
|
279
|
+
if (level) {
|
|
280
|
+
Cartesian3.normalize(along, along);
|
|
281
|
+
}
|
|
282
|
+
// the velocity, toward the intended one as fast as the acceleration allows
|
|
283
|
+
const target = Cartesian3.multiplyByScalar(camera.right, right * speed, targetScratch);
|
|
284
|
+
if (level) {
|
|
285
|
+
Cartesian3.add(target, Cartesian3.multiplyByScalar(along, forward * speed, changeScratch), target);
|
|
286
|
+
}
|
|
287
|
+
const change = Cartesian3.subtract(target, this.velocity_, changeScratch);
|
|
288
|
+
const needed = Cartesian3.magnitude(change);
|
|
289
|
+
const allowed = this.acceleration * deltaTime;
|
|
290
|
+
if (needed <= allowed) {
|
|
291
|
+
Cartesian3.clone(target, this.velocity_);
|
|
292
|
+
} else {
|
|
293
|
+
Cartesian3.add(this.velocity_, Cartesian3.multiplyByScalar(change, allowed / needed, change), this.velocity_);
|
|
294
|
+
}
|
|
295
|
+
const forwardSpeed = level ? Cartesian3.dot(this.velocity_, along) : 0;
|
|
296
|
+
const rightSpeed = Cartesian3.dot(this.velocity_, camera.right);
|
|
297
|
+
if (forwardSpeed !== 0) {
|
|
298
|
+
this.step_(along, forwardSpeed * deltaTime, "forward");
|
|
299
|
+
}
|
|
300
|
+
if (rightSpeed !== 0) {
|
|
301
|
+
this.step_(camera.right, rightSpeed * deltaTime, "right");
|
|
302
|
+
}
|
|
303
|
+
if (airborne) {
|
|
304
|
+
[this.jumpOffset_, this.jumpVelocity_] = jumpStep(this.jumpOffset_, this.jumpVelocity_, deltaTime, this.gravity);
|
|
305
|
+
if (this.headBob && this.jumpOffset_ === 0) {
|
|
306
|
+
this.landing_ = 1;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
// the head bob, which settles once turned off
|
|
310
|
+
const ease = 1 - Math.exp(-deltaTime / BOB_TIME);
|
|
311
|
+
const bob = this.headBob && moving && !airborne ? (this.sprinting_ ? 2 : 1) : 0;
|
|
312
|
+
this.bob_ += (bob - this.bob_) * ease;
|
|
313
|
+
this.landing_ -= this.landing_ * ease;
|
|
314
|
+
// the last thousandth in one step, so that the camera stops moving
|
|
315
|
+
if (bob === 0 && this.bob_ < 1e-3) {
|
|
316
|
+
this.bob_ = 0;
|
|
317
|
+
}
|
|
318
|
+
if (this.landing_ < 1e-3) {
|
|
319
|
+
this.landing_ = 0;
|
|
320
|
+
}
|
|
321
|
+
this.stepPhase_ += (Cartesian3.magnitude(this.velocity_) * deltaTime / STEP_LENGTH) * Math.PI;
|
|
322
|
+
this.clampCameraToTerrain_();
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Moves the camera a step, stopping at obstacles or sliding along them.
|
|
327
|
+
* @param {Cartesian3} direction The unit direction of the step, level.
|
|
328
|
+
* @param {number} step The length of the step, negative backward, in meters.
|
|
329
|
+
* @param {string} kind Which steps these are, for the obstacle looked for last time.
|
|
330
|
+
*/
|
|
331
|
+
step_(direction, step, kind) {
|
|
332
|
+
const camera = this.viewer.camera;
|
|
333
|
+
const toward = Cartesian3.multiplyByScalar(direction, Math.sign(step), stepScratch);
|
|
334
|
+
const obstacle = this.collision ? this.obstacleAhead(toward, `${kind}${Math.sign(step)}`) : undefined;
|
|
335
|
+
const allowed = blockedStep(toward, Math.abs(step), camera.positionWC, obstacle, allowedScratch);
|
|
336
|
+
if (allowed.length > 0) {
|
|
337
|
+
camera.move(allowed.direction, allowed.length);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
clampCameraToTerrain_() {
|
|
342
|
+
const camera = this.viewer.camera;
|
|
343
|
+
const terrainHeight = this.groundHeight(camera.positionCartographic);
|
|
344
|
+
if (terrainHeight === undefined) {
|
|
345
|
+
// ground not loaded yet at this position
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
if (this.ground_ !== undefined) {
|
|
349
|
+
// in the air, or off a drop deeper than a step, the walker keeps its height and falls the rest
|
|
350
|
+
const drop = this.ground_ - terrainHeight;
|
|
351
|
+
if (this.jumpOffset_ > 0 || this.jumpVelocity_ !== 0 || drop > this.stepHeight) {
|
|
352
|
+
this.jumpOffset_ += drop;
|
|
353
|
+
if (this.jumpOffset_ <= 0) {
|
|
354
|
+
// on the ground again: landed, or lifted by the ground while still going up
|
|
355
|
+
this.jumpOffset_ = 0;
|
|
356
|
+
if (this.jumpVelocity_ <= 0) {
|
|
357
|
+
this.jumpVelocity_ = 0;
|
|
358
|
+
if (this.headBob) {
|
|
359
|
+
this.landing_ = 1;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
this.ground_ = terrainHeight;
|
|
366
|
+
const cameraHeight = camera.positionCartographic.height;
|
|
367
|
+
|
|
368
|
+
// move along the surface normal, not the camera up vector, which is
|
|
369
|
+
// tilted when the camera is pitched
|
|
370
|
+
const bob = DIP * this.bob_ * Math.abs(Math.sin(this.stepPhase_)) + LANDING * this.landing_;
|
|
371
|
+
const offset = terrainHeight + this.height + this.jumpOffset_ - bob - cameraHeight;
|
|
372
|
+
if (Math.abs(offset) > SETTLED) {
|
|
373
|
+
camera.move(this.surfaceNormal_(), offset);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* @return {import('@cesium/core').Cartesian3} The ellipsoid surface normal at the camera position.
|
|
379
|
+
*/
|
|
380
|
+
surfaceNormal_() {
|
|
381
|
+
const scene = this.viewer.scene;
|
|
382
|
+
return scene.globe.ellipsoid.geodeticSurfaceNormal(scene.camera.position, normalScratch);
|
|
383
|
+
}
|
|
384
|
+
}
|
package/mesh-builder.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import {Cartesian2, PolygonPipeline} from "@cesium/core";
|
|
2
|
+
|
|
3
|
+
// Triangles of one color, built face by face, for createWalkMesh (walk-mesh.js).
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @typedef {object} Chart A face's place among a group's vertices, for laying it out in an atlas.
|
|
7
|
+
* @property {number} start Its first vertex.
|
|
8
|
+
* @property {number} count Its vertices.
|
|
9
|
+
* @property {number[]} normal Its normal, not of unit length.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** The normal of a flat ring of corners, by Newell's method, of any length. */
|
|
13
|
+
function newell(/** @type {number[][]} */ corners) {
|
|
14
|
+
const n = [0, 0, 0];
|
|
15
|
+
corners.forEach(([x, y, z], i) => {
|
|
16
|
+
const [nx, ny, nz] = corners[(i + 1) % corners.length];
|
|
17
|
+
n[0] += (y - ny) * (z + nz);
|
|
18
|
+
n[1] += (z - nz) * (x + nx);
|
|
19
|
+
n[2] += (x - nx) * (y + ny);
|
|
20
|
+
});
|
|
21
|
+
return n;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export class Group {
|
|
25
|
+
/**
|
|
26
|
+
* Triangles of one color, in meters, z up.
|
|
27
|
+
* @param {number[]} color Linear, and its alpha.
|
|
28
|
+
* @param {{charts?: boolean} & Record<string, any>} [options] The walk group's (show, collide, shadows, emissive,
|
|
29
|
+
* uv, customShader, ...), passed on; charts: each face recorded, for an atlas.
|
|
30
|
+
*/
|
|
31
|
+
constructor(color, {charts = false, ...options} = {}) {
|
|
32
|
+
this.color = color;
|
|
33
|
+
this.options = options;
|
|
34
|
+
/** @type {number[]} */
|
|
35
|
+
this.positions = [];
|
|
36
|
+
/** @type {number[]} */
|
|
37
|
+
this.indices = [];
|
|
38
|
+
/** @type {Chart[] | undefined} */
|
|
39
|
+
this.charts = charts ? [] : undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A planar convex face, its corners in order.
|
|
44
|
+
* @param {...number[]} corners [x, y, z]
|
|
45
|
+
*/
|
|
46
|
+
face(...corners) {
|
|
47
|
+
const first = this.positions.length / 3;
|
|
48
|
+
corners.forEach((corner) => this.positions.push(...corner));
|
|
49
|
+
for (let i = 1; i + 1 < corners.length; i++) {
|
|
50
|
+
this.indices.push(first, first + i, first + i + 1);
|
|
51
|
+
}
|
|
52
|
+
this.charts?.push({start: first, count: corners.length, normal: newell(corners)});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* A flat polygon with holes, its height at each corner.
|
|
57
|
+
* @param {number[][]} outline [x, y]
|
|
58
|
+
* @param {number[][][]} holes
|
|
59
|
+
* @param {(x: number, y: number) => number} z
|
|
60
|
+
*/
|
|
61
|
+
polygon(outline, holes, z) {
|
|
62
|
+
const points = [outline, ...holes].flat();
|
|
63
|
+
/** @type {number[]} */
|
|
64
|
+
const starts = [];
|
|
65
|
+
let start = outline.length;
|
|
66
|
+
for (const hole of holes) {
|
|
67
|
+
starts.push(start);
|
|
68
|
+
start += hole.length;
|
|
69
|
+
}
|
|
70
|
+
const first = this.positions.length / 3;
|
|
71
|
+
points.forEach(([x, y]) => this.positions.push(x, y, z(x, y)));
|
|
72
|
+
PolygonPipeline.triangulate(points.map(([x, y]) => new Cartesian2(x, y)), starts).forEach((i) => this.indices.push(first + i));
|
|
73
|
+
this.charts?.push({start: first, count: points.length, normal: newell(outline.map(([x, y]) => [x, y, z(x, y)]))});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The faces of a ring between two heights.
|
|
78
|
+
* @param {number[][]} ring [x, y]
|
|
79
|
+
* @param {number} z0
|
|
80
|
+
* @param {number} z1
|
|
81
|
+
*/
|
|
82
|
+
sides(ring, z0, z1) {
|
|
83
|
+
ring.forEach((a, i) => {
|
|
84
|
+
const b = ring[(i + 1) % ring.length];
|
|
85
|
+
this.face([...a, z0], [...b, z0], [...b, z1], [...a, z1]);
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* An axis-aligned box.
|
|
91
|
+
* @param {number} x0
|
|
92
|
+
* @param {number} y0
|
|
93
|
+
* @param {number} z0
|
|
94
|
+
* @param {number} x1
|
|
95
|
+
* @param {number} y1
|
|
96
|
+
* @param {number} z1
|
|
97
|
+
*/
|
|
98
|
+
box(x0, y0, z0, x1, y1, z1) {
|
|
99
|
+
this.sides([[x0, y0], [x1, y0], [x1, y1], [x0, y1]], z0, z1);
|
|
100
|
+
this.face([x0, y0, z1], [x1, y0, z1], [x1, y1, z1], [x0, y1, z1]);
|
|
101
|
+
this.face([x0, y0, z0], [x1, y0, z0], [x1, y1, z0], [x0, y1, z0]);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The group for createWalkMesh: its color, vertices and triangles, its options, its charts if recorded. */
|
|
105
|
+
get walkGroup() {
|
|
106
|
+
return {color: this.color, vertices: new Float32Array(this.positions), triangles: new Uint32Array(this.indices), ...this.options,
|
|
107
|
+
...(this.charts ? {charts: this.charts} : {})};
|
|
108
|
+
}
|
|
109
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,35 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@geoblocks/cesium-walk",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "BSD-3-Clause",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/geoblocks/cesium-helpers/",
|
|
9
|
+
"directory": "packages/cesium-walk"
|
|
10
|
+
},
|
|
11
|
+
"main": "cesium-walk.js",
|
|
12
|
+
"module": "cesium-walk.js",
|
|
13
|
+
"sideEffects": false,
|
|
14
|
+
"exports": {
|
|
15
|
+
".": "./cesium-walk.js",
|
|
16
|
+
"./walk-mesh.js": "./walk-mesh.js",
|
|
17
|
+
"./mesh-builder.js": "./mesh-builder.js"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"cesium-walk.js",
|
|
21
|
+
"walk-mesh.js",
|
|
22
|
+
"mesh-builder.js"
|
|
23
|
+
],
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
},
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"@geoblocks/cesium-input": "^0.1.0",
|
|
29
|
+
"@geoblocks/cesium-obstacles": "^0.1.0"
|
|
30
|
+
},
|
|
31
|
+
"peerDependencies": {
|
|
32
|
+
"@cesium/core": ">=0.1.0",
|
|
33
|
+
"@cesium/engine": ">=10"
|
|
34
|
+
}
|
|
35
|
+
}
|
package/walk-mesh.js
ADDED
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
import {Cartesian3, Cartographic, Ellipsoid, Matrix4} from "@cesium/core";
|
|
2
|
+
import {Axis, Model, PrimitiveCollection, ShadowMode} from "@cesium/engine";
|
|
3
|
+
|
|
4
|
+
// Triangles to walk on, in meters, z up, in the frame of a model matrix: their models, and the floors and
|
|
5
|
+
// walls a walker meets, without reading the depth, which waits for the GPU.
|
|
6
|
+
|
|
7
|
+
// the ground's cells, in meters
|
|
8
|
+
const CELL = 2;
|
|
9
|
+
// the faces flatter than this, of the cosine of their slope, are floors, stairs and ramps
|
|
10
|
+
const FLOOR = 0.5;
|
|
11
|
+
|
|
12
|
+
const originScratch = new Cartesian3();
|
|
13
|
+
const directionScratch = new Cartesian3();
|
|
14
|
+
const upScratch = new Cartesian3();
|
|
15
|
+
const rayScratch = new Cartesian3();
|
|
16
|
+
const rayOriginScratch = new Cartesian3();
|
|
17
|
+
const kneesScratch = new Cartographic();
|
|
18
|
+
const floorScratch = new Cartesian3();
|
|
19
|
+
const floorCartographicScratch = new Cartographic();
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @typedef {object} WalkGroup Triangles of one color.
|
|
23
|
+
* @property {number[]} color The base color, linear, and its alpha: translucent below 1.
|
|
24
|
+
* @property {Float32Array} vertices x, y, z, in meters, z up.
|
|
25
|
+
* @property {Uint32Array} triangles
|
|
26
|
+
* @property {boolean} [show] false: walked on, not seen (an invisible edge).
|
|
27
|
+
* @property {boolean} [collide] false: seen, not walked on (the battens, the water).
|
|
28
|
+
* @property {boolean} [shadows] false: casts no shadow (the glass, the water).
|
|
29
|
+
* @property {number} [emissive] The share of its color it gives off: the light indoors, which Cesium does not
|
|
30
|
+
* bounce off the walls.
|
|
31
|
+
* @property {boolean} [uv] Whether its vertices carry their x and y as texture coordinates, in meters, for a
|
|
32
|
+
* shader drawing on the floors.
|
|
33
|
+
* @property {Float32Array} [lightmap] Its vertices' coordinates in a baked light's atlas, u and v each, written as
|
|
34
|
+
* the second texture coordinates.
|
|
35
|
+
* @property {import("@cesium/engine").CustomShader} [customShader]
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The flat shaded model of the triangles, untextured, in glTF's y up, quantized (KHR_mesh_quantization):
|
|
40
|
+
* the positions in 16 bits, scaled back to meters by the node; the normals in 8 bits; each vertex padded to
|
|
41
|
+
* 4 bytes, 12 in all, as against 24 in floats. Exported for the tests.
|
|
42
|
+
* @param {Float32Array} vertices
|
|
43
|
+
* @param {Uint32Array} triangles
|
|
44
|
+
* @param {number[]} color
|
|
45
|
+
* @param {number} emissive
|
|
46
|
+
* @param {boolean} uv Whether to give the vertices their x and y as texture coordinates, in floats.
|
|
47
|
+
* @param {Float32Array | undefined} lightmap The vertices' second texture coordinates, in floats, if any.
|
|
48
|
+
* @return {ArrayBuffer} The binary glTF.
|
|
49
|
+
*/
|
|
50
|
+
export function toGlb(vertices, triangles, color, emissive, uv, lightmap) {
|
|
51
|
+
const count = triangles.length;
|
|
52
|
+
// the extent of the map, in glTF's axes
|
|
53
|
+
const toGltf = (/** @type {number} */ v) => [vertices[v], vertices[v + 2], -vertices[v + 1]];
|
|
54
|
+
const low = [Infinity, Infinity, Infinity];
|
|
55
|
+
const high = [-Infinity, -Infinity, -Infinity];
|
|
56
|
+
for (let v = 0; v < vertices.length; v += 3) {
|
|
57
|
+
toGltf(v).forEach((value, axis) => {
|
|
58
|
+
low[axis] = Math.min(low[axis], value);
|
|
59
|
+
high[axis] = Math.max(high[axis], value);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
// the node maps -32767..32767 back to the widest extent, the same on every axis, which leaves the
|
|
63
|
+
// normals as they are
|
|
64
|
+
const translation = low.map((value, axis) => (value + high[axis]) / 2);
|
|
65
|
+
// a single point: any step keeps it at the node's translation
|
|
66
|
+
const step = Math.max(...low.map((value, axis) => high[axis] - value)) / 65534 || 1;
|
|
67
|
+
const scale = [step, step, step];
|
|
68
|
+
const positions = new Int16Array(count * 4);
|
|
69
|
+
const normals = new Int8Array(count * 4);
|
|
70
|
+
const texCoords = new Float32Array(uv ? count * 2 : 0);
|
|
71
|
+
const lightmapCoords = new Float32Array(lightmap ? count * 2 : 0);
|
|
72
|
+
const min = [Infinity, Infinity, Infinity];
|
|
73
|
+
const max = [-Infinity, -Infinity, -Infinity];
|
|
74
|
+
for (let i = 0; i < count; i += 3) {
|
|
75
|
+
const corners = [0, 1, 2].map(k => toGltf(triangles[i + k] * 3));
|
|
76
|
+
const [a, b, c] = corners.map(([x, y, z]) => new Cartesian3(x, y, z));
|
|
77
|
+
const normal = Cartesian3.cross(Cartesian3.subtract(b, a, new Cartesian3()), Cartesian3.subtract(c, a, new Cartesian3()), new Cartesian3());
|
|
78
|
+
if (Cartesian3.magnitudeSquared(normal) > 0) {
|
|
79
|
+
Cartesian3.normalize(normal, normal);
|
|
80
|
+
}
|
|
81
|
+
corners.forEach((corner, k) => {
|
|
82
|
+
const quantized = corner.map((value, axis) => Math.round((value - translation[axis]) / step));
|
|
83
|
+
positions.set(quantized, (i + k) * 4);
|
|
84
|
+
normals.set([normal.x, normal.y, normal.z].map(n => Math.round(n * 127)), (i + k) * 4);
|
|
85
|
+
for (let axis = 0; axis < 3; axis++) {
|
|
86
|
+
min[axis] = Math.min(min[axis], quantized[axis]);
|
|
87
|
+
max[axis] = Math.max(max[axis], quantized[axis]);
|
|
88
|
+
}
|
|
89
|
+
if (uv) {
|
|
90
|
+
const v = triangles[i + k] * 3;
|
|
91
|
+
texCoords.set([vertices[v], vertices[v + 1]], (i + k) * 2);
|
|
92
|
+
}
|
|
93
|
+
if (lightmap) {
|
|
94
|
+
const v = triangles[i + k];
|
|
95
|
+
lightmapCoords.set([lightmap[v * 2], lightmap[v * 2 + 1]], (i + k) * 2);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
// the float attributes after the positions and normals, each its own buffer view
|
|
100
|
+
const floats = /** @type {[string, Float32Array][]} */ ([...(uv ? [['TEXCOORD_0', texCoords]] : []), ...(lightmap ? [['TEXCOORD_1', lightmapCoords]] : [])]);
|
|
101
|
+
const floatsLength = floats.reduce((n, [, array]) => n + array.byteLength, 0);
|
|
102
|
+
let floatOffset = positions.byteLength + normals.byteLength;
|
|
103
|
+
const json = new TextEncoder().encode(JSON.stringify({
|
|
104
|
+
asset: {version: '2.0'},
|
|
105
|
+
extensionsUsed: ['KHR_mesh_quantization', ...(emissive > 1 ? ['KHR_materials_emissive_strength'] : [])],
|
|
106
|
+
extensionsRequired: ['KHR_mesh_quantization'],
|
|
107
|
+
scene: 0,
|
|
108
|
+
scenes: [{nodes: [0]}],
|
|
109
|
+
nodes: [{mesh: 0, translation, scale}],
|
|
110
|
+
meshes: [{primitives: [{attributes: {POSITION: 0, NORMAL: 1, ...Object.fromEntries(floats.map(([name], k) => [name, 2 + k]))}, material: 0}]}],
|
|
111
|
+
materials: [{
|
|
112
|
+
pbrMetallicRoughness: {baseColorFactor: color, metallicFactor: 0, roughnessFactor: 1},
|
|
113
|
+
// glTF caps the factor at 1, the strength carries the rest
|
|
114
|
+
emissiveFactor: color.slice(0, 3).map(c => Math.min(Math.max(c * Math.min(emissive, 1), 0), 1)),
|
|
115
|
+
...(emissive > 1 ? {extensions: {KHR_materials_emissive_strength: {emissiveStrength: emissive}}} : {}),
|
|
116
|
+
doubleSided: true,
|
|
117
|
+
...(color[3] < 1 ? {alphaMode: 'BLEND'} : {}),
|
|
118
|
+
}],
|
|
119
|
+
buffers: [{byteLength: positions.byteLength + normals.byteLength + floatsLength}],
|
|
120
|
+
bufferViews: [
|
|
121
|
+
{buffer: 0, byteOffset: 0, byteLength: positions.byteLength, byteStride: 8, target: 34962},
|
|
122
|
+
{buffer: 0, byteOffset: positions.byteLength, byteLength: normals.byteLength, byteStride: 4, target: 34962},
|
|
123
|
+
...floats.map(([, array]) => {
|
|
124
|
+
const view = {buffer: 0, byteOffset: floatOffset, byteLength: array.byteLength, target: 34962};
|
|
125
|
+
floatOffset += array.byteLength;
|
|
126
|
+
return view;
|
|
127
|
+
}),
|
|
128
|
+
],
|
|
129
|
+
accessors: [
|
|
130
|
+
{bufferView: 0, componentType: 5122, count, type: 'VEC3', min, max},
|
|
131
|
+
{bufferView: 1, componentType: 5120, normalized: true, count, type: 'VEC3'},
|
|
132
|
+
...floats.map((_, k) => ({bufferView: 2 + k, componentType: 5126, count, type: 'VEC2'})),
|
|
133
|
+
],
|
|
134
|
+
}));
|
|
135
|
+
const jsonLength = Math.ceil(json.length / 4) * 4;
|
|
136
|
+
const binLength = positions.byteLength + normals.byteLength + floatsLength;
|
|
137
|
+
const glb = new ArrayBuffer(12 + 8 + jsonLength + 8 + binLength);
|
|
138
|
+
const view = new DataView(glb);
|
|
139
|
+
view.setUint32(0, 0x46546c67, true);
|
|
140
|
+
view.setUint32(4, 2, true);
|
|
141
|
+
view.setUint32(8, glb.byteLength, true);
|
|
142
|
+
view.setUint32(12, jsonLength, true);
|
|
143
|
+
view.setUint32(16, 0x4e4f534a, true);
|
|
144
|
+
new Uint8Array(glb, 20, jsonLength).fill(0x20).set(json);
|
|
145
|
+
view.setUint32(20 + jsonLength, binLength, true);
|
|
146
|
+
view.setUint32(24 + jsonLength, 0x004e4942, true);
|
|
147
|
+
new Int16Array(glb, 28 + jsonLength, count * 4).set(positions);
|
|
148
|
+
new Int8Array(glb, 28 + jsonLength + positions.byteLength, count * 4).set(normals);
|
|
149
|
+
let offset = 28 + jsonLength + positions.byteLength + normals.byteLength;
|
|
150
|
+
for (const [, array] of floats) {
|
|
151
|
+
new Float32Array(glb, offset, array.length).set(array);
|
|
152
|
+
offset += array.byteLength;
|
|
153
|
+
}
|
|
154
|
+
return glb;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* @param {WalkGroup[]} groups
|
|
159
|
+
* @param {Matrix4} modelMatrix From the triangles' frame to the globe.
|
|
160
|
+
*/
|
|
161
|
+
export async function createWalkMesh(groups, modelMatrix) {
|
|
162
|
+
// a single point, or nothing, has nothing to draw
|
|
163
|
+
const visible = groups.filter(group => group.show !== false && group.triangles.length > 0 &&
|
|
164
|
+
group.vertices.some((value, k) => value !== group.vertices[k % 3]));
|
|
165
|
+
const urls = visible.map(group => URL.createObjectURL(new Blob([toGlb(group.vertices, group.triangles, group.color, group.emissive ?? 0, group.uv ?? false, group.lightmap)], {type: 'model/gltf-binary'})));
|
|
166
|
+
const loaded = await Promise.allSettled(visible.map(async (group, k) => {
|
|
167
|
+
// with glTF's y up only: Cesium would also turn the model's +z to +x, a quarter turn off the frame's axes
|
|
168
|
+
const model = await Model.fromGltfAsync({
|
|
169
|
+
url: urls[k],
|
|
170
|
+
modelMatrix,
|
|
171
|
+
forwardAxis: Axis.X,
|
|
172
|
+
shadows: group.shadows === false ? ShadowMode.RECEIVE_ONLY : ShadowMode.ENABLED,
|
|
173
|
+
...(group.customShader ? {customShader: group.customShader} : {}),
|
|
174
|
+
});
|
|
175
|
+
model.readyEvent.addEventListener(() => URL.revokeObjectURL(urls[k]));
|
|
176
|
+
model.errorEvent.addEventListener(() => URL.revokeObjectURL(urls[k]));
|
|
177
|
+
return model;
|
|
178
|
+
}));
|
|
179
|
+
// in the groups' order
|
|
180
|
+
const primitive = new PrimitiveCollection();
|
|
181
|
+
for (const result of loaded) {
|
|
182
|
+
if (result.status === 'fulfilled') {
|
|
183
|
+
primitive.add(result.value);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const failed = loaded.find(result => result.status === 'rejected');
|
|
187
|
+
if (failed) {
|
|
188
|
+
primitive.destroy();
|
|
189
|
+
urls.forEach(url => URL.revokeObjectURL(url));
|
|
190
|
+
throw failed.reason;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// the groups walked on as one: their vertices one after the other, their triangles renumbered
|
|
194
|
+
const colliding = groups.filter(group => group.collide !== false);
|
|
195
|
+
const vertices = new Float32Array(colliding.reduce((n, group) => n + group.vertices.length, 0));
|
|
196
|
+
const triangles = new Uint32Array(colliding.reduce((n, group) => n + group.triangles.length, 0));
|
|
197
|
+
let vertexOffset = 0;
|
|
198
|
+
let triangleOffset = 0;
|
|
199
|
+
for (const group of colliding) {
|
|
200
|
+
vertices.set(group.vertices, vertexOffset);
|
|
201
|
+
for (let k = 0; k < group.triangles.length; k++) {
|
|
202
|
+
triangles[triangleOffset + k] = group.triangles[k] + vertexOffset / 3;
|
|
203
|
+
}
|
|
204
|
+
vertexOffset += group.vertices.length;
|
|
205
|
+
triangleOffset += group.triangles.length;
|
|
206
|
+
}
|
|
207
|
+
const toLocal = Matrix4.inverse(modelMatrix, new Matrix4());
|
|
208
|
+
// the normals' matrix, the inverse transpose
|
|
209
|
+
const toNormal = Matrix4.transpose(toLocal, new Matrix4());
|
|
210
|
+
|
|
211
|
+
// the triangles, and the floors among them, by the cells they overlap
|
|
212
|
+
/** @type {Map<string, number[]>} */
|
|
213
|
+
const cells = new Map();
|
|
214
|
+
/** @type {Map<string, number[]>} */
|
|
215
|
+
const allCells = new Map();
|
|
216
|
+
const isFloor = new Uint8Array(triangles.length / 3);
|
|
217
|
+
const vertex = (/** @type {number} */ index) => vertices.subarray(index * 3, index * 3 + 3);
|
|
218
|
+
const add = (/** @type {Map<string, number[]>} */ map, /** @type {string} */ key, /** @type {number} */ i) => {
|
|
219
|
+
const list = map.get(key);
|
|
220
|
+
if (list) {
|
|
221
|
+
list.push(i);
|
|
222
|
+
} else {
|
|
223
|
+
map.set(key, [i]);
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
for (let i = 0; i < triangles.length; i += 3) {
|
|
227
|
+
const [a, b, c] = [vertex(triangles[i]), vertex(triangles[i + 1]), vertex(triangles[i + 2])];
|
|
228
|
+
const nx = (b[1] - a[1]) * (c[2] - a[2]) - (b[2] - a[2]) * (c[1] - a[1]);
|
|
229
|
+
const ny = (b[2] - a[2]) * (c[0] - a[0]) - (b[0] - a[0]) * (c[2] - a[2]);
|
|
230
|
+
const nz = (b[0] - a[0]) * (c[1] - a[1]) - (b[1] - a[1]) * (c[0] - a[0]);
|
|
231
|
+
isFloor[i / 3] = Math.abs(nz) >= FLOOR * Math.hypot(nx, ny, nz) ? 1 : 0;
|
|
232
|
+
for (let x = Math.floor(Math.min(a[0], b[0], c[0]) / CELL); x <= Math.floor(Math.max(a[0], b[0], c[0]) / CELL); x++) {
|
|
233
|
+
for (let y = Math.floor(Math.min(a[1], b[1], c[1]) / CELL); y <= Math.floor(Math.max(a[1], b[1], c[1]) / CELL); y++) {
|
|
234
|
+
const key = `${x},${y}`;
|
|
235
|
+
add(allCells, key, i);
|
|
236
|
+
if (isFloor[i / 3]) {
|
|
237
|
+
add(cells, key, i);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* The highest floor at a position of the frame, not above it.
|
|
245
|
+
* @param {number} x
|
|
246
|
+
* @param {number} y
|
|
247
|
+
* @param {number} z
|
|
248
|
+
* @return {number | undefined} Its height, in meters.
|
|
249
|
+
*/
|
|
250
|
+
const floorBelow = (x, y, z) => {
|
|
251
|
+
let highest;
|
|
252
|
+
for (const i of cells.get(`${Math.floor(x / CELL)},${Math.floor(y / CELL)}`) ?? []) {
|
|
253
|
+
const a = triangles[i] * 3, b = triangles[i + 1] * 3, c = triangles[i + 2] * 3;
|
|
254
|
+
const ux = vertices[b] - vertices[a], uy = vertices[b + 1] - vertices[a + 1];
|
|
255
|
+
const vx = vertices[c] - vertices[a], vy = vertices[c + 1] - vertices[a + 1];
|
|
256
|
+
const d = ux * vy - vx * uy;
|
|
257
|
+
if (d === 0) {
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
const s = ((x - vertices[a]) * vy - vx * (y - vertices[a + 1])) / d;
|
|
261
|
+
const t = (ux * (y - vertices[a + 1]) - (x - vertices[a]) * uy) / d;
|
|
262
|
+
if (s < 0 || t < 0 || s + t > 1) {
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
const height = vertices[a + 2] + s * (vertices[b + 2] - vertices[a + 2]) + t * (vertices[c + 2] - vertices[a + 2]);
|
|
266
|
+
if (height <= z && (highest === undefined || height > highest)) {
|
|
267
|
+
highest = height;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return highest;
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
// the ray each triangle was last tested against, so that a triangle over several cells is tested once
|
|
274
|
+
const tested = new Uint32Array(triangles.length / 3);
|
|
275
|
+
let ray = 0;
|
|
276
|
+
/**
|
|
277
|
+
* The nearest triangle along a ray, in meters, by Möller and Trumbore's test, through the cells the
|
|
278
|
+
* ray crosses, nearest first.
|
|
279
|
+
* @param {Cartesian3} o The origin.
|
|
280
|
+
* @param {Cartesian3} d The unit direction.
|
|
281
|
+
* @param {number} far The longest distance.
|
|
282
|
+
* @param {boolean} walls Whether to skip the floors.
|
|
283
|
+
* @return {{distance: number, triangle: number} | undefined}
|
|
284
|
+
*/
|
|
285
|
+
const nearestHit = (o, d, far, walls) => {
|
|
286
|
+
ray++;
|
|
287
|
+
let best;
|
|
288
|
+
let x = Math.floor(o.x / CELL);
|
|
289
|
+
let y = Math.floor(o.y / CELL);
|
|
290
|
+
const stepX = Math.sign(d.x);
|
|
291
|
+
const stepY = Math.sign(d.y);
|
|
292
|
+
// the distances along the ray to the next cell borders, and between borders
|
|
293
|
+
let nextX = stepX === 0 ? Infinity : ((x + (stepX > 0 ? 1 : 0)) * CELL - o.x) / d.x;
|
|
294
|
+
let nextY = stepY === 0 ? Infinity : ((y + (stepY > 0 ? 1 : 0)) * CELL - o.y) / d.y;
|
|
295
|
+
const deltaX = stepX === 0 ? Infinity : CELL / Math.abs(d.x);
|
|
296
|
+
const deltaY = stepY === 0 ? Infinity : CELL / Math.abs(d.y);
|
|
297
|
+
let entry = 0;
|
|
298
|
+
while (entry <= far) {
|
|
299
|
+
const exit = Math.min(nextX, nextY, far);
|
|
300
|
+
for (const i of allCells.get(`${x},${y}`) ?? []) {
|
|
301
|
+
if (tested[i / 3] === ray || (walls && isFloor[i / 3])) {
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
tested[i / 3] = ray;
|
|
305
|
+
const a = triangles[i] * 3, b = triangles[i + 1] * 3, c = triangles[i + 2] * 3;
|
|
306
|
+
const e1x = vertices[b] - vertices[a], e1y = vertices[b + 1] - vertices[a + 1], e1z = vertices[b + 2] - vertices[a + 2];
|
|
307
|
+
const e2x = vertices[c] - vertices[a], e2y = vertices[c + 1] - vertices[a + 1], e2z = vertices[c + 2] - vertices[a + 2];
|
|
308
|
+
const px = d.y * e2z - d.z * e2y, py = d.z * e2x - d.x * e2z, pz = d.x * e2y - d.y * e2x;
|
|
309
|
+
const det = e1x * px + e1y * py + e1z * pz;
|
|
310
|
+
if (Math.abs(det) < 1e-9) {
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
const sx = o.x - vertices[a], sy = o.y - vertices[a + 1], sz = o.z - vertices[a + 2];
|
|
314
|
+
const u = (sx * px + sy * py + sz * pz) / det;
|
|
315
|
+
if (u < 0 || u > 1) {
|
|
316
|
+
continue;
|
|
317
|
+
}
|
|
318
|
+
const qx = sy * e1z - sz * e1y, qy = sz * e1x - sx * e1z, qz = sx * e1y - sy * e1x;
|
|
319
|
+
const v = (d.x * qx + d.y * qy + d.z * qz) / det;
|
|
320
|
+
if (v < 0 || u + v > 1) {
|
|
321
|
+
continue;
|
|
322
|
+
}
|
|
323
|
+
const t = (e2x * qx + e2y * qy + e2z * qz) / det;
|
|
324
|
+
if (t >= 0 && t <= far && (!best || t < best.distance)) {
|
|
325
|
+
best = {distance: t, triangle: i};
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
// a hit within this cell is the nearest; one beyond it may still be beaten in the next cells
|
|
329
|
+
if (best && best.distance <= exit) {
|
|
330
|
+
return best;
|
|
331
|
+
}
|
|
332
|
+
if (exit >= far) {
|
|
333
|
+
return best;
|
|
334
|
+
}
|
|
335
|
+
entry = exit;
|
|
336
|
+
if (nextX < nextY) {
|
|
337
|
+
x += stepX;
|
|
338
|
+
nextX += deltaX;
|
|
339
|
+
} else {
|
|
340
|
+
y += stepY;
|
|
341
|
+
nextY += deltaY;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return best;
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
return {
|
|
348
|
+
primitive,
|
|
349
|
+
floorBelow,
|
|
350
|
+
/**
|
|
351
|
+
* The first surface along a ray on the globe.
|
|
352
|
+
* @param {Cartesian3} origin
|
|
353
|
+
* @param {Cartesian3} direction A unit vector.
|
|
354
|
+
* @param {number} maxDistance In meters.
|
|
355
|
+
* @param {{walls?: boolean}} [options] walls: skip the floors, stairs and ramps, which the walker climbs.
|
|
356
|
+
* @return {{position: Cartesian3, normal: Cartesian3, distance: number} | undefined} Where the
|
|
357
|
+
* ray stops, the surface's normal toward the ray's origin, and the distance in meters.
|
|
358
|
+
*/
|
|
359
|
+
raycast: (origin, direction, maxDistance, {walls = false} = {}) => {
|
|
360
|
+
const o = Matrix4.multiplyByPoint(toLocal, origin, originScratch);
|
|
361
|
+
const d = Matrix4.multiplyByPointAsVector(toLocal, direction, directionScratch);
|
|
362
|
+
// the frame's units in a meter along the ray, should the matrix scale
|
|
363
|
+
const perMeter = Cartesian3.magnitude(d);
|
|
364
|
+
Cartesian3.divideByScalar(d, perMeter, d);
|
|
365
|
+
const hit = nearestHit(o, d, maxDistance * perMeter, walls);
|
|
366
|
+
if (!hit) {
|
|
367
|
+
return undefined;
|
|
368
|
+
}
|
|
369
|
+
const position = Matrix4.multiplyByPoint(modelMatrix, Cartesian3.add(o, Cartesian3.multiplyByScalar(d, hit.distance, rayScratch), rayScratch), new Cartesian3());
|
|
370
|
+
const i = hit.triangle;
|
|
371
|
+
const [a, b, c] = [vertex(triangles[i]), vertex(triangles[i + 1]), vertex(triangles[i + 2])];
|
|
372
|
+
const e1 = new Cartesian3(b[0] - a[0], b[1] - a[1], b[2] - a[2]);
|
|
373
|
+
const e2 = new Cartesian3(c[0] - a[0], c[1] - a[1], c[2] - a[2]);
|
|
374
|
+
const normal = Matrix4.multiplyByPointAsVector(toNormal, Cartesian3.cross(e1, e2, e1), new Cartesian3());
|
|
375
|
+
Cartesian3.normalize(normal, normal);
|
|
376
|
+
if (Cartesian3.dot(normal, direction) > 0) {
|
|
377
|
+
Cartesian3.negate(normal, normal);
|
|
378
|
+
}
|
|
379
|
+
return {position, normal, distance: Cartesian3.distance(origin, position)};
|
|
380
|
+
},
|
|
381
|
+
/**
|
|
382
|
+
* @param {number} x
|
|
383
|
+
* @param {number} y
|
|
384
|
+
* @param {number} z
|
|
385
|
+
* @param {Cartesian3} [result]
|
|
386
|
+
* @return {Cartesian3} A position of the frame on the globe.
|
|
387
|
+
*/
|
|
388
|
+
position: (x, y, z, result = new Cartesian3()) => Matrix4.multiplyByPoint(modelMatrix, Cartesian3.fromElements(x, y, z, result), result),
|
|
389
|
+
/**
|
|
390
|
+
* @param {Cartesian3} position
|
|
391
|
+
* @param {Cartesian3} [result]
|
|
392
|
+
* @return {Cartesian3} A position on the globe in the frame.
|
|
393
|
+
*/
|
|
394
|
+
local: (/** @type {Cartesian3} */ position, result = new Cartesian3()) => Matrix4.multiplyByPoint(toLocal, position, result),
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* The heights above the floor the walls are looked for at: the knees, the waist and the eyes.
|
|
400
|
+
* @param {number} eye
|
|
401
|
+
* @param {number} step
|
|
402
|
+
* @return {number[]} In meters, from the lowest.
|
|
403
|
+
*/
|
|
404
|
+
export function rayHeights(eye, step) {
|
|
405
|
+
return [step + 0.05, (step + eye) / 2, eye];
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Walks a CesiumWalk on a mesh: it stands on the highest floor below its knees, up the stairs, under the
|
|
410
|
+
* arches, else on what it stood on before (the terrain); the walls ahead stop it, from rays cast at the knees,
|
|
411
|
+
* the waist and the eyes, the floors and stairs left to the ground below, in every direction.
|
|
412
|
+
* @param {import('./cesium-walk.js').default} walk
|
|
413
|
+
* @param {Awaited<ReturnType<typeof createWalkMesh>>} mesh
|
|
414
|
+
* @param {{eye: number, step: number, reach: number}} options In meters: the eyes above the floor, the
|
|
415
|
+
* highest step, how far ahead the walls are looked for.
|
|
416
|
+
*/
|
|
417
|
+
export function walkOn(walk, mesh, {eye, step, reach}) {
|
|
418
|
+
const camera = walk.viewer.scene.camera;
|
|
419
|
+
const fallback = walk.groundHeight;
|
|
420
|
+
const heights = rayHeights(eye, step);
|
|
421
|
+
const walls = {walls: true};
|
|
422
|
+
walk.obstacleAhead = toward => {
|
|
423
|
+
const eyes = camera.positionWC;
|
|
424
|
+
const up = Ellipsoid.WGS84.geodeticSurfaceNormal(eyes, upScratch);
|
|
425
|
+
let nearest;
|
|
426
|
+
for (const height of heights) {
|
|
427
|
+
const origin = Cartesian3.add(eyes, Cartesian3.multiplyByScalar(up, height - eye, rayOriginScratch), rayOriginScratch);
|
|
428
|
+
const hit = mesh.raycast(origin, toward, reach, walls);
|
|
429
|
+
if (hit && (!nearest || hit.distance < nearest.distance)) {
|
|
430
|
+
nearest = hit;
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
return nearest && {point: nearest.position, normal: nearest.normal};
|
|
434
|
+
};
|
|
435
|
+
walk.groundHeight = cartographic => {
|
|
436
|
+
const knees = Cartographic.clone(cartographic, kneesScratch);
|
|
437
|
+
knees.height -= eye - step;
|
|
438
|
+
const {x, y, z} = mesh.local(Cartographic.toCartesian(knees, Ellipsoid.WGS84, floorScratch), floorScratch);
|
|
439
|
+
const floor = mesh.floorBelow(x, y, z);
|
|
440
|
+
return floor === undefined ? fallback(cartographic) :
|
|
441
|
+
Cartographic.fromCartesian(mesh.position(x, y, floor, floorScratch), Ellipsoid.WGS84, floorCartographicScratch).height;
|
|
442
|
+
};
|
|
443
|
+
}
|