itowns
Version:
A JS/WebGL framework for 3D geospatial data visualization
405 lines (389 loc) • 15.6 kB
JavaScript
import * as THREE from 'three';
import GeometryLayer from "./GeometryLayer.js";
import PointsMaterial, { PNTS_MODE } from "../Renderer/PointsMaterial.js";
import Picking from "../Core/Picking.js";
const point = new THREE.Vector3();
const bboxMesh = new THREE.Mesh();
const box3 = new THREE.Box3();
bboxMesh.geometry.boundingBox = box3;
function initBoundingBox(elt, layer) {
elt.tightbbox.getSize(box3.max);
box3.max.multiplyScalar(0.5);
box3.min.copy(box3.max).negate();
elt.obj.boxHelper = new THREE.BoxHelper(bboxMesh);
elt.obj.boxHelper.geometry = elt.obj.boxHelper.geometry.toNonIndexed();
elt.obj.boxHelper.computeLineDistances();
elt.obj.boxHelper.material = elt.childrenBitField ? new THREE.LineDashedMaterial({
dashSize: 0.25,
gapSize: 0.25
}) : new THREE.LineBasicMaterial();
elt.obj.boxHelper.material.color.setHex(0);
elt.obj.boxHelper.material.linewidth = 2;
elt.obj.boxHelper.frustumCulled = false;
elt.obj.boxHelper.position.copy(elt.tightbbox.min).add(box3.max);
elt.obj.boxHelper.autoUpdateMatrix = false;
layer.bboxes.add(elt.obj.boxHelper);
elt.obj.boxHelper.updateMatrix();
elt.obj.boxHelper.updateMatrixWorld();
}
function computeSSEPerspective(context, pointSize, spacing, elt, distance) {
if (distance <= 0) {
return Infinity;
}
const pointSpacing = spacing / 2 ** elt.depth;
// Estimate the onscreen distance between 2 points
const onScreenSpacing = context.camera.preSSE * pointSpacing / distance;
// [ P1 ]--------------[ P2 ]
// <---------------------> = pointsSpacing (in world coordinates)
// ~ onScreenSpacing (in pixels)
// <------> = pointSize (in pixels)
return Math.max(0.0, onScreenSpacing - pointSize);
}
function computeSSEOrthographic(context, pointSize, spacing, elt) {
const pointSpacing = spacing / 2 ** elt.depth;
// Given an identity view matrix, project pointSpacing from world space to
// clip space. v' = vVP = vP
const v = new THREE.Vector4(pointSpacing);
v.applyMatrix4(context.camera.camera3D.projectionMatrix);
// We map v' to the screen space and calculate the distance to the origin.
const dx = v.x * 0.5 * context.camera.width;
const dy = v.y * 0.5 * context.camera.height;
const distance = Math.sqrt(dx * dx + dy * dy);
return Math.max(0.0, distance - pointSize);
}
function computeScreenSpaceError(context, pointSize, spacing, elt, distance) {
if (context.camera.camera3D.isOrthographicCamera) {
return computeSSEOrthographic(context, pointSize, spacing, elt);
}
return computeSSEPerspective(context, pointSize, spacing, elt, distance);
}
function markForDeletion(elt) {
if (elt.obj) {
elt.obj.visible = false;
}
if (!elt.notVisibleSince) {
elt.notVisibleSince = Date.now();
// Set .sse to an invalid value
elt.sse = -1;
}
for (const child of elt.children) {
markForDeletion(child);
}
}
function changeIntensityRange(layer) {
layer.material.intensityRange?.set(layer.minIntensityRange, layer.maxIntensityRange);
}
function changeElevationRange(layer) {
layer.material.elevationRange?.set(layer.minElevationRange, layer.maxElevationRange);
}
function changeAngleRange(layer) {
layer.material.angleRange?.set(layer.minAngleRange, layer.maxAngleRange);
}
/**
* The basis for all point clouds related layers.
*
* @property {boolean} isPointCloudLayer - Used to checkout whether this layer
* is a PointCloudLayer. Default is `true`. You should not change this, as it is
* used internally for optimisation.
* @property {THREE.Group|THREE.Object3D} group - Contains the created
* `THREE.Points` meshes, usually with an instance of a `THREE.Points` per node.
* @property {THREE.Group|THREE.Object3D} bboxes - Contains the bounding boxes
* (`THREE.Box3`) of the tree, usually one per node.
* @property {number} octreeDepthLimit - The depth limit at which to stop
* browsing the octree. Can be used to limit the browsing, without having to
* edit manually the source of the point cloud. No limit by default (`-1`).
* @property {number} [pointBudget=2000000] - Maximum number of points to
* display at the same time. This influences the performance of rendering.
* Default to two millions points.
* @property {number} [sseThreshold=2] - Threshold of the **S**creen **S**pace
* **E**rror. Default to `2`.
* @property {number} [pointSize=4] - The size (in pixels) of the points.
* Default to `4`.
* @property {THREE.Material|PointsMaterial} [material=new PointsMaterial] - The
* material to use to display the points of the cloud. Be default it is a new
* `PointsMaterial`.
* @property {number} [mode=PNTS_MODE.COLOR] - The displaying mode of the points.
* Values are specified in `PointsMaterial`.
* @property {number} [minIntensityRange=0] - The minimal intensity of the
* layer. Changing this value will affect the material, if it has the
* corresponding uniform. The value is normalized between 0 and 1.
* @property {number} [maxIntensityRange=1] - The maximal intensity of the
* layer. Changing this value will affect the material, if it has the
* corresponding uniform. The value is normalized between 0 and 1.
*
* @extends GeometryLayer
*/
class PointCloudLayer extends GeometryLayer {
/**
* Constructs a new instance of point cloud layer.
* Constructs a new instance of a Point Cloud Layer. This should not be used
* directly, but rather implemented using `extends`.
*
* @param {string} id - The id of the layer, that should be unique. It is
* not mandatory, but an error will be emitted if this layer is added a
* {@link View} that already has a layer going by that id.
* @param {Object} [config] - Optional configuration, all elements in it
* will be merged as is in the layer. For example, if the configuration
* contains three elements `name, protocol, extent`, these elements will be
* available using `layer.name` or something else depending on the property
* name. See the list of properties to know which one can be specified.
* @param {Source} config.source - Description and options of the source See @Layer.
* @param {number} [options.minElevationRange] - Min value for the elevation range (default value will be taken from the source.metadata).
* @param {number} [options.maxElevationRange] - Max value for the elevation range (default value will be taken from the source.metadata).
*/
constructor(id) {
let config = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : {};
const {
object3d = new THREE.Group(),
group = new THREE.Group(),
bboxes = new THREE.Group(),
octreeDepthLimit = -1,
pointBudget = 2000000,
pointSize = 2,
sseThreshold = 2,
minIntensityRange = 1,
maxIntensityRange = 65536,
minElevationRange,
maxElevationRange,
minAngleRange = -90,
maxAngleRange = 90,
material = {},
mode = PNTS_MODE.COLOR,
...geometryLayerConfig
} = config;
super(id, object3d, geometryLayerConfig);
/**
* @type {boolean}
* @readonly
*/
this.isPointCloudLayer = true;
this.protocol = 'pointcloud';
this.group = group;
this.object3d.add(this.group);
this.bboxes = bboxes || new THREE.Group();
this.bboxes.visible = false;
this.object3d.add(this.bboxes);
this.group.updateMatrixWorld();
// default config
/**
* @type {number}
*/
this.octreeDepthLimit = octreeDepthLimit;
/**
* @type {number}
*/
this.pointBudget = pointBudget;
/**
* @type {number}
*/
this.pointSize = pointSize;
/**
* @type {number}
*/
this.sseThreshold = sseThreshold;
this.defineLayerProperty('minIntensityRange', minIntensityRange, changeIntensityRange);
this.defineLayerProperty('maxIntensityRange', maxIntensityRange, changeIntensityRange);
this.defineLayerProperty('minElevationRange', minElevationRange, changeElevationRange);
this.defineLayerProperty('maxElevationRange', maxElevationRange, changeElevationRange);
this.defineLayerProperty('minAngleRange', minAngleRange, changeAngleRange);
this.defineLayerProperty('maxAngleRange', maxAngleRange, changeAngleRange);
/**
* @type {THREE.Material}
*/
this.material = material;
if (!this.material.isMaterial) {
this.material.intensityRange = new THREE.Vector2(this.minIntensityRange, this.maxIntensityRange);
this.material.elevationRange = new THREE.Vector2(this.minElevationRange, this.maxElevationRange);
this.material.angleRange = new THREE.Vector2(this.minAngleRange, this.maxAngleRange);
this.material = new PointsMaterial(this.material);
}
this.mode = mode || PNTS_MODE.COLOR;
/**
* @type {PointCloudNode | undefined}
*/
this.root = undefined;
}
preUpdate(context, changeSources) {
// See https://cesiumjs.org/hosted-apps/massiveworlds/downloads/Ring/WorldScaleTerrainRendering.pptx
// slide 17
context.camera.preSSE = context.camera.height / (2 * Math.tan(THREE.MathUtils.degToRad(context.camera.camera3D.fov) * 0.5));
if (this.material) {
this.material.visible = this.visible;
this.material.opacity = this.opacity;
this.material.transparent = this.opacity < 1 || this.material.userData.needTransparency[this.material.mode];
this.material.size = this.pointSize;
this.material.scale = context.camera.preSSE;
if (this.material.updateUniforms) {
this.material.updateUniforms();
}
}
// lookup lowest common ancestor of changeSources
let commonAncestor;
for (const source of changeSources.values()) {
if (source.isCamera || source == this) {
// if the change is caused by a camera move, no need to bother
// to find common ancestor: we need to update the whole tree:
// some invisible tiles may now be visible
return [this.root];
}
if (source.obj === undefined) {
continue;
}
// filter sources that belong to our layer
if (source.obj.isPoints && source.obj.layer == this) {
if (!commonAncestor) {
commonAncestor = source;
} else {
commonAncestor = source.findCommonAncestor(commonAncestor);
if (!commonAncestor) {
return [this.root];
}
}
}
}
if (commonAncestor) {
return [commonAncestor];
}
// Start updating from hierarchy root
return [this.root];
}
update(context, layer, elt) {
elt.visible = false;
if (this.octreeDepthLimit >= 0 && this.octreeDepthLimit < elt.depth) {
markForDeletion(elt);
return;
}
// pick the best bounding box
const bbox = elt.tightbbox ? elt.tightbbox : elt.bbox;
elt.visible = context.camera.isBox3Visible(bbox, this.object3d.matrixWorld);
if (!elt.visible) {
markForDeletion(elt);
return;
}
elt.notVisibleSince = undefined;
point.copy(context.camera.camera3D.position).sub(this.object3d.getWorldPosition(new THREE.Vector3()));
point.applyQuaternion(this.object3d.getWorldQuaternion(new THREE.Quaternion()).invert());
// only load geometry if this elements has points
if (elt.numPoints !== 0) {
if (elt.obj) {
elt.obj.visible = true;
} else if (!elt.promise) {
const distance = Math.max(0.001, bbox.distanceToPoint(point));
// Increase priority of nearest node
const priority = computeScreenSpaceError(context, layer.pointSize, layer.spacing, elt, distance) / distance;
elt.promise = context.scheduler.execute({
layer,
requester: elt,
view: context.view,
priority,
redraw: true,
earlyDropFunction: cmd => !cmd.requester.visible || !this.visible
}).then(pts => {
elt.obj = pts;
// store tightbbox to avoid ping-pong (bbox = larger => visible, tight => invisible)
elt.tightbbox = pts.tightbbox;
// make sure to add it here, otherwise it might never
// be added nor cleaned
this.group.add(elt.obj);
elt.obj.updateMatrixWorld(true);
}).catch(err => {
if (!err.isCancelledCommandException) {
return err;
}
}).finally(() => {
elt.promise = null;
});
}
}
if (elt.children && elt.children.length) {
const distance = bbox.distanceToPoint(point);
elt.sse = computeScreenSpaceError(context, layer.pointSize, layer.spacing, elt, distance) / this.sseThreshold;
if (elt.sse >= 1) {
return elt.children;
} else {
for (const child of elt.children) {
markForDeletion(child);
}
}
}
}
postUpdate() {
this.displayedCount = 0;
for (const pts of this.group.children) {
if (pts.visible) {
const count = pts.geometry.attributes.position.count;
pts.geometry.setDrawRange(0, count);
this.displayedCount += count;
}
}
if (this.displayedCount > this.pointBudget) {
// 2 different point count limit implementation, depending on the potree source
if (this.supportsProgressiveDisplay) {
// In this format, points are evenly distributed within a node,
// so we can draw a percentage of each node and still get a correct
// representation
const reduction = this.pointBudget / this.displayedCount;
for (const pts of this.group.children) {
if (pts.visible) {
const count = Math.floor(pts.geometry.drawRange.count * reduction);
if (count > 0) {
pts.geometry.setDrawRange(0, count);
} else {
pts.visible = false;
}
}
}
this.displayedCount *= reduction;
} else {
// This format doesn't require points to be evenly distributed, so
// we're going to sort the nodes by "importance" (= on screen size)
// and display only the first N nodes
this.group.children.sort((p1, p2) => p2.userData.node.sse - p1.userData.node.sse);
let limitHit = false;
this.displayedCount = 0;
for (const pts of this.group.children) {
const count = pts.geometry.attributes.position.count;
if (limitHit || this.displayedCount + count > this.pointBudget) {
pts.visible = false;
limitHit = true;
} else {
this.displayedCount += count;
}
}
}
}
const now = Date.now();
for (let i = this.group.children.length - 1; i >= 0; i--) {
const obj = this.group.children[i];
if (!obj.visible && now - obj.userData.node.notVisibleSince > 10000) {
// remove from group
this.group.children.splice(i, 1);
// no need to dispose obj.material, as it is shared by all objects of this layer
obj.geometry.dispose();
obj.material = null;
obj.geometry = null;
obj.userData.node.obj = null;
}
}
}
pickObjectsAt(view, mouse, radius) {
let target = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : [];
return Picking.pickPointsAt(view, mouse, radius, this, target);
}
getObjectToUpdateForAttachedLayers(meta) {
if (meta.obj) {
const p = meta.parent;
if (p && p.obj) {
return {
element: meta.obj,
parent: p.obj
};
} else {
return {
element: meta.obj
};
}
}
}
}
export default PointCloudLayer;