Files
knsoandClaude Opus 5 fb38652e7f chore(controller): three-player-controller als Ordner statt Submodul
Das Submodul zeigte auf GitHub, der eingetragene Stand 028c652 lag aber nur
lokal vor: GitHub-Master ac39682 plus Showcase mit Figuren und launch.json.
Frische Klone konnten den Controller deshalb nicht laden. Jetzt liegt dieser
Stand als normaler Ordner im Repo, ohne Verweis auf GitHub.

dist/ und node_modules/ im Controller bleiben ignoriert und werden wie bisher
lokal gebaut.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 21:27:03 +02:00

575 lines
19 KiB
JavaScript

import tellux from "tellux";
import {
AxesHelper,
Box3,
Group,
Matrix4,
Quaternion,
Sphere,
Vector3,
} from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
import { GUI } from "three/examples/jsm/libs/lil-gui.module.min.js";
import Stats from "three/examples/jsm/libs/stats.module.js";
import { playerController } from "../src/PlayerController";
// Google Photorealistic 3D Tiles
const GOOGLE_PHOTOREALISTIC_ASSET_ID = 2275207;
const CESIUM_ION_TOKEN =
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiJmMzgwMGY3ZS1jOTMwLTQyNmQtOTkyNS03MDE4ZjlkYmY0MTYiLCJpZCI6MjIzMDk3LCJpYXQiOjE3MTg3NjgwNTN9.FcpK7jiFPzWZL8m6VxRbG7ly8LMecpXnDAMZJX_UehM";
// 出生城市
const CITIES = {
paris: { longitude: 2.2945, latitude: 48.8584, height: 100, hourUTC: 12 },
tokyo: { longitude: 139.7671, latitude: 35.6812, height: 40, hourUTC: 4 },
newyork: { longitude: -74.006, latitude: 40.7128, height: 20, hourUTC: 17 },
london: { longitude: -0.1246, latitude: 51.5007, height: 40, hourUTC: 14 },
sydney: { longitude: 151.2153, latitude: -33.8568, height: 40, hourUTC: 3 },
dubai: { longitude: 55.2744, latitude: 25.1972, height: 40, hourUTC: 8 },
rome: { longitude: 12.4922, latitude: 41.8902, height: 40, hourUTC: 11 },
saopaulo: { longitude: -46.6559, latitude: -23.5615, height: 1000, hourUTC: 16 },
};
const CITY_OPTIONS = {
"New York": "newyork",
Paris: "paris",
Tokyo: "tokyo",
London: "london",
Sydney: "sydney",
Dubai: "dubai",
Rome: "rome",
"São Paulo": "saopaulo",
};
const DEG2RAD = Math.PI / 180;
const RAD2DEG = 180 / Math.PI;
// 动态碰撞:靠近才建,远离再拆
const TILE_ENTER_RADIUS = 100;
const TILE_EXIT_RADIUS = 150;
const MAX_TILE_ADDS_PER_UPDATE = 1;
const MAX_TILE_REMOVES_PER_UPDATE = 4;
// 人物速度;飞行时按住 Shift 进入极速
const BASE_WALK_SPEED = 200;
const BASE_RUN_SPEED = 600;
const BASE_FLY_SPEED = 2100;
const TURBO_SPEED_SCALE = 3;
const container = document.querySelector("#container");
if (!(container instanceof HTMLElement)) {
throw new Error("Missing #container");
}
const attributionsElement = document.querySelector("#google-attributions");
const guiParams = {
city: "newyork",
playerCapsuleDebug: false,
colliderDebug: false,
localAxes: false,
};
// Tellux 视图
const viewer = new tellux.Viewer(container, {
dracoDecoderPath: "https://unpkg.com/three@0.184.0/examples/jsm/libs/draco/gltf/",
useDefaultRenderLoop: false,
camera: {
longitude: CITIES.newyork.longitude,
latitude: CITIES.newyork.latitude,
height: 80,
heading: 0,
pitch: -20,
near: 0.1,
},
scene: {
atmosphere: {
show: true,
lighting: {
mode: "post-process",
sunLight: true,
skyLight: true,
},
sky: {
sun: true,
moon: true,
stars: true,
},
},
clouds: {
show: true,
coverage: 0.35,
},
postProcess: {
lensFlare: { enabled: false },
smaa: { enabled: false },
},
},
});
// 关闭默认地球交互,改由人物控制器接管相机
viewer.controls.enabled = false;
viewer.camera.allowUnderground = true;
viewer.tileset.group.visible = false;
// 控制器
const orbitControls = new OrbitControls(viewer.camera.threeCamera, viewer.renderer.domElement);
orbitControls.enablePan = false;
// 局部坐标轴
const localAxes = new AxesHelper(5000);
localAxes.name = "local-axes";
localAxes.visible = false;
localAxes.renderOrder = 10;
localAxes.material.depthTest = false;
localAxes.material.depthWrite = false;
viewer.scene.threeScene.add(localAxes);
// 帧率
const stats = new Stats();
Object.assign(stats.dom.style, {
position: "fixed",
left: "0",
bottom: "0",
top: "auto",
zIndex: "9998",
});
document.body.appendChild(stats.dom);
let activeLayer = null;
let localFrame = null;
let tileCollision = null;
let player = null;
let skipCollisionAfterRebase = false;
let running = true;
let turboMode = false;
// 局部坐标系下每帧把世界到 ECEF 的矩阵写进大气。
function syncAtmosphereFrame() {
const atmosphere = viewer.atmosphere;
atmosphere.aerialPerspectiveEffect.worldToECEFMatrix.copy(localFrame.localToEcef);
atmosphere.cloudsEffect.worldToECEFMatrix.copy(localFrame.localToEcef);
}
// 以人物附近 ECEF 点建立局部 Y-up 坐标系,走远后 rebase。
class LocalFrameManager {
// 建局部 ENU 根节点,并按出生点设原点
constructor(ellipsoid, origin) {
this.ellipsoid = ellipsoid;
this.rebaseDistance = 10_000;
this.group = new Group();
this.group.name = "local-frame";
this.group.matrixAutoUpdate = false;
this.localToEcef = new Matrix4();
this.ecefToLocal = new Matrix4();
this.deltaMatrix = new Matrix4();
this.deltaQuat = new Quaternion();
this.scratchEcef = new Vector3();
this.scratchCartographic = { lat: 0, lon: 0, height: 0 };
this.setOrigin(origin);
}
// 按经纬高重设局部原点
setOrigin(origin) {
const height = origin.height ?? 0;
this.ellipsoid.getObjectFrame(
origin.latitude * DEG2RAD,
origin.longitude * DEG2RAD,
height,
0,
0,
0,
this.localToEcef
);
this.ecefToLocal.copy(this.localToEcef).invert();
this.group.matrix.copy(this.ecefToLocal);
this.group.updateMatrixWorld(true);
}
// 人物离原点太远时平移局部系,并同步胶囊、相机和速度
maybeRebase(target) {
const position = target.getPosition();
if (!position || position.length() < this.rebaseDistance) return false;
this.scratchEcef.copy(position).applyMatrix4(this.localToEcef);
this.ellipsoid.getPositionToCartographic(this.scratchEcef, this.scratchCartographic);
this.deltaMatrix.copy(this.localToEcef);
this.setOrigin({
latitude: this.scratchCartographic.lat * RAD2DEG,
longitude: this.scratchCartographic.lon * RAD2DEG,
height: this.scratchCartographic.height,
});
this.deltaMatrix.premultiply(this.ecefToLocal);
this.deltaQuat.setFromRotationMatrix(this.deltaMatrix);
const capsule = target.getPlayerCapsule();
if (capsule) this.applyDelta(capsule, this.deltaMatrix);
if (!this.isDescendant(target.camera, capsule)) {
this.applyDelta(target.camera, this.deltaMatrix);
}
target.controls?.target.applyMatrix4(this.deltaMatrix);
target.playerVelocity?.applyQuaternion(this.deltaQuat);
return true;
}
// 判断 object 是否挂在 ancestor 下面
isDescendant(object, ancestor) {
if (!ancestor) return false;
let current = object;
while (current) {
if (current === ancestor) return true;
current = current.parent;
}
return false;
}
// 用 rebase 增量改物体位姿
applyDelta(object, delta) {
object.updateMatrixWorld(true);
object.matrix.premultiply(delta);
object.matrix.decompose(object.position, object.quaternion, object.scale);
object.updateMatrixWorld(true);
}
}
// 登记已加载瓦片,只给当前可见且靠近人物的瓦片建运动学 mesh 碰撞。
class TileCollisionManager {
// 绑定瓦片集和人物,并备好事件回调
constructor(tileset, targetPlayer) {
this.tileset = tileset;
this.player = targetPlayer;
this.attached = false;
this.tracked = new Map();
this.scratchCenter = new Vector3();
this.scratchScale = new Vector3();
this.scratchBox = new Box3();
this.scratchSphere = new Sphere();
this.onLoadModel = (event) => this.track(event.scene, this.isTileVisible(event.tile, event.scene));
this.onDisposeModel = (event) => this.untrack(event.scene);
this.onVisibilityChange = (event) => this.setVisible(event.scene, event.visible);
}
// 监听加载 / 卸载 / 可见性,并补登记已在场的瓦片
attach() {
if (this.attached) return;
this.attached = true;
this.tileset.addEventListener("load-model", this.onLoadModel);
this.tileset.addEventListener("dispose-model", this.onDisposeModel);
this.tileset.addEventListener("tile-visibility-change", this.onVisibilityChange);
this.tileset.forEachLoadedModel((scene, tile) => this.track(scene, this.isTileVisible(tile, scene)));
}
// 去掉瓦片事件,不再跟踪新变化
detach() {
if (!this.attached) return;
this.attached = false;
this.tileset.removeEventListener("load-model", this.onLoadModel);
this.tileset.removeEventListener("dispose-model", this.onDisposeModel);
this.tileset.removeEventListener("tile-visibility-change", this.onVisibilityChange);
}
// 拆掉全部碰撞并停止跟踪
dispose() {
this.detach();
for (const entry of this.tracked.values()) this.removeCollider(entry);
this.tracked.clear();
}
// 只给当前可见且靠近的瓦片建碰撞;隐藏的(含 REPLACE 父级)立刻拆
update(playerPos) {
let adds = 0;
let removes = 0;
for (const entry of this.tracked.values()) {
if (!entry.visible) {
this.removeCollider(entry);
continue;
}
const distance = this.distanceToTile(entry, playerPos);
if (!entry.handle && distance < TILE_ENTER_RADIUS) {
if (adds >= MAX_TILE_ADDS_PER_UPDATE) continue;
const handle = this.player.addCollider({
motion: "kinematic",
useWorker: true,
shape: { kind: "mesh", source: entry.scene },
follow: entry.scene,
});
if (handle.id < 0) continue;
entry.handle = handle;
adds += 1;
} else if (entry.handle && distance > TILE_EXIT_RADIUS) {
if (removes >= MAX_TILE_REMOVES_PER_UPDATE) continue;
this.removeCollider(entry);
removes += 1;
}
}
}
// 人物到瓦片包围球表面的距离;负值表示人在球内
distanceToTile(entry, playerPos) {
entry.scene.updateWorldMatrix(true, false);
this.scratchCenter.copy(entry.localCenter).applyMatrix4(entry.scene.matrixWorld);
entry.scene.getWorldScale(this.scratchScale);
const worldRadius = entry.localRadius * (this.scratchScale.x || 1);
return playerPos.distanceTo(this.scratchCenter) - worldRadius;
}
// 以 traversal.visible 为准,没有则看 scene 是否还挂在树上
isTileVisible(tile, scene) {
if (typeof tile?.traversal?.visible === "boolean") return tile.traversal.visible;
return Boolean(scene?.parent);
}
// 同步可见性;藏起来就拆碰撞,避免粗父级残留
setVisible(scene, visible) {
const entry = this.tracked.get(scene) ?? this.track(scene, visible);
if (!entry) return;
entry.visible = visible;
if (!visible) this.removeCollider(entry);
}
// 登记瓦片局部中心和半径,已跟踪则只改可见性
track(scene, visible = Boolean(scene.parent)) {
const existing = this.tracked.get(scene);
if (existing) {
existing.visible = visible;
return existing;
}
scene.updateWorldMatrix(true, true);
this.scratchBox.setFromObject(scene);
if (this.scratchBox.isEmpty()) return null;
this.scratchBox.getBoundingSphere(this.scratchSphere);
const localCenter = scene.worldToLocal(this.scratchSphere.center.clone());
scene.getWorldScale(this.scratchScale);
const scale = this.scratchScale.x || 1;
const entry = {
scene,
localCenter,
localRadius: this.scratchSphere.radius / scale,
visible,
handle: null,
};
this.tracked.set(scene, entry);
return entry;
}
// 瓦片卸载时拆碰撞并移出跟踪表
untrack(scene) {
const entry = this.tracked.get(scene);
if (!entry) return;
this.removeCollider(entry);
this.tracked.delete(scene);
}
// 从人物控制器移除该瓦片的运动学碰撞
removeCollider(entry) {
if (!entry.handle) return;
this.player.removeCollider(entry.handle);
entry.handle = null;
}
}
// 当前选中的出生城市
function currentCity() {
return guiParams.city in CITIES ? guiParams.city : "newyork";
}
// 局部坐标系中的出生点
function spawnLocalPosition() {
return new Vector3(0, 100, 0);
}
// 渲染 Google / Cesium 版权信息
function renderAttributions() {
if (!attributionsElement) return;
const attributions = activeLayer?.tileset.getAttributions() ?? [];
attributionsElement.replaceChildren();
for (const attribution of attributions) {
if (attribution.type === "image") {
const image = document.createElement("img");
image.src = String(attribution.value);
image.alt = "";
attributionsElement.append(image);
continue;
}
if (attribution.value) {
const item = document.createElement("span");
item.innerHTML = String(attribution.value);
attributionsElement.append(item);
}
}
}
// 创建或更新局部坐标系
function ensureLocalFrame(origin) {
if (!localFrame) {
localFrame = new LocalFrameManager(viewer.tileset.ellipsoid, origin);
viewer.scene.threeScene.add(localFrame.group);
return;
}
localFrame.setOrigin(origin);
}
// 飞行时按住 Shift 切换极速
function syncTurboMode() {
if (!player) return;
const next = player.isFlying && player.input.shift;
if (next === turboMode) return;
turboMode = next;
player.setPlayerFlySpeed(BASE_FLY_SPEED * (turboMode ? TURBO_SPEED_SCALE : 1));
}
// 极速时拉宽视野
function updateTurboFov() {
const camera = viewer.camera.threeCamera;
const targetFov = turboMode ? 120 : 60;
if (Math.abs(camera.fov - targetFov) > 0.01) {
camera.fov += (targetFov - camera.fov) * 0.01;
camera.updateProjectionMatrix();
}
}
// 切换城市或重新出生时悬停,等待周围地形加载
function respawn() {
ensureLocalFrame(CITIES[currentCity()]);
viewer.clock.hourUTC = CITIES[currentCity()].hourUTC;
player?.reset(spawnLocalPosition());
if (player && !player.isFlying) player.setInput({ toggleFly: true });
player?.setSkipCapsuleCollision(true);
skipCollisionAfterRebase = true;
}
// 加载 Google Photorealistic 3D Tiles
function loadPhotorealisticTiles() {
ensureLocalFrame(CITIES[currentCity()]);
activeLayer = viewer.load3DTileset({
type: "cesium-ion",
id: "photorealistic-3d-tiles",
assetId: GOOGLE_PHOTOREALISTIC_ASSET_ID,
apiToken: CESIUM_ION_TOKEN,
creasedNormals: true,
materialMode: "unlit",
});
localFrame.group.add(activeLayer.tileset.group);
localFrame.group.updateMatrixWorld(true);
}
// 玩家控制器
async function initPlayer() {
const loader = new GLTFLoader();
const gltf = await loader.loadAsync("./glb/josh.glb");
player = new playerController();
await player.init({
scene: viewer.scene.threeScene,
camera: viewer.camera.threeCamera,
controls: orbitControls,
initPos: spawnLocalPosition(),
minCamDistance: 5,
maxCamDistance: 200,
playerModelConfig: {
model: gltf.scene,
animations: gltf.animations ?? [],
scale: 0.01,
idleAnim: "idle1",
walkAnim: "walk",
runAnim: "run",
jumpAnim: ["jump", "Jump_Loop", "Jump_Land"],
flyAnim: "fly",
flyIdleAnim: "flyIdle",
flyHoverForwardAnim: "flyHoverForward",
flyHoverBackAnim: "flyHoverBack",
flyHoverLeftAnim: "flyHoverLeft",
flyHoverRightAnim: "flyHoverRight",
flyHoverUpAnim: "flyHoverUp",
flyHoverDownAnim: "flyHoverDown",
headBoneName: "mixamorigHead",
rotateY: Math.PI,
speed: BASE_WALK_SPEED,
runSpeed: BASE_RUN_SPEED,
flySpeed: BASE_FLY_SPEED,
acceleration: 500,
deceleration: 10000000,
},
});
player.setPlayerCapsuleDebug(guiParams.playerCapsuleDebug);
player.setColliderDebug(guiParams.colliderDebug);
// 启用实际太阳光、天空光和环境反射。
viewer.atmosphere.setPostProcessMaterialLights(true);
}
// 创建调试面板
function initGUI() {
const gui = new GUI({ title: "3D Tiles Scene", width: 260 });
if (window.matchMedia("(hover: none) and (pointer: coarse), (max-width: 768px)").matches) gui.close();
Object.assign(gui.domElement.style, {
position: "fixed",
top: "12px",
right: "12px",
zIndex: "9999",
});
gui.domElement.addEventListener("pointerdown", (e) => e.stopPropagation());
gui.add(guiParams, "city", CITY_OPTIONS).name("City").onChange(respawn);
gui.add(guiParams, "playerCapsuleDebug").name("Capsule Debug").onChange((value) => {
player?.setPlayerCapsuleDebug(value);
});
gui.add(guiParams, "colliderDebug").name("Collider Debug").onChange((value) => {
player?.setColliderDebug(value);
});
gui.add(guiParams, "localAxes").name("Local Axes").onChange((value) => {
localAxes.visible = value;
});
}
// 启动示例
async function start() {
await viewer.ready;
loadPhotorealisticTiles();
await initPlayer();
tileCollision = new TileCollisionManager(activeLayer.tileset, player);
tileCollision.attach();
respawn();
initGUI();
requestAnimationFrame(loop);
// 关闭加载页面
window.hideLoader?.();
}
// 每帧调用
function loop(time) {
if (!running) return;
if (player) {
if (skipCollisionAfterRebase) {
player.setSkipCapsuleCollision(false);
skipCollisionAfterRebase = false;
}
syncTurboMode();
void player.update();
if (localFrame?.maybeRebase(player)) {
player.setSkipCapsuleCollision(true);
skipCollisionAfterRebase = true;
}
const pos = player.getPosition();
if (pos) tileCollision?.update(pos);
}
updateTurboFov();
// 局部坐标系下同步大气世界到 ECEF 的变换
syncAtmosphereFrame();
viewer.render(time);
renderAttributions();
stats.update();
requestAnimationFrame(loop);
}
window.viewer = viewer;
void start();
// 页面卸载时释放资源
window.addEventListener("beforeunload", () => {
running = false;
tileCollision?.dispose();
player?.destroy();
stats.dom.remove();
viewer.destroy();
});