Repository: iTowns/itowns (JavaScript)

Issue:
Better handle near and far camera values depending on camera position
The near and far plane [are fixed](https://github.com/iTowns/itowns/blob/master/src/Core/Prefab/GlobeView.js#L102) in the `GlobeView` and the `PlanarView`.

1. Make them configurable
2. Update the far distance dynamically depending on the camera position. The far plane is indeed quite far (10 times the earth diameter for the `GlobeView`) to allow zooming out pretty far from the globe or the plane. This value is not adapted when zooming in and tilting the camera: it generates unuseful tiles subdivision and external layers querying and manipulation, which has an impact on performance. Ideally, this should be configurable in some way too.


Discussion: Yes, it's good idea, i started in this branch https://github.com/gchoqueux/itowns/tree/nearFar_2.0
You could use the global bounding box to compute the near and the far.



Fix (patch):
diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml
index 301924abf3..f1d9e5dcdb 100644
--- a/.github/workflows/integration.yml
+++ b/.github/workflows/integration.yml
@@ -19,7 +19,7 @@ jobs:
 
       # Use specific Node.js version
       - uses: actions/checkout@v4
-      - name: Use Node.js 22.x
+      - name: Use Node.js 22.17
         uses: actions/setup-node@v4
         with:
           node-version: 22.17
@@ -91,7 +91,7 @@ jobs:
 
       # Use specific Node.js version
       - uses: actions/checkout@v4
-      - name: Use Node.js 22.x
+      - name: Use Node.js 22.17
         uses: actions/setup-node@v4
         with:
           node-version: 22.17
@@ -137,7 +137,7 @@ jobs:
 
       # Use specific Node.js version
       - uses: actions/checkout@v4
-      - name: Use Node.js 22.x
+      - name: Use Node.js 22.17
         uses: actions/setup-node@v4
         with:
           node-version: 22.17
@@ -288,7 +288,7 @@ jobs:
 
       - uses: actions/checkout@v4
 
-      - name: Use Node.js 22.x
+      - name: Use Node.js 22.17
         uses: actions/setup-node@v4
         with:
           node-version: 22.17
diff --git a/packages/Main/src/Controls/PlanarControls.js b/packages/Main/src/Controls/PlanarControls.js
index 9c867be539..c1e4600929 100644
--- a/packages/Main/src/Controls/PlanarControls.js
+++ b/packages/Main/src/Controls/PlanarControls.js
@@ -1,5 +1,6 @@
 import * as THREE from 'three';
 import { MAIN_LOOP_EVENTS } from 'Core/MainLoop';
+import { VIEW_EVENTS } from 'Core/View';
 
 // event keycode
 export const keys = {
@@ -336,31 +337,32 @@ class PlanarControls extends THREE.EventDispatcher {
             dt = 16;
         }
         const onMovement = this.state !== STATE.NONE;
+        let camChanged = true;
         switch (this.state) {
             case STATE.TRAVEL:
                 this.handleTravel(dt);
-                this.view.notifyChange(this.camera);
                 break;
             case STATE.ORTHO_ZOOM:
                 this.handleZoomOrtho(dt);
-                this.view.notifyChange(this.camera);
                 break;
             case STATE.DRAG:
                 this.handleDragMovement();
-                this.view.notifyChange(this.camera);
                 break;
             case STATE.ROTATE:
                 this.handleRotation();
-                this.view.notifyChange(this.camera);
                 break;
             case STATE.PAN:
                 this.handlePanMovement();
-                this.view.notifyChange(this.camera);
                 break;
             case STATE.NONE:
             default:
+                camChanged = false;
                 break;
         }
+        if (camChanged) {
+            this.view.dispatchEvent({ type: VIEW_EVENTS.CAMERA_MOVED });
+            this.view.notifyChange(this.camera);
+        }
         // We test if camera collides to the geometry layer or is too close to the ground, and adjust its altitude in
         // case
         if (this.handleCollision) { // check distance to the ground/surface geometry (could be another geometry layer)
diff --git a/packages/Main/src/Core/Prefab/GlobeView.js b/packages/Main/src/Core/Prefab/GlobeView.js
index aae0feb9a6..cc88ca2ca9 100644
--- a/packages/Main/src/Core/Prefab/GlobeView.js
+++ b/packages/Main/src/Core/Prefab/GlobeView.js
@@ -81,9 +81,13 @@ class GlobeView extends View {
      * @param {object} [options] - See options of {@link View}.
      * @param {Object} [options.controls] - See options of {@link GlobeControls}
      * @param {Object} [options.webXR] - WebXR configuration - its presence alone
-     * enable WebXR to switch on VR visualization. (optional).
-     * @param {function} [options.webXR.callback] - WebXR rendering callback (optional).
-     * @param {boolean} [options.webXR.controllers] - Enable the webXR controllers handling (optional).
+     * enable WebXR to switch on VR visualization.
+     * @param {function} [options.webXR.callback] - WebXR rendering callback.
+     * @param {boolean} [options.webXR.controllers] - Enable the webXR controllers handling.
+     * @param {number} [options.farFactor=20] - Controls how far the camera can see.
+     * The maximum view distance is this factor times the camera’s altitude (above sea level).
+     * @param {number} [options.fogSpread=0.5] - Proportion of the visible depth range that contains fog.
+     * Between 0 and 1.
      */
     constructor(viewerDiv, placement = {}, options = {}) {
         THREE.Object3D.DEFAULT_UP.set(0, 0, 1);
@@ -93,7 +97,6 @@ class GlobeView extends View {
 
         this.camera3D.near = Math.max(15.0, 0.000002352 * ellipsoidSizes.x);
         this.camera3D.far = ellipsoidSizes.x * 10;
-
         const tileLayer = new GlobeLayer('globe', options.object3d, options);
         this.mainLoop.gfxEngine.label2dRenderer.infoTileLayer = tileLayer.info;
 
@@ -107,11 +110,45 @@ class GlobeView extends View {
             placement.range = placement.range || ellipsoidSizes.x * 2.0;
         }
 
+        this.farFactor = options.farFactor ?? 20;
+        this.fogSpread = options.fogSpread ?? 0.5;
+
         if (options.noControls) {
             CameraUtils.transformCameraToLookAtTarget(this, this.camera3D, placement);
+
+            // In this case, since the camera's near and far properties aren't
+            // dynamically computed, the default fog won't be adapted, so disable it.
+            this.scene.fog = null;
         } else {
             this.controls = new GlobeControls(this, placement, options.controls);
             this.controls.handleCollision = typeof (options.handleCollision) !== 'undefined' ? options.handleCollision : true;
+
+            const globeRadiusMin = Math.min(ellipsoidSizes.x, ellipsoidSizes.y, ellipsoidSizes.z);
+
+            this.addEventListener(VIEW_EVENTS.CAMERA_MOVED, () => {
+                // update camera's near and far
+                const originToCamSq = this.camera3D.position.lengthSq();
+
+                // maximum possible distance from ground to camera
+                const camCoordinates = new Coordinates(this.referenceCrs)
+                    .setFromVector3(this.camera3D.position);
+                camCoordinates.as(this.tileLayer.extent.crs, camCoordinates);
+                const camToSeaLevel = camCoordinates.z;
+
+                const camToGroundDistMin = camToSeaLevel - View.ALTITUDE_MAX;
+                this.camera3D.near = Math.max(1, camToGroundDistMin * this.fovDepthFactor);
+
+                // distance from camera to the horizon
+                const horizonDist = Math.sqrt(Math.max(0, originToCamSq - globeRadiusMin * globeRadiusMin));
+
+                this.camera3D.far = Math.min(this.farFactor * camToSeaLevel, horizonDist);
+                this.camera3D.updateProjectionMatrix();
+
+                const fog = this.scene.fog;
+                if (!fog) { return; }
+                fog.far = this.camera3D.far;
+                fog.near = fog.far - this.fogSpread * (fog.far - this.camera3D.near);
+            });
         }
 
         this.addLayer(new Atmosphere('atmosphere', options.atmosphere));
diff --git a/packages/Main/src/Core/Prefab/PlanarView.js b/packages/Main/src/Core/Prefab/PlanarView.js
index 7ec1fb9fa4..9fb9c1e624 100644
--- a/packages/Main/src/Core/Prefab/PlanarView.js
+++ b/packages/Main/src/Core/Prefab/PlanarView.js
@@ -1,6 +1,6 @@
 import * as THREE from 'three';
 
-import View from 'Core/View';
+import View, { VIEW_EVENTS } from 'Core/View';
 import CameraUtils from 'Utils/CameraUtils';
 
 import PlanarControls from 'Controls/PlanarControls';
@@ -25,6 +25,10 @@ class PlanarView extends View {
      * @param {CameraUtils~CameraTransformOptions|Extent} [options.placement] - The
      * {@link CameraUtils~CameraTransformOptions} to apply to view's camera or the extent it must display at
      * initialization. By default, camera will display the view's extent (given in `extent` parameter).
+     * @param {number} [options.farFactor=20] - Controls how far the camera can see.
+     * The maximum view distance is this factor times the camera’s altitude (above sea level).
+     * @param {number} [options.fogSpread=0.5] - Proportion of the visible depth range that contains fog.
+     *  Between 0 and 1.
      */
     constructor(viewerDiv, extent, options = {}) {
         THREE.Object3D.DEFAULT_UP.set(0, 0, 1);
@@ -36,15 +40,58 @@ class PlanarView extends View {
         // Configure camera
         const dim = extent.planarDimensions();
         const max = Math.max(dim.x, dim.y);
-        this.camera3D.near = 0.1;
-        this.camera3D.far = this.camera3D.isOrthographicCamera ? 2000 : 2 * max;
-        this.camera3D.updateProjectionMatrix();
 
         const tileLayer = new PlanarLayer('planar', extent, options.object3d, options);
         this.mainLoop.gfxEngine.label2dRenderer.infoTileLayer = tileLayer.info;
 
         this.addLayer(tileLayer);
 
+        this.addEventListener(VIEW_EVENTS.CAMERA_MOVED, () => {
+            // update camera's near and far
+
+            // compute layer's bounding box
+            if (!this.tileLayer) { return; }
+            const obj = this.tileLayer.object3d;
+            const box = new THREE.Box3();
+            obj.traverse((child) => {
+                if (child.isMesh && child.geometry) {
+                    child.geometry.computeBoundingBox();
+                    const childBox = new THREE.Box3().copy(child.geometry.boundingBox)
+                        .applyMatrix4(child.matrixWorld);
+                    box.union(childBox);
+                }
+            });
+            if (box.isEmpty()) { return; }
+
+            // calculate the greatest distance to the corners of the box
+            let maxDistSq = 0;
+            const camPos = this.camera3D.position;
+            for (const x of [box.min.x, box.max.x]) {
+                for (const y of [box.min.y, box.max.y]) {
+                    for (const z of [box.min.z, box.max.z]) {
+                        const distSq = camPos.distanceToSquared({ x, y, z });
+                        if (distSq > maxDistSq) {
+                            maxDistSq = distSq;
+                        }
+                    }
+                }
+            }
+            const maxDist = Math.sqrt(maxDistSq);
+
+            const camToGroundDistMin = this.camera3D.position.z - View.ALTITUDE_MAX;
+            this.camera3D.near = Math.max(1, camToGroundDistMin * this.fovDepthFactor);
+
+            const boxBottomToCam = camPos.z - box.min.z;
+            const far = this.farFactor * boxBottomToCam;
+            this.camera3D.far = Math.min(far, maxDist);
+            this.camera3D.updateProjectionMatrix();
+
+            const fog = this.scene.fog;
+            if (!fog) { return; }
+            fog.far = far;
+            fog.near = fog.far - this.fogSpread * (fog.far - this.camera3D.near);
+        });
+
         // Configure camera
         const placement = options.placement || {};
         if (!placement.isExtent) {
@@ -60,10 +107,17 @@ class PlanarView extends View {
         }
 
         this.tileLayer = tileLayer;
+
+        this.farFactor = options.farFactor ?? 20;
+        this.fogSpread = options.fogSpread ?? 0.5;
     }
 
     addLayer(layer) {
-        return super.addLayer(layer, this.tileLayer);
+        const p = super.addLayer(layer, this.tileLayer);
+        p.then(() => {
+            this.dispatchEvent({ type: VIEW_EVENTS.CAMERA_MOVED });
+        });
+        return p;
     }
 }
 
diff --git a/packages/Main/src/Core/View.js b/packages/Main/src/Core/View.js
index 368ba1de14..d223eb7007 100644
--- a/packages/Main/src/Core/View.js
+++ b/packages/Main/src/Core/View.js
@@ -133,6 +133,9 @@ class View extends THREE.EventDispatcher {
     #layers = [];
     #pixelDepthBuffer = new Uint8Array(4);
     #fullSizeDepthBuffer;
+
+    static ALTITUDE_MAX = 100000; // more than 10 times altitude of Mount Everest
+
     /**
      * Constructs an Itowns View instance
      *
@@ -188,6 +191,7 @@ class View extends THREE.EventDispatcher {
         if (!options.scene3D) {
             this.scene.matrixWorldAutoUpdate = false;
         }
+        this.scene.fog = new THREE.Fog(0xe2edff, 1, 1000); // default fog
 
         this.camera = new Camera(
             this.referenceCrs,
@@ -219,6 +223,18 @@ class View extends THREE.EventDispatcher {
             }
         };
 
+        // Factor to apply to the camera's near value.
+        // Given a plane orthogonal the camera direction (in this case, the near plane),
+        // fovDepthFactor represents the ratio between:
+        // * the distance to a point on this plane at the center of the view and
+        // * the distance to a point on this plane in a corner.
+        this.fovDepthFactor = 1;
+        if (this.camera3D.isPerspectiveCamera) {
+            const corner = new THREE.Vector4(1, 1, -1); // a corner of the camera in NDC at near plane
+            corner.applyMatrix4(this.camera3D.projectionMatrixInverse).divideScalar(corner.z);
+            this.fovDepthFactor /= Math.sqrt(1 + corner.x * corner.x + corner.y * corner.y);
+        }
+
         this.camera.resize(this.domElement.clientWidth, this.domElement.clientHeight);
 
         const fn = () => {
diff --git a/packages/Main/src/Renderer/WebXR.js b/packages/Main/src/Renderer/WebXR.js
index 1fb801aa1c..57efabe67c 100644
--- a/packages/Main/src/Renderer/WebXR.js
+++ b/packages/Main/src/Renderer/WebXR.js
@@ -74,8 +74,8 @@ class WebXR extends THREE.EventDispatcher {
      * @param {GlobeView} view - The view where the webXR session will be started
      * @param {Object} [options] - WebXR configuration - its presence alone
      * enable WebXR to switch on VR visualization.
-     * @param {function} [options.callback] - WebXR rendering callback (optional).
-     * @param {boolean} [options.controllers] - Enable the webXR controllers handling (optional).
+     * @param {function} [options.callback] - WebXR rendering callback.
+     * @param {boolean} [options.controllers] - Enable the webXR controllers handling.
      */
     constructor(view, options) {
         super();
diff --git a/packages/Main/src/Source/EntwinePointTileSource.js b/packages/Main/src/Source/EntwinePointTileSource.js
index 643a9600ca..5ba2c64153 100644
--- a/packages/Main/src/Source/EntwinePointTileSource.js
+++ b/packages/Main/src/Source/EntwinePointTileSource.js
@@ -60,7 +60,7 @@ class EntwinePointTileSource extends Source {
             }
 
             this.boundsConforming = metadata.boundsConforming;
-            this.bounds = metadata.bounds;
+            this.bounds = metadata.bounds; // xMin, yMin, zMin, xMax, yMax, zMax
             this.span = metadata.span;
 
             return this;
diff --git a/packages/Main/tsconfig.json b/packages/Main/tsconfig.json
index 5dc1cc39f6..9744b6bb4d 100644
--- a/packages/Main/tsconfig.json
+++ b/packages/Main/tsconfig.json
@@ -6,6 +6,7 @@
         "declaration": true,
         "emitDeclarationOnly": true,
         "outDir": "lib",
+        "rootDir": ".",
         "baseUrl": "src"
     }
 }