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>
575 lines
19 KiB
JavaScript
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();
|
|
});
|