From 3e6504d6b0fcffb206975571e786af12ddc1c19d Mon Sep 17 00:00:00 2001 From: Thomas Fuhrmann Date: Wed, 12 Mar 2025 13:59:51 +0100 Subject: [PATCH] Add elevation to topography --- app/three/CustomMapHeightNodeShader.ts | 250 +++++++++++++++++++++++++ app/three/SceneView.ts | 26 ++- app/three/utils/build-scene.ts | 68 +++---- 3 files changed, 305 insertions(+), 39 deletions(-) create mode 100644 app/three/CustomMapHeightNodeShader.ts diff --git a/app/three/CustomMapHeightNodeShader.ts b/app/three/CustomMapHeightNodeShader.ts new file mode 100644 index 0000000..460f233 --- /dev/null +++ b/app/three/CustomMapHeightNodeShader.ts @@ -0,0 +1,250 @@ +import { + BufferGeometry, + Intersection, + Material, + MeshPhongMaterial, + NearestFilter, + Raycaster, + RGBAFormat, + Texture, + Vector3, +} from "three"; + +import { + MapHeightNode, + MapNodeGeometry, + MapPlaneNode, + UnitsUtils, + MapNode, + QuadTreePosition, + TextureUtils, + MapView, +} from "geo-three"; + +/** + * Map height node that uses GPU height calculation to generate the deformed plane mesh. + * + * This solution is faster if no mesh interaction is required since all trasnformations are done in the GPU the transformed mesh cannot be accessed for CPU operations (e.g. raycasting). + * + * @param parentNode - The parent node of this node. + * @param mapView - Map view object where this node is placed. + * @param location - Position in the node tree relative to the parent. + * @param level - Zoom level in the tile tree of the node. + * @param x - X position of the node in the tile tree. + * @param y - Y position of the node in the tile tree. + */ +export class CustomMapHeightNodeShader extends MapHeightNode { + /** + * Default height texture applied when tile load fails. + * + * This tile sets the height to sea level where it is common for the data sources to be missing height data. + */ + public static defaultHeightTexture = + TextureUtils.createFillTexture("#0186C0"); + + /** + * Size of the grid of the geometry displayed on the scene for each tile. + */ + public static geometrySize: number = 256; + + /** + * Map node plane geometry. + */ + public static geometry: BufferGeometry = new MapNodeGeometry( + 1.0, + 1.0, + CustomMapHeightNodeShader.geometrySize, + CustomMapHeightNodeShader.geometrySize, + true + ); + + /** + * Base geometry of the map node. + */ + public static baseGeometry: BufferGeometry = MapPlaneNode.geometry; + + /** + * Base scale of the map node. + */ + public static baseScale: Vector3 = new Vector3( + UnitsUtils.EARTH_PERIMETER, + 1, + UnitsUtils.EARTH_PERIMETER + ); + + public constructor( + parentNode: MapHeightNode | undefined | null, + mapView: MapView | undefined | null, + location: number = QuadTreePosition.root, + level: number = 0, + x: number = 0, + y: number = 0 + ) { + const material: Material = CustomMapHeightNodeShader.prepareMaterial( + new MeshPhongMaterial({ + map: MapNode.defaultTexture, + color: 0xffffff, + transparent: true, + opacity: 1.0, + depthTest: true, + }) + ); + + if (parentNode === null) { + parentNode = undefined; + } + + if (mapView === null) { + mapView = undefined; + } + + super( + parentNode, + mapView, + location, + level, + x, + y, + CustomMapHeightNodeShader.geometry, + material + ); + + this.frustumCulled = false; + } + + /** + * Prepare the three.js material to be used in the map tile. + * + * @param material - Material to be transformed. + */ + public static prepareMaterial(material: Material): Material { + material.userData = { + heightMap: { value: CustomMapHeightNodeShader.defaultHeightTexture }, + }; + + material.onBeforeCompile = (shader) => { + // Pass uniforms from userData to the + for (const i in material.userData) { + shader.uniforms[i] = material.userData[i]; + } + + // Vertex variables + shader.vertexShader = + ` + uniform sampler2D heightMap; + ` + shader.vertexShader; + + // Vertex depth logic + // elevation = -10000 + ((R * 256 * 256 + G * 256 + B) * 0.1) + // heightMap stores normalized values in the range [0, 1] + // multiply by 255.0 to obtain values in the range [0, 255] + shader.vertexShader = shader.vertexShader.replace( + "#include ", + ` + #include + + // Calculate height of the tile + vec4 _theight = texture(heightMap, vMapUv); + float _height = ((_theight.r * 255.0 * 65536.0 + _theight.g * 255.0 * 256.0 + _theight.b * 255.0) * 0.1) - 10000.0; + + vec3 _transformed = position + _height * normal; + + // Vertex position based on height + gl_Position = projectionMatrix * modelViewMatrix * vec4(_transformed, 1.0); + ` + ); + }; + + return material; + } + + public async loadData(): Promise { + await super.loadData(); + + this.textureLoaded = true; + } + + public async loadHeightGeometry(): Promise { + if (this.mapView.heightProvider === null) { + throw new Error("GeoThree: MapView.heightProvider provider is null."); + } + + if ( + this.level < this.mapView.heightProvider.minZoom || + this.level > this.mapView.heightProvider.maxZoom + ) { + console.warn("Geo-Three: Loading tile outside of provider range: ", this); + + // @ts-ignore + this.material.map = MapHeightNodeShader.defaultTexture; + // @ts-ignore + this.material.needsUpdate = true; + return; + } + + try { + const image = await this.mapView.heightProvider.fetchTile( + this.level, + this.x, + this.y + ); + + if (this.disposed) { + return; + } + + const texture = new Texture(image as any); + texture.generateMipmaps = false; + texture.format = RGBAFormat; + texture.magFilter = NearestFilter; + texture.minFilter = NearestFilter; + texture.needsUpdate = true; + + // @ts-ignore + this.material.userData.heightMap.value = texture; + } catch (e) { + if (this.disposed) { + return; + } + + console.warn("Geo-Three: Failed to load height data: ", this); + + // Water level texture (assume that missing texture will be water level) + // @ts-ignore + this.material.userData.heightMap.value = + CustomMapHeightNodeShader.defaultHeightTexture; + } + + // @ts-ignore + this.material.needsUpdate = true; + + this.heightLoaded = true; + } + + /** + * Overrides normal raycasting, to avoid raycasting when isMesh is set to false. + * + * Switches the geometry for a simpler one for faster raycasting. + */ + public raycast(raycaster: Raycaster, intersects: Intersection[]): void { + if (this.isMesh === true) { + this.geometry = MapPlaneNode.geometry; + + super.raycast(raycaster, intersects); + + this.geometry = CustomMapHeightNodeShader.geometry; + } + } + + public dispose(): void { + super.dispose(); + + if ( + (this.material as Material).userData.heightMap.value && + (this.material as Material).userData.heightMap.value !== + CustomMapHeightNodeShader.defaultHeightTexture + ) { + (this.material as Material).userData.heightMap.value.dispose(); + } + } +} diff --git a/app/three/SceneView.ts b/app/three/SceneView.ts index 10b9e92..6ab5ab4 100644 --- a/app/three/SceneView.ts +++ b/app/three/SceneView.ts @@ -9,7 +9,14 @@ import { } from "./utils/build-clipping-planes"; import { buildCoordinateGrid } from "./utils/build-coordinate-grid"; import { DragControls } from "three/examples/jsm/Addons.js"; -import { MapView, OpenStreetMapsProvider } from "geo-three"; +import { + DebugProvider, + HeightDebugProvider, + MapTilerProvider, + MapView, + OpenStreetMapsProvider, +} from "geo-three"; +import { CustomMapHeightNodeShader } from "./CustomMapHeightNodeShader"; export class SceneView { private _scene: Scene; @@ -98,6 +105,7 @@ export class SceneView { } } +const MAPTILER_API_KEY = "1JkD1W8u5UM5Tjd8r3Wl "; async function init(container: HTMLElement, modelId = MODEL_ID) { const modelData = await getMetadata(SERVICE_URL + modelId); const mappedFeatures = modelData.mappedfeatures; @@ -147,16 +155,20 @@ async function init(container: HTMLElement, modelId = MODEL_ID) { annotationsGroup.visible = false; scene.add(annotationsGroup); - //const axesHelper = new AxesHelper(5); - //scene.add(axesHelper); - // Create a map tiles provider object const provider = new OpenStreetMapsProvider(); + const heightProvider = new MapTilerProvider( + MAPTILER_API_KEY, + "tiles", + "terrain-rgb", + "png" + ); - // Create the map view of OSM topography - const map = new MapView(MapView.PLANAR, provider); + // Create the map view for OSM topography + const map = new MapView(MapView.PLANAR, provider, heightProvider); + const customNode = new CustomMapHeightNodeShader(null, map); + map.setRoot(customNode); map.rotateX(Math.PI / 2); - // map.position.setComponent(2, -100); map.name = "topography"; scene.add(map); diff --git a/app/three/utils/build-scene.ts b/app/three/utils/build-scene.ts index f04a7b9..5560e81 100644 --- a/app/three/utils/build-scene.ts +++ b/app/three/utils/build-scene.ts @@ -4,39 +4,14 @@ import { WebGLRenderer, AmbientLight, DirectionalLight, + Group, + Object3D, + Vector3, } from "three"; import { OrbitControls } from "three/addons/controls/OrbitControls.js"; import { getCenter3D, getMaxSize } from "./utils"; -const DEG2RAD = Math.PI / 180; -function buildDefaultLights() { - // Ambient light - scene.add(new AmbientLight(0xaaaaaa)); - - // Directional lights - const opt = { - azimuth: 220, - altitude: 45, - }; - - const lambda = (90 - opt.azimuth) * DEG2RAD; - const phi = opt.altitude * DEG2RAD; - - const x = Math.cos(phi) * Math.cos(lambda); - const y = Math.cos(phi) * Math.sin(lambda); - const z = Math.sin(phi); - - const light1 = new DirectionalLight(0xffffff, 0.5); - light1.position.set(x, y, z); - scene.add(light1); - - // Thin light from the opposite direction - const light2 = new DirectionalLight(0xffffff, 0.1); - light2.position.set(-x, -y, -z); - return light2; -} - export interface Extent { xmin: number; ymin: number; @@ -90,14 +65,13 @@ export function buildScene(container: HTMLElement, extent: Extent) { controls.maxDistance = maxSize * 5; controls.update(); - // Scene will hold all our elements such as objects, cameras and lights + // Scene + // set wireframe to false on initial load scene = new Scene(); scene.userData.wireframe = false; // Add lights to the scene - const lights = buildDefaultLights(); - lights.name = "lights"; - scene.add(lights); + buildDefaultLights(scene, extent); return { renderer, scene, camera, controls }; } @@ -119,3 +93,33 @@ function animate() { // required if controls.enableDamping or controls.autoRotate are set to true controls.update(); } + +function buildDefaultLights(scene: Scene, extent: Extent) { + const center = getCenter3D(extent); + + const lights = []; + // Ambient light + const ambient = new AmbientLight(0xffffff, 1.0); + lights.push(ambient); + + // Directional lights + const directionalLight = new DirectionalLight(0xffffff, 1); + directionalLight.position.set( + center.x, + center.y - 200000, + extent.zmax + 100000 + ); + + // Create a target for the ligth + const target = new Object3D(); + target.position.set(center.x, center.y, center.z); + scene.add(target); + + directionalLight.target = target; + lights.push(directionalLight); + + const lightsGroup = new Group(); + lightsGroup.name = "lights"; + lightsGroup.add(...lights); + scene.add(lightsGroup); +}