diff --git a/app/components/Form.tsx b/app/components/Form.tsx index f1fd145..00862fc 100644 --- a/app/components/Form.tsx +++ b/app/components/Form.tsx @@ -166,6 +166,7 @@ export function Form() { if (!sceneView) return; sceneView.toggleLayerVisibility(name); + sceneView.dispatchChangeEvent(); } function handleChangeTopography() { @@ -259,9 +260,12 @@ export function Form() {
{sceneView?.model.children.map((child) => { const key = `toggle-visibility-${child.name}`; - const color = `#${( - (child as Mesh).material as MeshStandardMaterial - ).color.getHexString()}`; + let color = "transparent"; + if ((child as Mesh).material instanceof MeshStandardMaterial) { + color = `#${( + (child as Mesh).material as MeshStandardMaterial + ).color.getHexString()}`; + } const visible = (child as Mesh).visible; return ( diff --git a/app/three/SceneView.ts b/app/three/SceneView.ts index 7326905..c8e071c 100644 --- a/app/three/SceneView.ts +++ b/app/three/SceneView.ts @@ -1,10 +1,13 @@ import { + Frustum, Group, Material, + Matrix4, Mesh, MeshBasicMaterial, MeshPhongMaterial, MeshStandardMaterial, + Object3D, PerspectiveCamera, Plane, Raycaster, @@ -16,7 +19,12 @@ import { } from "three"; import { buildMeshes } from "./utils/build-meshes"; import { Extent, buildScene } from "./utils/build-scene"; -import { getMetadata, transform } from "./utils/utils"; +import { + getFrustumIntersections, + getMetadata, + tileBounds, + transform, +} from "./utils/utils"; import { MODEL_ID, SERVICE_URL } from "./config"; import { Orientation, @@ -32,11 +40,11 @@ import { OrbitControls, } from "three/examples/jsm/Addons.js"; import { + LODFrustum, LODRaycast, MapPlaneNode, MapView, OpenStreetMapsProvider, - UnitsUtils, } from "geo-three"; import { Data, createSVG } from "./utils/create-borehole-svg"; import { TileData, updateTiles } from "./ShaderMaterial"; @@ -435,6 +443,10 @@ export class SceneView extends EventTarget { this._resetClippingBox(); } } + + dispatchChangeEvent() { + this._orbitControls.dispatchEvent({ type: "change" }); + } } async function init(container: HTMLElement, modelId = MODEL_ID) { @@ -483,20 +495,12 @@ async function init(container: HTMLElement, modelId = MODEL_ID) { // 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 for OSM topography - const lod = new LODRaycast(); + const lod = new LODFrustum(); const map = new MapView(MapView.PLANAR, provider); map.lod = lod; - // const customNode = new CustomMapHeightNodeShader(undefined, map); - // map.setRoot(customNode); map.rotateX(Math.PI / 2); map.name = "topography"; @@ -505,39 +509,15 @@ async function init(container: HTMLElement, modelId = MODEL_ID) { controls.addEventListener("change", () => { const tiles: TileData[] = []; - function traverse(node: MapPlaneNode) { - if (node.isMesh) { - const bounds = UnitsUtils.tileBounds(node.level, node.x, node.y); + camera.updateMatrix(); - const xmin = bounds[0]; - const ymin = bounds[2]; - const xmax = xmin + bounds[1]; - const ymax = ymin + bounds[3]; - - if ( - (extent.xmax >= xmin && extent.ymax >= ymin) || - (extent.xmin <= xmax && extent.ymax >= ymin) || - (extent.xmin <= xmax && extent.ymin <= ymax) || - (extent.xmax >= xmin && extent.ymin <= ymax) - ) { - tiles.push({ - xmin, - ymin, - xmax, - ymax, - texture: (node.material as MeshPhongMaterial).map, - }); - } - } - for (const c of node.children) { - traverse(c as MapPlaneNode); - } - } + const frustumPoints = getFrustumIntersections(camera); map.lod.updateLOD(map, camera, renderer, scene); - traverse(map.root); + traverse(map.root, extent, tiles, frustumPoints); + tiles.sort((a, b) => b.zoom - a.zoom); - updateTiles(tiles.reverse()); + updateTiles(tiles); }); return { @@ -549,3 +529,48 @@ async function init(container: HTMLElement, modelId = MODEL_ID) { renderer, }; } + +function traverse( + node: MapPlaneNode, + extent: Extent, + tiles: TileData[], + frustumPoints: Vector3[] +) { + const bounds = tileBounds(node.level, node.x, node.y); + + const xmin = bounds[0]; + const ymin = bounds[2]; + const xmax = xmin + bounds[1]; + const ymax = ymin + bounds[3]; + + if ( + ((xmax >= extent.xmin && xmax <= extent.xmax) || + (xmin >= extent.xmin && xmin <= extent.xmax)) && + ((ymax >= extent.ymin && ymax <= extent.ymax) || + (ymin >= extent.ymin && ymin <= extent.ymax)) + ) { + if ( + frustumPoints.length < 2 || + (((xmax >= frustumPoints[0].x && xmax <= frustumPoints[1].x) || + (xmin >= frustumPoints[0].x && xmin <= frustumPoints[1].x)) && + ((ymax >= frustumPoints[0].y && ymax <= frustumPoints[1].y) || + (ymin >= frustumPoints[0].y && ymin <= frustumPoints[1].y))) + ) { + const texture = (node.material as MeshPhongMaterial).map; + if (texture) { + tiles.push({ + xmin, + ymin, + xmax, + ymax, + zoom: node.level, + texture, + }); + } + } + } + + for (const c of node.children) { + traverse(c as MapPlaneNode, extent, tiles, frustumPoints); + } +} diff --git a/app/three/ShaderMaterial.ts b/app/three/ShaderMaterial.ts index 18ba003..9cb41a4 100644 --- a/app/three/ShaderMaterial.ts +++ b/app/three/ShaderMaterial.ts @@ -1,14 +1,23 @@ -import { ShaderMaterial, Texture, Vector4 } from "three"; +import { + DataArrayTexture, + LinearFilter, + RGBAFormat, + ShaderChunk, + ShaderMaterial, + Texture, + Vector4, +} from "three"; export interface TileData { xmin: number; ymin: number; xmax: number; ymax: number; - texture: Texture | null; + zoom: number; + texture: Texture; } -const maxTiles = 16; +const maxTiles = 24; // Initialize empty texture slots const dummyTexture = new Texture(); @@ -18,25 +27,39 @@ dummyTexture.needsUpdate = true; // Create shader material export const shaderMaterial = new ShaderMaterial({ uniforms: { - tileTextures: { value: Array(maxTiles).fill(dummyTexture) }, tileBounds: { value: Array(maxTiles).fill(new Vector4(0, 0, 0, 0)) }, tileCount: { value: 0 }, + tiles: { value: null }, }, - vertexShader: ` - varying vec3 vWorldPosition; - void main() { - vWorldPosition = (modelMatrix * vec4(position, 1.0)).xyz; - gl_Position = projectionMatrix * viewMatrix * vec4(vWorldPosition, 1.0); - } - `, - fragmentShader: ` - uniform sampler2D tileTextures[${maxTiles}]; - uniform vec4 tileBounds[${maxTiles}]; - uniform int tileCount; + vertexShader: + ShaderChunk.common + + "\n" + + ShaderChunk.logdepthbuf_pars_vertex + + ` varying vec3 vWorldPosition; + varying float fragDepth; void main() { - vec4 color = vec4(1.0, 1.0, 1.0, 1.0); // Default color + vWorldPosition = (modelMatrix * vec4(position, 1.0)).xyz; + gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); + fragDepth = (gl_Position.z / gl_Position.w + 1.0) * 0.5; + + ` + + ShaderChunk.logdepthbuf_vertex + + ` + } +`, + fragmentShader: + ShaderChunk.logdepthbuf_pars_fragment + + ` + uniform vec4 tileBounds[${maxTiles}]; + uniform int tileCount; + uniform sampler2DArray tiles; + varying vec3 vWorldPosition; + varying float fragDepth; + + void main() { + vec4 color = vec4(191.0/255.0, 209.0/255.0, 229.0/255.0, 1.0); // Default color for (int i = 0; i < ${maxTiles}; i++) { if (i >= tileCount) break; // Only process available tiles @@ -47,32 +70,20 @@ export const shaderMaterial = new ShaderMaterial({ vWorldPosition.y >= bounds.z && vWorldPosition.y <= bounds.w) { vec2 uv = (vWorldPosition.xy - bounds.xz) / (bounds.yw - bounds.xz); - switch (i) { - case 0: color = texture2D(tileTextures[0], uv); break; - case 1: color = texture2D(tileTextures[1], uv); break; - case 2: color = texture2D(tileTextures[2], uv); break; - case 3: color = texture2D(tileTextures[3], uv); break; - case 4: color = texture2D(tileTextures[4], uv); break; - case 5: color = texture2D(tileTextures[5], uv); break; - case 6: color = texture2D(tileTextures[6], uv); break; - case 7: color = texture2D(tileTextures[7], uv); break; - case 8: color = texture2D(tileTextures[8], uv); break; - case 9: color = texture2D(tileTextures[9], uv); break; - case 10: color = texture2D(tileTextures[10], uv); break; - case 11: color = texture2D(tileTextures[11], uv); break; - case 12: color = texture2D(tileTextures[12], uv); break; - case 13: color = texture2D(tileTextures[13], uv); break; - case 14: color = texture2D(tileTextures[14], uv); break; - case 15: color = texture2D(tileTextures[15], uv); break; - } + uv = vec2(uv.x, 1.0 - uv.y); + color = texture2D(tiles, vec3(uv, i)); - break; // Stop checking once we find the correct tile + break; // Stop checking once we find the correct tile } } gl_FragColor = color; - } - `, + gl_FragDepth = fragDepth; + ` + + ShaderChunk.logdepthbuf_fragment + + ` + } +`, }); export function updateTiles(newTiles: TileData[]) { @@ -92,7 +103,53 @@ export function updateTiles(newTiles: TileData[]) { } // Update shader uniforms - shaderMaterial.uniforms.tileTextures.value = textures; shaderMaterial.uniforms.tileBounds.value = bounds; shaderMaterial.uniforms.tileCount.value = newTiles.length; + shaderMaterial.uniforms.tiles.value = createDataArrayTexture(textures); +} + +// Create a buffer with color data +const width = 256; +const height = 256; +const size = width * height; +function createDataArrayTexture(textures: Texture[]) { + const depth = textures.length; + + const data = new Uint8Array(4 * size * depth); + + for (let i = 0; i < depth; i++) { + const texture = textures[i]; + const imageData = getImageData(texture); + + if (imageData) { + data.set(imageData, i * size * 4); + } + } + + // Use the buffer to create a DataArrayTexture + const texture = new DataArrayTexture(data, width, height, depth); + texture.format = RGBAFormat; + texture.generateMipmaps = false; + texture.magFilter = LinearFilter; + texture.minFilter = LinearFilter; + texture.needsUpdate = true; + return texture; +} + +// Create a canvas and draw the image on it +const canvas = new OffscreenCanvas(width, height); +const ctx = canvas.getContext("2d"); +function getImageData(texture: Texture) { + const image = texture.source.data; + + // Draw the image onto the canvas + if (ctx) { + ctx.drawImage(image, 0, 0); + + // Get the pixel data from the canvas + const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); + return imageData.data; + } else { + return null; + } } diff --git a/app/three/config.ts b/app/three/config.ts index d942486..33f10be 100644 --- a/app/three/config.ts +++ b/app/three/config.ts @@ -5,5 +5,3 @@ export const SERVICE_URL = export const VERTICES_URL = "https://geusegdi01.geus.dk/geom3d/data/nodes/"; export const TRIANGLE_INDICES_URL = "https://geusegdi01.geus.dk/geom3d/data/triangles/"; - -export const MAPTILER_API_KEY = "1JkD1W8u5UM5Tjd8r3Wl "; diff --git a/app/three/utils/build-meshes.ts b/app/three/utils/build-meshes.ts index 7fddd1d..07679e3 100644 --- a/app/three/utils/build-meshes.ts +++ b/app/three/utils/build-meshes.ts @@ -8,6 +8,7 @@ import { import { fetchVertices, fetchTriangleIndices, transform } from "./utils"; import { TRIANGLE_INDICES_URL, VERTICES_URL } from "../config"; +import { shaderMaterial } from "../ShaderMaterial"; interface MappedFeature { featuregeom_id: number; @@ -60,14 +61,17 @@ async function buildMesh(layerData: MappedFeature) { const material = new MeshStandardMaterial({ color: color, - metalness: 0.0, - roughness: 5.0, + metalness: 0.1, + roughness: 0.5, flatShading: true, side: DoubleSide, wireframe: false, }); - const mesh = new Mesh(geometry, material); + const mesh = new Mesh( + geometry, + name === "Topography" ? shaderMaterial : material + ); mesh.name = name; mesh.userData.layerId = geomId; mesh.castShadow = true; diff --git a/app/three/utils/utils.ts b/app/three/utils/utils.ts index f34466b..ccf60bf 100644 --- a/app/three/utils/utils.ts +++ b/app/three/utils/utils.ts @@ -1,4 +1,4 @@ -import { Vector3 } from "three"; +import { PerspectiveCamera, Plane, Raycaster, Vector2, Vector3 } from "three"; import { Extent } from "./build-scene"; import { unpackEdges, unpackVertices } from "./decoders"; import proj4 from "proj4"; @@ -60,12 +60,69 @@ export async function fetchVertices(pointUrl: string, geomId: string) { return unpackVertices(buffer); } -// Transformation from EPSG 3034 to EPSG 3857 +// Transformation from EPSG 3034 to a modified EPSG 900913 using the mean radius to align with geo-three const SOURCE = "EPSG:3034"; -const PROJ_STRING = - "+proj=lcc +lat_0=52 +lon_0=10 +lat_1=35 +lat_2=65 +x_0=4000000 +y_0=2800000 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs +type=crs"; -const DEST = "EPSG:3857"; -proj4.defs(SOURCE, PROJ_STRING); +const DEST = "EPSG:900913"; +proj4.defs( + SOURCE, + "+proj=lcc +lat_0=52 +lon_0=10 +lat_1=35 +lat_2=65 +x_0=4000000 +y_0=2800000 +ellps=GRS80 +towgs84=0,0,0,0,0,0,0 +units=m +no_defs +type=crs" +); +proj4.defs( + DEST, + "+proj=merc +a=6371008 +b=6371008 +lat_ts=0 +lon_0=0 +x_0=0 +y_0=0 +k=1 +units=m +nadgrids=@null +wktext +no_defs +type=crs" +); export function transform(p: number[]) { return proj4(SOURCE, DEST, p); } + +const MAX_EXTENT = 20015111.9287618; +function getTileSize(zoom: number): number { + const numTiles = Math.pow(2, zoom); + return (2 * MAX_EXTENT) / numTiles; +} + +export function tileBounds(zoom: number, x: number, y: number): number[] { + const tileSize = getTileSize(zoom); + const minX = -MAX_EXTENT + x * tileSize; + const minY = MAX_EXTENT - (y + 1) * tileSize; + return [minX, tileSize, minY, tileSize]; +} + +const plane = new Plane(new Vector3(0, 0, 1), 0); + +const corners = [new Vector2(-1, -1), new Vector2(1, 1)]; + +export const getFrustumIntersections = (camera: PerspectiveCamera) => { + const points = []; + + const raycaster = new Raycaster(); + + for (const ndc of corners) { + // Convert NDC to world space ray + raycaster.setFromCamera(ndc, camera); + + // Find intersection with the XY plane + const intersection = new Vector3(); + const hit = raycaster.ray.intersectPlane(plane, intersection); + if (hit) { + points.push(intersection.clone()); + } + } + + if (points.length > 1) { + return [ + new Vector3( + Math.min(points[0].x, points[1].x), + Math.min(points[0].y, points[1].y), + 0 + ), + new Vector3( + Math.max(points[0].x, points[1].x), + Math.max(points[0].y, points[1].y), + 0 + ), + ]; + } + + return points; +};