3d-outdoor-map-explorer / js /engine /WorldEngine.js
timfromhcs's picture
Deploy 3D Outdoor Map Explorer and POV Walker
044e5b0 verified
Raw History Blame Contribute Delete
14.7 kB
/**
* WorldEngine.js
* Core Three.js runtime engine coordinating render layers, dual-camera system
* (POV / Orbit), asset loading, and animation loop.
*/
class WorldEngine {
constructor(container) {
this.container = container;
this.scene = new THREE.Scene();
// Atmospheric daylight sky and subtle haze
this.scene.background = new THREE.Color(0x8cb8e6);
this.scene.fog = new THREE.FogExp2(0xa5cbf0, 0.008);
this.camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.05, 2000);
this.renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: "high-performance" });
this.renderer.setSize(window.innerWidth, window.innerHeight);
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
this.renderer.outputEncoding = THREE.sRGBEncoding;
this.renderer.toneMapping = THREE.ACESFilmicToneMapping;
this.renderer.toneMappingExposure = 0.95;
this.renderer.shadowMap.enabled = true;
this.renderer.shadowMap.type = THREE.PCFSoftShadowMap;
this.container.appendChild(this.renderer.domElement);
this.collisionSystem = new CollisionSystem();
this.player = new PlayerController(this.camera, this.renderer.domElement, this.collisionSystem);
this.orbitControls = new THREE.OrbitControls(this.camera, this.renderer.domElement);
this.orbitControls.enableDamping = true;
this.orbitControls.dampingFactor = 0.08;
this.orbitControls.maxPolarAngle = Math.PI / 2 + 0.05; // Prevent camera going below ground
this.orbitControls.enabled = true; // Starts in Orbit Overview mode
this.currentMode = "ORBIT"; // "ORBIT" or "POV"
this.currentMapManifest = null;
// Visual Layers
this.layers = {
render: null,
collision: null,
walkable: null,
segmentation: null,
navGraph: null,
bbox: null,
grid: null,
playerHelper: null
};
this.layerVisibility = {
render: true,
collision: false,
walkable: false,
segmentation: false,
navGraph: false,
wireframe: false,
bbox: false,
playerHelper: false
};
this.clock = new THREE.Clock();
this.loader = new THREE.GLTFLoader();
this._setupLighting();
this._setupGroundGrid();
this._setupPlayerHelper();
window.addEventListener('resize', () => this.onResize());
}
_setupLighting() {
// Multi-angle realistic outdoor daylight lighting calibrated for vertex colors
const ambient = new THREE.AmbientLight(0xffffff, 0.35);
this.scene.add(ambient);
// Sky & ground hemisphere bounce
const hemiLight = new THREE.HemisphereLight(0xffffff, 0x556677, 0.45);
hemiLight.position.set(0, 50, 0);
this.scene.add(hemiLight);
// Primary warm sun
const sun = new THREE.DirectionalLight(0xfff5e6, 0.75);
sun.position.set(25, 45, 30);
sun.castShadow = true;
this.scene.add(sun);
// Secondary fill / ground bounce light
const backSun = new THREE.DirectionalLight(0xddeeff, 0.25);
backSun.position.set(-25, 20, -25);
this.scene.add(backSun);
}
_setupGroundGrid() {
const grid = new THREE.GridHelper(80, 40, 0x5588bb, 0x99bbee);
grid.position.y = -0.01;
this.layers.grid = grid;
this.scene.add(grid);
}
_setupPlayerHelper() {
const geom = new THREE.CylinderGeometry(0.35, 0.35, 1.7, 12);
const mat = new THREE.MeshStandardMaterial({ color: 0x00e5ff, wireframe: true });
this.layers.playerHelper = new THREE.Mesh(geom, mat);
this.layers.playerHelper.visible = false;
this.scene.add(this.layers.playerHelper);
}
onResize() {
this.camera.aspect = window.innerWidth / window.innerHeight;
this.camera.updateProjectionMatrix();
this.renderer.setSize(window.innerWidth, window.innerHeight);
}
/**
* Toggles between First Person POV mode and Aerial Orbit mode.
*/
setMode(mode) {
this.currentMode = mode;
if (mode === "POV") {
this.orbitControls.enabled = false;
this.player.respawn("POV");
this.player.controls.lock();
this.layers.playerHelper.visible = false;
} else {
this.player.controls.unlock();
this.orbitControls.enabled = true;
if (this.layers.render) {
const box = new THREE.Box3().setFromObject(this.layers.render);
const center = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
const maxDim = Math.max(size.x, size.z, 15);
this.orbitControls.target.copy(center);
this.camera.position.set(
center.x + maxDim * 0.85,
center.y + maxDim * 0.75,
center.z + maxDim * 0.85
);
} else {
this.orbitControls.target.copy(this.player.position);
this.camera.position.set(
this.player.position.x + 10,
this.player.position.y + 8,
this.player.position.z + 10
);
}
this.orbitControls.update();
this.layers.playerHelper.visible = this.layerVisibility.playerHelper;
}
}
/**
* Clears all layers of the currently loaded map.
*/
clearMap() {
['render', 'collision', 'walkable', 'segmentation', 'navGraph', 'bbox'].forEach(k => {
if (this.layers[k]) {
this.scene.remove(this.layers[k]);
this.layers[k] = null;
}
});
}
/**
* Loads a map dynamically from its manifest.
*/
async loadMap(manifest) {
this.currentMapManifest = manifest;
this.clearMap();
const basePath = `/output/${manifest.id}`;
// Helper to load GLB safely
const loadGLB = (relPath) => {
return new Promise((resolve) => {
if (!relPath) return resolve(null);
const url = `${basePath}/${relPath}`;
this.loader.load(
url,
(gltf) => resolve(gltf.scene),
undefined,
(err) => {
console.warn(`Asset not available: ${url}`);
resolve(null);
}
);
});
};
// Load visual and physical layers concurrently
const [renderScene, colScene, walkScene, segScene] = await Promise.all([
loadGLB(manifest.assets.render ? manifest.assets.render.path : null),
loadGLB(manifest.assets.collision ? manifest.assets.collision.path : null),
loadGLB(manifest.assets.walkable ? manifest.assets.walkable.path : null),
loadGLB(manifest.assets.segmentation ? manifest.assets.segmentation.path : null)
]);
if (renderScene) {
this.layers.render = renderScene;
this.layers.render.visible = this.layerVisibility.render;
this.scene.add(renderScene);
}
if (colScene) {
this.layers.collision = colScene;
this.layers.collision.visible = this.layerVisibility.collision;
this.scene.add(colScene);
}
if (walkScene) {
this.layers.walkable = walkScene;
this.layers.walkable.visible = this.layerVisibility.walkable;
this.scene.add(walkScene);
}
if (segScene) {
this.layers.segmentation = segScene;
this.layers.segmentation.visible = this.layerVisibility.segmentation;
this.scene.add(segScene);
}
// Configure high visual fidelity, double-sided rendering & smooth normals
[renderScene, colScene, walkScene, segScene].forEach(scene => {
if (!scene) return;
scene.traverse(child => {
if (child.isMesh) {
if (child.material) {
if (Array.isArray(child.material)) {
child.material.forEach(m => {
m.side = THREE.DoubleSide;
m.roughness = 0.65;
m.metalness = 0.05;
m.needsUpdate = true;
});
} else {
child.material.side = THREE.DoubleSide;
child.material.roughness = 0.65;
child.material.metalness = 0.05;
child.material.needsUpdate = true;
}
}
if (child.geometry && !child.geometry.attributes.normal) {
child.geometry.computeVertexNormals();
}
}
});
});
// Bounding Box Helper
const mainMesh = renderScene || colScene || walkScene;
if (mainMesh) {
const box = new THREE.Box3().setFromObject(mainMesh);
this.layers.bbox = new THREE.Box3Helper(box, 0x58a6ff);
this.layers.bbox.visible = this.layerVisibility.bbox;
this.scene.add(this.layers.bbox);
// Auto-frame Orbit camera to center of map
if (this.currentMode === 'ORBIT') {
const center = box.getCenter(new THREE.Vector3());
const size = box.getSize(new THREE.Vector3());
const maxDim = Math.max(size.x, size.z, 15);
this.orbitControls.target.copy(center);
this.camera.position.set(
center.x + maxDim * 0.85,
center.y + maxDim * 0.75,
center.z + maxDim * 0.85
);
this.orbitControls.update();
}
}
// Load Nav Graph if available
if (manifest.assets.nav_graph) {
this._loadNavGraph(`${basePath}/${manifest.assets.nav_graph.path}`);
}
// Extract primary mesh for collision detection
const colMesh = this._extractMainTrimesh(this.layers.collision || this.layers.walkable || this.layers.render);
const walkMesh = this._extractMainTrimesh(this.layers.walkable);
this.collisionSystem.setCollisionMesh(colMesh, walkMesh, manifest.bounds);
// Position player at safe spawn point
const spawn = (manifest.spawn_points && manifest.spawn_points.length > 0)
? manifest.spawn_points[0]
: { position: [0, 1.7, 0], lookYaw: 0 };
this.player.setSpawn(spawn, manifest.bounds.extents, this.currentMode);
this.applyWireframe(this.layerVisibility.wireframe);
return true;
}
_extractMainTrimesh(sceneGroup) {
if (!sceneGroup) return null;
let found = null;
sceneGroup.traverse((child) => {
if (child.isMesh && child.geometry && !found) {
found = child;
}
});
return found;
}
async _loadNavGraph(url) {
try {
const res = await fetch(url);
if (!res.ok) return;
const data = await res.json();
const graphGroup = new THREE.Group();
// Render nodes
const nodeGeom = new THREE.SphereGeometry(0.008, 6, 6);
const nodeMat = new THREE.MeshBasicMaterial({ color: 0x00e5ff });
const nodeInstanced = new THREE.InstancedMesh(nodeGeom, nodeMat, data.nodes.length);
const matrix = new THREE.Matrix4();
data.nodes.forEach((n, i) => {
matrix.setPosition(n.x, n.y + 0.01, n.z);
nodeInstanced.setMatrixAt(i, matrix);
});
nodeInstanced.instanceMatrix.needsUpdate = true;
graphGroup.add(nodeInstanced);
// Render edges
const linePositions = [];
const nodeMap = new Map();
data.nodes.forEach(n => nodeMap.set(n.id, [n.x, n.y + 0.01, n.z]));
data.edges.forEach(e => {
const p1 = nodeMap.get(e.source);
const p2 = nodeMap.get(e.target);
if (p1 && p2) {
linePositions.push(...p1, ...p2);
}
});
if (linePositions.length > 0) {
const lineGeom = new THREE.BufferGeometry();
lineGeom.setAttribute('position', new THREE.Float32BufferAttribute(linePositions, 3));
const lineMat = new THREE.LineBasicMaterial({ color: 0x00bcd4, transparent: true, opacity: 0.6 });
const lines = new THREE.LineSegments(lineGeom, lineMat);
graphGroup.add(lines);
}
graphGroup.visible = this.layerVisibility.navGraph;
this.layers.navGraph = graphGroup;
this.scene.add(graphGroup);
} catch (e) {
console.warn("Could not load nav graph visualizer:", e);
}
}
setLayerVisible(name, visible) {
this.layerVisibility[name] = visible;
if (this.layers[name]) {
this.layers[name].visible = visible;
}
}
applyWireframe(wireframe) {
this.layerVisibility.wireframe = wireframe;
this.scene.traverse((obj) => {
if (obj.isMesh && obj.material && obj !== this.layers.playerHelper) {
if (Array.isArray(obj.material)) {
obj.material.forEach(m => m.wireframe = wireframe);
} else {
obj.material.wireframe = wireframe;
}
}
});
}
update() {
const delta = this.clock.getDelta();
if (this.currentMode === "POV") {
this.player.update(delta);
} else {
this.orbitControls.update();
// Sync player helper position in orbit mode
if (this.layers.playerHelper) {
this.layers.playerHelper.position.set(
this.player.position.x,
this.player.position.y + this.player.height * 0.5,
this.player.position.z
);
}
}
this.renderer.render(this.scene, this.camera);
}
}
window.WorldEngine = WorldEngine;