diff --git a/Apps/Sandcastle/gallery/AEC Clipping.html b/Apps/Sandcastle/gallery/AEC Clipping.html new file mode 100644 index 000000000000..03ea18d52693 --- /dev/null +++ b/Apps/Sandcastle/gallery/AEC Clipping.html @@ -0,0 +1,147 @@ + + + + + + + + + Cesium Demo + + + + + +
+

Loading...

+
+ + + diff --git a/Apps/Sandcastle/gallery/AEC Clipping.jpg b/Apps/Sandcastle/gallery/AEC Clipping.jpg new file mode 100644 index 000000000000..5cba32b58e5d Binary files /dev/null and b/Apps/Sandcastle/gallery/AEC Clipping.jpg differ diff --git a/Apps/Sandcastle/gallery/Clipping Regions.html b/Apps/Sandcastle/gallery/Clipping Regions.html new file mode 100644 index 000000000000..191c90b58d9f --- /dev/null +++ b/Apps/Sandcastle/gallery/Clipping Regions.html @@ -0,0 +1,283 @@ + + + + + + + + + Cesium Demo + + + + + +
+

Loading...

+
+ + + + + + + + + +
Left click to add a vertex.
Right click to add the polygon to the clipping collection.
+
+ + + diff --git a/Apps/Sandcastle/gallery/Clipping Regions.jpg b/Apps/Sandcastle/gallery/Clipping Regions.jpg new file mode 100644 index 000000000000..1fe127ab4b92 Binary files /dev/null and b/Apps/Sandcastle/gallery/Clipping Regions.jpg differ diff --git a/CHANGES.md b/CHANGES.md index 3ec372c2769d..62509ae514db 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -20,6 +20,8 @@ ##### Additions :tada: +- Added `ClippingPolygon` and `ClippingPolygonCollection` for applying multiple clipping regions, with support for concave regions and inverse clipping regions, to 3D Tiles and Terrain. [#11750](https://github.com/CesiumGS/cesium/pull/11750) +- Added `Cesium3DTileset.clippingPolygons`, `Globe.clippingPolygons`, and `Model.clippingPolygons` properties for defining clipping regions from world positions. - Surface normals are now computed for clipping and shape bounds in VoxelEllipsoidShape and VoxelCylinderShape. [#11847](https://github.com/CesiumGS/cesium/pull/11847) - Implemented sharper rendering and lighting on voxels with CYLINDER and ELLIPSOID shape. [#11875](https://github.com/CesiumGS/cesium/pull/11875) - Implemented vertical exaggeration for voxels with BOX shape. [#11887](https://github.com/CesiumGS/cesium/pull/11887) diff --git a/Specs/ShaderBuilderTester.js b/Specs/ShaderBuilderTester.js index a1b389f853c0..3f01f3b537f8 100644 --- a/Specs/ShaderBuilderTester.js +++ b/Specs/ShaderBuilderTester.js @@ -64,13 +64,15 @@ ShaderBuilderTester.expectHasVaryings = function ( shaderBuilder, expectedVaryings ) { - expectEqualUnordered( - shaderBuilder._vertexShaderParts.varyingLines, - expectedVaryings.map((varying) => `out ${varying}`) + expect(shaderBuilder._vertexShaderParts.varyingLines).toEqual( + jasmine.arrayWithExactContents( + expectedVaryings.map((varying) => jasmine.stringContaining(varying)) + ) ); - expectEqualUnordered( - shaderBuilder._fragmentShaderParts.varyingLines, - expectedVaryings.map((varying) => `in ${varying}`) + expect(shaderBuilder._fragmentShaderParts.varyingLines).toEqual( + jasmine.arrayWithExactContents( + expectedVaryings.map((varying) => jasmine.stringContaining(varying)) + ) ); }; diff --git a/packages/engine/Source/Core/PixelFormat.js b/packages/engine/Source/Core/PixelFormat.js index f68997489cf9..6f7061252aa7 100644 --- a/packages/engine/Source/Core/PixelFormat.js +++ b/packages/engine/Source/Core/PixelFormat.js @@ -240,6 +240,7 @@ PixelFormat.validate = function (pixelFormat) { */ PixelFormat.isColorFormat = function (pixelFormat) { return ( + pixelFormat === PixelFormat.RED || pixelFormat === PixelFormat.ALPHA || pixelFormat === PixelFormat.RGB || pixelFormat === PixelFormat.RGBA || diff --git a/packages/engine/Source/Core/Rectangle.js b/packages/engine/Source/Core/Rectangle.js index 36ca6fab850f..ffbcf09b4684 100644 --- a/packages/engine/Source/Core/Rectangle.js +++ b/packages/engine/Source/Core/Rectangle.js @@ -1,9 +1,12 @@ +import Cartesian3 from "./Cartesian3.js"; import Cartographic from "./Cartographic.js"; import Check from "./Check.js"; import defaultValue from "./defaultValue.js"; import defined from "./defined.js"; import Ellipsoid from "./Ellipsoid.js"; import CesiumMath from "./Math.js"; +import Transforms from "./Transforms.js"; +import Matrix4 from "./Matrix4.js"; /** * A two dimensional region specified as longitude and latitude coordinates. @@ -337,6 +340,92 @@ Rectangle.fromCartesianArray = function (cartesians, ellipsoid, result) { return result; }; +const fromBoundingSphereMatrixScratch = new Cartesian3(); +const fromBoundingSphereEastScratch = new Cartesian3(); +const fromBoundingSphereNorthScratch = new Cartesian3(); +const fromBoundingSphereWestScratch = new Cartesian3(); +const fromBoundingSphereSouthScratch = new Cartesian3(); +const fromBoundingSpherePositionsScratch = new Array(5); +for (let n = 0; n < fromBoundingSpherePositionsScratch.length; ++n) { + fromBoundingSpherePositionsScratch[n] = new Cartesian3(); +} +/** + * Create a rectangle from a bounding sphere, ignoring height. + * + * + * @param {BoundingSphere} boundingSphere The bounding sphere. + * @param {Ellipsoid} [ellipsoid=Ellipsoid.WGS84] The ellipsoid. + * @param {Rectangle} [result] The object onto which to store the result, or undefined if a new instance should be created. + * @returns {Rectangle} The modified result parameter or a new Rectangle instance if none was provided. + */ +Rectangle.fromBoundingSphere = function (boundingSphere, ellipsoid, result) { + //>>includeStart('debug', pragmas.debug); + Check.typeOf.object("boundingSphere", boundingSphere); + //>>includeEnd('debug'); + + const center = boundingSphere.center; + const radius = boundingSphere.radius; + + if (!defined(ellipsoid)) { + ellipsoid = Ellipsoid.WGS84; + } + + if (!defined(result)) { + result = new Rectangle(); + } + + if (Cartesian3.equals(center, Cartesian3.ZERO)) { + Rectangle.clone(Rectangle.MAX_VALUE, result); + return result; + } + + const fromENU = Transforms.eastNorthUpToFixedFrame( + center, + ellipsoid, + fromBoundingSphereMatrixScratch + ); + const east = Matrix4.multiplyByPointAsVector( + fromENU, + Cartesian3.UNIT_X, + fromBoundingSphereEastScratch + ); + Cartesian3.normalize(east, east); + const north = Matrix4.multiplyByPointAsVector( + fromENU, + Cartesian3.UNIT_Y, + fromBoundingSphereNorthScratch + ); + Cartesian3.normalize(north, north); + + Cartesian3.multiplyByScalar(north, radius, north); + Cartesian3.multiplyByScalar(east, radius, east); + + const south = Cartesian3.negate(north, fromBoundingSphereSouthScratch); + const west = Cartesian3.negate(east, fromBoundingSphereWestScratch); + + const positions = fromBoundingSpherePositionsScratch; + + // North + let corner = positions[0]; + Cartesian3.add(center, north, corner); + + // West + corner = positions[1]; + Cartesian3.add(center, west, corner); + + // South + corner = positions[2]; + Cartesian3.add(center, south, corner); + + // East + corner = positions[3]; + Cartesian3.add(center, east, corner); + + positions[4] = center; + + return Rectangle.fromCartesianArray(positions, ellipsoid, result); +}; + /** * Duplicates a Rectangle. * diff --git a/packages/engine/Source/Renderer/ShaderBuilder.js b/packages/engine/Source/Renderer/ShaderBuilder.js index f6cc1b3c643d..6fd79ab0098e 100644 --- a/packages/engine/Source/Renderer/ShaderBuilder.js +++ b/packages/engine/Source/Renderer/ShaderBuilder.js @@ -369,21 +369,24 @@ ShaderBuilder.prototype.addAttribute = function (type, identifier) { * * @param {string} type The GLSL type of the varying * @param {string} identifier An identifier for the varying. Identifiers must begin with v_ to be consistent with Cesium's style guide. + * @param {string} [qualifier] A qualifier for the varying, such as flat. * * @example * // creates the line "in vec3 v_color;" in the vertex shader * // creates the line "out vec3 v_color;" in the fragment shader * shaderBuilder.addVarying("vec3", "v_color"); */ -ShaderBuilder.prototype.addVarying = function (type, identifier) { +ShaderBuilder.prototype.addVarying = function (type, identifier, qualifier) { //>>includeStart('debug', pragmas.debug); Check.typeOf.string("type", type); Check.typeOf.string("identifier", identifier); //>>includeEnd('debug'); + qualifier = defined(qualifier) ? `${qualifier} ` : ""; + const line = `${type} ${identifier};`; - this._vertexShaderParts.varyingLines.push(`out ${line}`); - this._fragmentShaderParts.varyingLines.push(`in ${line}`); + this._vertexShaderParts.varyingLines.push(`${qualifier}out ${line}`); + this._fragmentShaderParts.varyingLines.push(`${qualifier}in ${line}`); }; /** diff --git a/packages/engine/Source/Scene/Cesium3DTile.js b/packages/engine/Source/Scene/Cesium3DTile.js index 8dd01a1f77a3..2d6a2d80974b 100644 --- a/packages/engine/Source/Scene/Cesium3DTile.js +++ b/packages/engine/Source/Scene/Cesium3DTile.js @@ -411,6 +411,16 @@ function Cesium3DTile(tileset, baseResource, header, parent) { */ this.clippingPlanesDirty = false; + /** + * Tracks if the tile's relationship with a ClippingPolygonCollection has changed with regards + * to the ClippingPolygonCollection's state. + * + * @type {boolean} + * + * @private + */ + this.clippingPolygonsDirty = false; + /** * Tracks if the tile's request should be deferred until all non-deferred * tiles load. @@ -481,7 +491,9 @@ function Cesium3DTile(tileset, baseResource, header, parent) { this._refines = false; this._shouldSelect = false; this._isClipped = true; + this._isClippedByPolygon = false; this._clippingPlanesState = 0; // encapsulates (_isClipped, clippingPlanes.enabled) and number/function + this._clippingPolygonsState = 0; // encapsulates (_isClipped, clippingPolygons.enabled) and number/function this._debugBoundingVolume = undefined; this._debugContentBoundingVolume = undefined; this._debugViewerRequestVolume = undefined; @@ -1418,6 +1430,8 @@ Cesium3DTile.prototype.unloadContent = function () { this.lastStyleTime = 0.0; this.clippingPlanesDirty = this._clippingPlanesState === 0; this._clippingPlanesState = 0; + this.clippingPolygonsDirty = this._clippingPolygonsState === 0; + this._clippingPolygonsState = 0; this._debugColorizeTiles = false; @@ -1516,6 +1530,17 @@ Cesium3DTile.prototype.visibility = function ( } } + const clippingPolygons = tileset.clippingPolygons; + if (defined(clippingPolygons) && clippingPolygons.enabled) { + const intersection = clippingPolygons.computeIntersectionWithBoundingVolume( + boundingVolume + ); + + this._isClippedByPolygon = intersection !== Intersect.OUTSIDE; + // Polygon clipping intersections are determined by outer rectangles, therefore we cannot + // preemptively determine if a tile is completely clipped or not here. + } + return cullingVolume.computeVisibilityWithPlaneMask( boundingVolume, parentVisibilityPlaneMask @@ -1562,6 +1587,17 @@ Cesium3DTile.prototype.contentVisibility = function (frameState) { } } + const clippingPolygons = tileset.clippingPolygons; + if (defined(clippingPolygons) && clippingPolygons.enabled) { + const intersection = clippingPolygons.computeIntersectionWithBoundingVolume( + boundingVolume + ); + this._isClippedByPolygon = intersection !== Intersect.OUTSIDE; + if (intersection === Intersect.INSIDE) { + return Intersect.OUTSIDE; + } + } + return cullingVolume.computeVisibility(boundingVolume); }; @@ -2150,6 +2186,33 @@ function updateClippingPlanes(tile, tileset) { } } +/** + * Compute and compare ClippingPolygons state: + * - enabled-ness - are clipping polygons enabled? is this tile clipped? + * - clipping polygon count & position count + * - clipping function (inverse) + + * @private + * @param {Cesium3DTile} tile + * @param {Cesium3DTileset} tileset + */ +function updateClippingPolygons(tile, tileset) { + const clippingPolygons = tileset.clippingPolygons; + let currentClippingPolygonsState = 0; + if ( + defined(clippingPolygons) && + tile._isClippedByPolygon && + clippingPolygons.enabled + ) { + currentClippingPolygonsState = clippingPolygons.clippingPolygonsState; + } + // If clippingPolygonState for tile changed, mark clippingPolygonsDirty so content can update + if (currentClippingPolygonsState !== tile._clippingPolygonsState) { + tile._clippingPolygonsState = currentClippingPolygonsState; + tile.clippingPolygonsDirty = true; + } +} + /** * Get the draw commands needed to render this tile. * @@ -2163,6 +2226,7 @@ Cesium3DTile.prototype.update = function (tileset, frameState, passOptions) { const commandStart = commandList.length; updateClippingPlanes(this, tileset); + updateClippingPolygons(this, tileset); applyDebugSettings(this, tileset, frameState, passOptions); updateContent(this, tileset, frameState); @@ -2176,6 +2240,7 @@ Cesium3DTile.prototype.update = function (tileset, frameState, passOptions) { } this.clippingPlanesDirty = false; // reset after content update + this.clippingPolygonsDirty = false; }; const scratchCommandList = []; diff --git a/packages/engine/Source/Scene/Cesium3DTileset.js b/packages/engine/Source/Scene/Cesium3DTileset.js index b09711b63874..abcb2071b8b6 100644 --- a/packages/engine/Source/Scene/Cesium3DTileset.js +++ b/packages/engine/Source/Scene/Cesium3DTileset.js @@ -41,6 +41,7 @@ import Cesium3DTilesetHeatmap from "./Cesium3DTilesetHeatmap.js"; import Cesium3DTilesetStatistics from "./Cesium3DTilesetStatistics.js"; import Cesium3DTileStyleEngine from "./Cesium3DTileStyleEngine.js"; import ClippingPlaneCollection from "./ClippingPlaneCollection.js"; +import ClippingPolygonCollection from "./ClippingPolygonCollection.js"; import hasExtension from "./hasExtension.js"; import ImplicitTileset from "./ImplicitTileset.js"; import ImplicitTileCoordinates from "./ImplicitTileCoordinates.js"; @@ -97,6 +98,7 @@ import Ray from "../Core/Ray.js"; * @property {boolean} [immediatelyLoadDesiredLevelOfDetail=false] When skipLevelOfDetail is true, only tiles that meet the maximum screen space error will ever be downloaded. Skipping factors are ignored and just the desired tiles are loaded. * @property {boolean} [loadSiblings=false] When skipLevelOfDetail is true, determines whether siblings of visible tiles are always downloaded during traversal. * @property {ClippingPlaneCollection} [clippingPlanes] The {@link ClippingPlaneCollection} used to selectively disable rendering the tileset. + * @property {ClippingPolygonCollection} [clippingPolygons] The {@link ClippingPolygonCollection} used to selectively disable rendering the tileset. * @property {ClassificationType} [classificationType] Determines whether terrain, 3D Tiles or both will be classified by this tileset. See {@link Cesium3DTileset#classificationType} for details about restrictions and limitations. * @property {Ellipsoid} [ellipsoid=Ellipsoid.WGS84] The ellipsoid determining the size and shape of the globe. * @property {object} [pointCloudShading] Options for constructing a {@link PointCloudShading} object to control point attenuation based on geometric error and lighting. @@ -800,7 +802,22 @@ function Cesium3DTileset(options) { this.loadSiblings = defaultValue(options.loadSiblings, false); this._clippingPlanes = undefined; - this.clippingPlanes = options.clippingPlanes; + if (defined(options.clippingPlanes)) { + ClippingPlaneCollection.setOwner( + options.clippingPlanes, + this, + "_clippingPlanes" + ); + } + + this._clippingPolygons = undefined; + if (defined(options.clippingPolygons)) { + ClippingPolygonCollection.setOwner( + options.clippingPolygons, + this, + "_clippingPolygons" + ); + } if (defined(options.imageBasedLighting)) { this._imageBasedLighting = options.imageBasedLighting; @@ -1120,6 +1137,22 @@ Object.defineProperties(Cesium3DTileset.prototype, { }, }, + /** + * The {@link ClippingPolygonCollection} used to selectively disable rendering the tileset. + * + * @memberof Cesium3DTileset.prototype + * + * @type {ClippingPolygonCollection} + */ + clippingPolygons: { + get: function () { + return this._clippingPolygons; + }, + set: function (value) { + ClippingPolygonCollection.setOwner(value, this, "_clippingPolygons"); + }, + }, + /** * Gets the tileset's properties dictionary object, which contains metadata about per-feature properties. *

@@ -2519,6 +2552,12 @@ Cesium3DTileset.prototype.prePassesUpdate = function (frameState) { clippingPlanes.update(frameState); } + // Update clipping polygons + const clippingPolygons = this._clippingPolygons; + if (defined(clippingPolygons) && clippingPolygons.enabled) { + clippingPolygons.update(frameState); + } + if (!defined(this._loadTimestamp)) { this._loadTimestamp = JulianDate.clone(frameState.time); } @@ -3372,6 +3411,12 @@ Cesium3DTileset.prototype.updateForPass = function ( originalCullingVolume ); + // Update clipping polygons + const clippingPolygons = this._clippingPolygons; + if (defined(clippingPolygons) && clippingPolygons.enabled) { + clippingPolygons.queueCommands(frameState); + } + const passStatistics = this._statisticsPerPass[pass]; if (this.show || ignoreCommands) { @@ -3440,6 +3485,8 @@ Cesium3DTileset.prototype.destroy = function () { this._tileDebugLabels = this._tileDebugLabels && this._tileDebugLabels.destroy(); this._clippingPlanes = this._clippingPlanes && this._clippingPlanes.destroy(); + this._clippingPolygons = + this._clippingPolygons && this._clippingPolygons.destroy(); // Traverse the tree and destroy all tiles if (defined(this._root)) { diff --git a/packages/engine/Source/Scene/ClippingPolygon.js b/packages/engine/Source/Scene/ClippingPolygon.js new file mode 100644 index 000000000000..786a63f9981b --- /dev/null +++ b/packages/engine/Source/Scene/ClippingPolygon.js @@ -0,0 +1,212 @@ +import Check from "../Core/Check.js"; +import Cartesian3 from "../Core/Cartesian3.js"; +import Cartographic from "../Core/Cartographic.js"; +import defaultValue from "../Core/defaultValue.js"; +import defined from "../Core/defined.js"; +import Ellipsoid from "../Core/Ellipsoid.js"; +import CesiumMath from "../Core/Math.js"; +import PolygonGeometry from "../Core/PolygonGeometry.js"; +import Rectangle from "../Core/Rectangle.js"; + +/** + * A geodesic polygon to be used with {@link ClippingPlaneCollection} for selectively hiding regions in a model, a 3D tileset, or the globe. + * @alias ClippingPolygon + * @constructor + * + * @param {object} options Object with the following properties: + * @param {Cartesian3[]} options.positions A list of three or more Cartesian coordinates defining the outer ring of the clipping polygon. + * @param {Ellipsoid} [options.ellipsoid=Ellipsoid.WGS84] + * + * @example + * const positions = Cesium.Cartesian3.fromRadiansArray([ + * -1.3194369277314022, + * 0.6988062530900625, + * -1.31941, + * 0.69879, + * -1.3193955980204217, + * 0.6988091578771254, + * -1.3193931220959367, + * 0.698743632490865, + * -1.3194358224045408, + * 0.6987471965556998, + * ]); + * + * const polygon = new Cesium.ClippingPolygon({ + * positions: positions + * }); + */ +function ClippingPolygon(options) { + //>>includeStart('debug', pragmas.debug); + Check.typeOf.object("options", options); + Check.typeOf.object("options.positions", options.positions); + Check.typeOf.number.greaterThanOrEquals( + "options.positions.length", + options.positions.length, + 3 + ); + //>>includeEnd('debug'); + + this._ellipsoid = defaultValue(options.ellipsoid, Ellipsoid.WGS84); + this._positions = [...options.positions]; +} + +Object.defineProperties(ClippingPolygon.prototype, { + /** + * Returns the total number of positions in the polygon, include any holes. + * + * @memberof ClippingPolygon.prototype + * @type {number} + * @readonly + */ + length: { + get: function () { + return this._positions.length; + }, + }, + /** + * Returns the outer ring of positions. + * + * @memberof ClippingPolygon.prototype + * @type {Cartesian3[]} + * @readonly + */ + positions: { + get: function () { + return this._positions; + }, + }, + /** + * Returns the ellipsoid used to project the polygon onto surfaces when clipping. + * + * @memberof ClippingPolygon.prototype + * @type {Ellipsoid} + * @readonly + */ + ellipsoid: { + get: function () { + return this._ellipsoid; + }, + }, +}); + +/** + * Clones the ClippingPolygon without setting its ownership. + * @param {ClippingPolygon} polygon The ClippingPolygon to be cloned + * @param {ClippingPolygon} [result] The object on which to store the cloned parameters. + * @returns {ClippingPolygon} a clone of the input ClippingPolygon + */ +ClippingPolygon.clone = function (polygon, result) { + //>>includeStart('debug', pragmas.debug); + Check.typeOf.object("polygon", polygon); + //>>includeEnd('debug'); + + if (!defined(result)) { + return new ClippingPolygon({ + positions: polygon.positions, + ellipsoid: polygon.ellipsoid, + }); + } + + result._ellipsoid = polygon.ellipsoid; + result._positions.length = 0; + result._positions.push(...polygon.positions); + return result; +}; + +/** + * Compares the provided ClippingPolygons and returns + * true if they are equal, false otherwise. + * + * @param {Plane} left The first polygon. + * @param {Plane} right The second polygon. + * @returns {boolean} true if left and right are equal, false otherwise. + */ +ClippingPolygon.equals = function (left, right) { + //>>includeStart('debug', pragmas.debug); + Check.typeOf.object("left", left); + Check.typeOf.object("right", right); + //>>includeEnd('debug'); + + return ( + left.ellipsoid.equals(right.ellipsoid) && left.positions === right.positions + ); +}; + +/** + * Computes a cartographic rectangle which encloses the polygon defined by the list of positions, including cases over the international date line and the poles. + * + * @param {Rectangle} [result] An object in which to store the result. + * @returns {Rectangle} The result rectangle + */ +ClippingPolygon.prototype.computeRectangle = function (result) { + return PolygonGeometry.computeRectangleFromPositions( + this.positions, + this.ellipsoid, + undefined, + result + ); +}; + +const scratchRectangle = new Rectangle(); +const spherePointScratch = new Cartesian3(); +/** + * Computes a rectangle with the spherical extents that encloses the polygon defined by the list of positions, including cases over the international date line and the poles. + * + * @private + * + * @param {Rectangle} [result] An object in which to store the result. + * @returns {Rectangle} The result rectangle with spherical extents. + */ +ClippingPolygon.prototype.computeSphericalExtents = function (result) { + if (!defined(result)) { + result = new Rectangle(); + } + + const rectangle = this.computeRectangle(scratchRectangle); + + let spherePoint = Cartographic.toCartesian( + Rectangle.southwest(rectangle), + this.ellipsoid, + spherePointScratch + ); + + // Project into plane with vertical for latitude + let magXY = Math.sqrt( + spherePoint.x * spherePoint.x + spherePoint.y * spherePoint.y + ); + + // Use fastApproximateAtan2 for alignment with shader + let sphereLatitude = CesiumMath.fastApproximateAtan2(magXY, spherePoint.z); + let sphereLongitude = CesiumMath.fastApproximateAtan2( + spherePoint.x, + spherePoint.y + ); + + result.south = sphereLatitude; + result.west = sphereLongitude; + + spherePoint = Cartographic.toCartesian( + Rectangle.northeast(rectangle), + this.ellipsoid, + spherePointScratch + ); + + // Project into plane with vertical for latitude + magXY = Math.sqrt( + spherePoint.x * spherePoint.x + spherePoint.y * spherePoint.y + ); + + // Use fastApproximateAtan2 for alignment with shader + sphereLatitude = CesiumMath.fastApproximateAtan2(magXY, spherePoint.z); + sphereLongitude = CesiumMath.fastApproximateAtan2( + spherePoint.x, + spherePoint.y + ); + + result.north = sphereLatitude; + result.east = sphereLongitude; + + return result; +}; + +export default ClippingPolygon; diff --git a/packages/engine/Source/Scene/ClippingPolygonCollection.js b/packages/engine/Source/Scene/ClippingPolygonCollection.js new file mode 100644 index 000000000000..6fb039f71200 --- /dev/null +++ b/packages/engine/Source/Scene/ClippingPolygonCollection.js @@ -0,0 +1,968 @@ +import Cartesian2 from "../Core/Cartesian2.js"; +import CesiumMath from "../Core/Math.js"; +import Check from "../Core/Check.js"; +import defaultValue from "../Core/defaultValue.js"; +import defined from "../Core/defined.js"; +import destroyObject from "../Core/destroyObject.js"; +import DeveloperError from "../Core/DeveloperError.js"; +import Event from "../Core/Event.js"; +import Intersect from "../Core/Intersect.js"; +import PixelFormat from "../Core/PixelFormat.js"; +import Rectangle from "../Core/Rectangle.js"; +import ContextLimits from "../Renderer/ContextLimits.js"; +import PixelDatatype from "../Renderer/PixelDatatype.js"; +import RuntimeError from "../Core/RuntimeError.js"; +import Sampler from "../Renderer/Sampler.js"; +import Texture from "../Renderer/Texture.js"; +import TextureMagnificationFilter from "../Renderer/TextureMagnificationFilter.js"; +import TextureMinificationFilter from "../Renderer/TextureMinificationFilter.js"; +import TextureWrap from "../Renderer/TextureWrap.js"; +import ClippingPolygon from "./ClippingPolygon.js"; +import ComputeCommand from "../Renderer/ComputeCommand.js"; +import PolygonSignedDistanceFS from "../Shaders/PolygonSignedDistanceFS.js"; + +/** + * Specifies a set of clipping polygons. Clipping polygons selectively disable rendering in a region + * inside or outside the specified list of {@link ClippingPolygon} objects for a single glTF model, 3D Tileset, or the globe. + * + * Clipping Polygons are only supported in WebGL 2 contexts. + * + * @alias ClippingPolygonCollection + * @constructor + * + * @param {object} [options] Object with the following properties: + * @param {ClippingPolygon[]} [options.polygons=[]] An array of {@link ClippingPolygon} objects used to selectively disable rendering on the inside of each polygon. + * @param {boolean} [options.enabled=true] Determines whether the clipping polygons are active. + * @param {boolean} [options.inverse=false] If true, a region will be clipped if it is outside of every polygon in the collection. Otherwise, a region will only be clipped if it is on the inside of any polygon. + * + * @example + * const positions = Cesium.Cartesian3.fromRadiansArray([ + * -1.3194369277314022, + * 0.6988062530900625, + * -1.31941, + * 0.69879, + * -1.3193955980204217, + * 0.6988091578771254, + * -1.3193931220959367, + * 0.698743632490865, + * -1.3194358224045408, + * 0.6987471965556998, + * ]); + * + * const polygon = new Cesium.ClippingPolygon({ + * positions: positions + * }); + * + * const polygons = new Cesium.ClippingPolygonCollection({ + * polygons: [ polygon ] + * }); + */ +function ClippingPolygonCollection(options) { + options = defaultValue(options, defaultValue.EMPTY_OBJECT); + + this._polygons = []; + this._totalPositions = 0; + + /** + * If true, clipping will be enabled. + * + * @memberof ClippingPolygonCollection.prototype + * @type {boolean} + * @default true + */ + this.enabled = defaultValue(options.enabled, true); + + /** + * If true, a region will be clipped if it is outside of every polygon in the + * collection. Otherwise, a region will only be clipped if it is + * inside of any polygon. + * + * @memberof ClippingPolygonCollection.prototype + * @type {boolean} + * @default false + */ + this.inverse = defaultValue(options.inverse, false); + + /** + * An event triggered when a new clipping polygon is added to the collection. Event handlers + * are passed the new polygon and the index at which it was added. + * @type {Event} + * @default Event() + */ + this.polygonAdded = new Event(); + + /** + * An event triggered when a new clipping polygon is removed from the collection. Event handlers + * are passed the new polygon and the index from which it was removed. + * @type {Event} + * @default Event() + */ + this.polygonRemoved = new Event(); + + // If this ClippingPolygonCollection has an owner, only its owner should update or destroy it. + // This is because in a Cesium3DTileset multiple models may reference the tileset's ClippingPolygonCollection. + this._owner = undefined; + + this._float32View = undefined; + this._extentsFloat32View = undefined; + this._extentsCount = 0; + + this._polygonsTexture = undefined; + this._extentsTexture = undefined; + this._signedDistanceTexture = undefined; + + this._signedDistanceComputeCommand = undefined; + + // Add each ClippingPolygon object. + const polygons = options.polygons; + if (defined(polygons)) { + const polygonsLength = polygons.length; + for (let i = 0; i < polygonsLength; ++i) { + this._polygons.push(polygons[i]); + } + } +} + +Object.defineProperties(ClippingPolygonCollection.prototype, { + /** + * Returns the number of polygons in this collection. This is commonly used with + * {@link ClippingPolygonCollection#get} to iterate over all the polygons + * in the collection. + * + * @memberof ClippingPolygonCollection.prototype + * @type {number} + * @readonly + */ + length: { + get: function () { + return this._polygons.length; + }, + }, + + /** + * Returns the total number of positions in all polygons in the collection. + * + * @memberof ClippingPolygonCollection.prototype + * @type {number} + * @readonly + * @private + */ + totalPositions: { + get: function () { + return this._totalPositions; + }, + }, + + /** + * Returns a texture containing the packed computed spherical extents for each polygon + * + * @memberof ClippingPolygonCollection.prototype + * @type {Texture} + * @readonly + * @private + */ + extentsTexture: { + get: function () { + return this._extentsTexture; + }, + }, + + /** + * Returns the number of packed extents, which can be fewer than the number of polygons. + * + * @memberof ClippingPolygonCollection.prototype + * @type {number} + * @readonly + * @private + */ + extentsCount: { + get: function () { + return this._extentsCount; + }, + }, + + /** + * Returns the number of pixels needed in the texture containing the packed computed spherical extents for each polygon. + * + * @memberof ClippingPolygonCollection.prototype + * @type {number} + * @readonly + * @private + */ + pixelsNeededForExtents: { + get: function () { + return this.length; // With an RGBA texture, each pixel contains min/max latitude and longitude. + }, + }, + + /** + * Returns the number of pixels needed in the texture containing the packed polygon positions. + * + * @memberof ClippingPolygonCollection.prototype + * @type {number} + * @readonly + * @private + */ + pixelsNeededForPolygonPositions: { + get: function () { + // In an RG FLOAT texture, each polygon position is 2 floats packed to a RG. + // Each polygon is the number of positions of that polygon, followed by the list of positions + return this.totalPositions + this.length; + }, + }, + + /** + * Returns a texture containing the computed signed distance of each polygon. + * + * @memberof ClippingPolygonCollection.prototype + * @type {Texture} + * @readonly + * @private + */ + clippingTexture: { + get: function () { + return this._signedDistanceTexture; + }, + }, + + /** + * A reference to the ClippingPolygonCollection's owner, if any. + * + * @memberof ClippingPolygonCollection.prototype + * @readonly + * @private + */ + owner: { + get: function () { + return this._owner; + }, + }, + + /** + * Returns a number encapsulating the state for this ClippingPolygonCollection. + * + * Clipping mode is encoded in the sign of the number, which is just the total position count. + * If this value changes, then shader regeneration is necessary. + * + * @memberof ClippingPolygonCollection.prototype + * @returns {number} A Number that describes the ClippingPolygonCollection's state. + * @readonly + * @private + */ + clippingPolygonsState: { + get: function () { + return this.inverse ? -this.extentsCount : this.extentsCount; + }, + }, +}); + +/** + * Adds the specified {@link ClippingPolygon} to the collection to be used to selectively disable rendering + * on the inside of each polygon. Use {@link ClippingPolygonCollection#unionClippingRegions} to modify + * how modify the clipping behavior of multiple polygons. + * + * @param {ClippingPolygon} polygon The ClippingPolygon to add to the collection. + * @returns {ClippingPolygon} The added ClippingPolygon. + * + * @example + * const polygons = new Cesium.ClippingPolygonCollection(); + * + * const positions = Cesium.Cartesian3.fromRadiansArray([ + * -1.3194369277314022, + * 0.6988062530900625, + * -1.31941, + * 0.69879, + * -1.3193955980204217, + * 0.6988091578771254, + * -1.3193931220959367, + * 0.698743632490865, + * -1.3194358224045408, + * 0.6987471965556998, + * ]); + * + * polygons.add(new Cesium.ClippingPolygon({ + * positions: positions + * })); + * + * + * + * @see ClippingPolygonCollection#remove + * @see ClippingPolygonCollection#removeAll + */ +ClippingPolygonCollection.prototype.add = function (polygon) { + //>>includeStart('debug', pragmas.debug); + Check.typeOf.object("polygon", polygon); + //>>includeEnd('debug'); + + const newPlaneIndex = this._polygons.length; + this._polygons.push(polygon); + this.polygonAdded.raiseEvent(polygon, newPlaneIndex); + return polygon; +}; + +/** + * Returns the clipping polygon in the collection at the specified index. Indices are zero-based + * and increase as polygons are added. Removing a polygon polygon all polygons after + * it to the left, changing their indices. This function is commonly used with + * {@link ClippingPolygonCollection#length} to iterate over all the polygons + * in the collection. + * + * @param {number} index The zero-based index of the polygon. + * @returns {ClippingPolygon} The ClippingPolygon at the specified index. + * + * @see ClippingPolygonCollection#length + */ +ClippingPolygonCollection.prototype.get = function (index) { + //>>includeStart('debug', pragmas.debug); + Check.typeOf.number("index", index); + //>>includeEnd('debug'); + + return this._polygons[index]; +}; + +/** + * Checks whether this collection contains a ClippingPolygon equal to the given ClippingPolygon. + * + * @param {ClippingPolygon} polygon The ClippingPolygon to check for. + * @returns {boolean} true if this collection contains the ClippingPolygon, false otherwise. + * + * @see ClippingPolygonCollection#get + */ +ClippingPolygonCollection.prototype.contains = function (polygon) { + //>>includeStart('debug', pragmas.debug); + Check.typeOf.object("polygon", polygon); + //>>includeEnd('debug'); + + return this._polygons.some((p) => ClippingPolygon.equals(p, polygon)); +}; + +/** + * Removes the first occurrence of the given ClippingPolygon from the collection. + * + * @param {ClippingPolygon} polygon + * @returns {boolean} true if the polygon was removed; false if the polygon was not found in the collection. + * + * @see ClippingPolygonCollection#add + * @see ClippingPolygonCollection#contains + * @see ClippingPolygonCollection#removeAll + */ +ClippingPolygonCollection.prototype.remove = function (polygon) { + //>>includeStart('debug', pragmas.debug); + Check.typeOf.object("polygon", polygon); + //>>includeEnd('debug'); + + const polygons = this._polygons; + const index = polygons.findIndex((p) => ClippingPolygon.equals(p, polygon)); + + if (index === -1) { + return false; + } + + polygons.splice(index, 1); + + this.polygonRemoved.raiseEvent(polygon, index); + return true; +}; + +const scratchRectangle = new Rectangle(); + +// Map the polygons to a list of extents-- Overlapping extents will be merged +// into a single encompassing extent +function getExtents(polygons) { + const extentsList = []; + const polygonIndicesList = []; + + const length = polygons.length; + for (let polygonIndex = 0; polygonIndex < length; ++polygonIndex) { + const polygon = polygons[polygonIndex]; + const extents = polygon.computeSphericalExtents(); + + let height = Math.max(extents.height * 2.5, 0.001); + let width = Math.max(extents.width * 2.5, 0.001); + + // Pad extents to avoid floating point error when fragment culling at edges. + let paddedExtents = Rectangle.clone(extents); + paddedExtents.south -= height; + paddedExtents.west -= width; + paddedExtents.north += height; + paddedExtents.east += width; + + paddedExtents.south = Math.max(paddedExtents.south, -Math.PI); + paddedExtents.west = Math.max(paddedExtents.west, -Math.PI); + paddedExtents.north = Math.min(paddedExtents.north, Math.PI); + paddedExtents.east = Math.min(paddedExtents.east, Math.PI); + + const polygonIndices = [polygonIndex]; + for (let i = 0; i < extentsList.length; ++i) { + const e = extentsList[i]; + if ( + defined(e) && + defined(Rectangle.simpleIntersection(e, paddedExtents)) && + !Rectangle.equals(e, paddedExtents) + ) { + const intersectingPolygons = polygonIndicesList[i]; + polygonIndices.push(...intersectingPolygons); + intersectingPolygons.reduce( + (extents, p) => + Rectangle.union( + polygons[p].computeSphericalExtents(scratchRectangle), + extents, + extents + ), + extents + ); + + extentsList[i] = undefined; + polygonIndicesList[i] = undefined; + + height = Math.max(extents.height * 2.5, 0.001); + width = Math.max(extents.width * 2.5, 0.001); + + paddedExtents = Rectangle.clone(extents, paddedExtents); + paddedExtents.south -= height; + paddedExtents.west -= width; + paddedExtents.north += height; + paddedExtents.east += width; + + paddedExtents.south = Math.max(paddedExtents.south, -Math.PI); + paddedExtents.west = Math.max(paddedExtents.west, -Math.PI); + paddedExtents.north = Math.min(paddedExtents.north, Math.PI); + paddedExtents.east = Math.min(paddedExtents.east, Math.PI); + + // Reiterate through the extents list until there are no more intersections + i = -1; + } + } + + extentsList.push(paddedExtents); + polygonIndicesList.push(polygonIndices); + } + + const extentsIndexByPolygon = new Map(); + polygonIndicesList + .filter(defined) + .forEach((polygonIndices, e) => + polygonIndices.forEach((p) => extentsIndexByPolygon.set(p, e)) + ); + + return { + extentsList: extentsList.filter(defined), + extentsIndexByPolygon: extentsIndexByPolygon, + }; +} + +/** + * Removes all polygons from the collection. + * + * @see ClippingPolygonCollection#add + * @see ClippingPolygonCollection#remove + */ +ClippingPolygonCollection.prototype.removeAll = function () { + // Dereference this ClippingPolygonCollection from all ClippingPolygons + const polygons = this._polygons; + const polygonsCount = polygons.length; + for (let i = 0; i < polygonsCount; ++i) { + const polygon = polygons[i]; + this.polygonRemoved.raiseEvent(polygon, i); + } + this._polygons = []; +}; + +function packPolygonsAsFloats(clippingPolygonCollection) { + const polygonsFloat32View = clippingPolygonCollection._float32View; + const extentsFloat32View = clippingPolygonCollection._extentsFloat32View; + const polygons = clippingPolygonCollection._polygons; + + const { extentsList, extentsIndexByPolygon } = getExtents(polygons); + + let floatIndex = 0; + for (const [polygonIndex, polygon] of polygons.entries()) { + // Pack the length of the polygon into the polygon texture array buffer + const length = polygon.length; + polygonsFloat32View[floatIndex++] = length; + polygonsFloat32View[floatIndex++] = extentsIndexByPolygon.get(polygonIndex); + + // Pack the polygon positions into the polygon texture array buffer + for (let i = 0; i < length; ++i) { + const spherePoint = polygon.positions[i]; + + // Project into plane with vertical for latitude + const magXY = Math.hypot(spherePoint.x, spherePoint.y); + + // Use fastApproximateAtan2 for alignment with shader + const latitudeApproximation = CesiumMath.fastApproximateAtan2( + magXY, + spherePoint.z + ); + const longitudeApproximation = CesiumMath.fastApproximateAtan2( + spherePoint.x, + spherePoint.y + ); + + polygonsFloat32View[floatIndex++] = latitudeApproximation; + polygonsFloat32View[floatIndex++] = longitudeApproximation; + } + } + + // Pack extents + let extentsFloatIndex = 0; + for (const extents of extentsList) { + const longitudeRangeInverse = 1.0 / (extents.east - extents.west); + const latitudeRangeInverse = 1.0 / (extents.north - extents.south); + + extentsFloat32View[extentsFloatIndex++] = extents.south; + extentsFloat32View[extentsFloatIndex++] = extents.west; + extentsFloat32View[extentsFloatIndex++] = latitudeRangeInverse; + extentsFloat32View[extentsFloatIndex++] = longitudeRangeInverse; + } + + clippingPolygonCollection._extentsCount = extentsList.length; +} + +const textureResolutionScratch = new Cartesian2(); +/** + * Called when {@link Viewer} or {@link CesiumWidget} render the scene to + * build the resources for clipping polygons. + *

+ * Do not call this function directly. + *

+ * @private + * @throws {RuntimeError} ClippingPolygonCollections are only supported for WebGL 2 + */ +ClippingPolygonCollection.prototype.update = function (frameState) { + const context = frameState.context; + + if (!ClippingPolygonCollection.isSupported(frameState)) { + throw new RuntimeError( + "ClippingPolygonCollections are only supported for WebGL 2." + ); + } + + // It'd be expensive to validate any individual position has changed. Instead verify if the list of polygon positions has had elements added or removed, which should be good enough for most cases. + const totalPositions = this._polygons.reduce( + (totalPositions, polygon) => totalPositions + polygon.length, + 0 + ); + + if (totalPositions === this.totalPositions) { + return; + } + + this._totalPositions = totalPositions; + + // If there are no clipping polygons, there's nothing to update. + if (this.length === 0) { + return; + } + + if (defined(this._signedDistanceComputeCommand)) { + this._signedDistanceComputeCommand.canceled = true; + this._signedDistanceComputeCommand = undefined; + } + + let polygonsTexture = this._polygonsTexture; + let extentsTexture = this._extentsTexture; + let signedDistanceTexture = this._signedDistanceTexture; + if (defined(polygonsTexture)) { + const currentPixelCount = polygonsTexture.width * polygonsTexture.height; + // Recreate the texture to double current requirement if it isn't big enough or is 4 times larger than it needs to be. + // Optimization note: this isn't exactly the classic resizeable array algorithm + // * not necessarily checking for resize after each add/remove operation + // * random-access deletes instead of just pops + // * alloc ops likely more expensive than demonstrable via big-O analysis + if ( + currentPixelCount < this.pixelsNeededForPolygonPositions || + this.pixelsNeededForPolygonPositions < 0.25 * currentPixelCount + ) { + polygonsTexture.destroy(); + polygonsTexture = undefined; + this._polygonsTexture = undefined; + } + } + + if (!defined(polygonsTexture)) { + const requiredResolution = ClippingPolygonCollection.getTextureResolution( + polygonsTexture, + this.pixelsNeededForPolygonPositions, + textureResolutionScratch + ); + + polygonsTexture = new Texture({ + context: context, + width: requiredResolution.x, + height: requiredResolution.y, + pixelFormat: PixelFormat.RG, + pixelDatatype: PixelDatatype.FLOAT, + sampler: Sampler.NEAREST, + flipY: false, + }); + this._float32View = new Float32Array( + requiredResolution.x * requiredResolution.y * 2 + ); + this._polygonsTexture = polygonsTexture; + } + + if (defined(extentsTexture)) { + const currentPixelCount = extentsTexture.width * extentsTexture.height; + // Recreate the texture to double current requirement if it isn't big enough or is 4 times larger than it needs to be. + // Optimization note: this isn't exactly the classic resizeable array algorithm + // * not necessarily checking for resize after each add/remove operation + // * random-access deletes instead of just pops + // * alloc ops likely more expensive than demonstrable via big-O analysis + if ( + currentPixelCount < this.pixelsNeededForExtents || + this.pixelsNeededForExtents < 0.25 * currentPixelCount + ) { + extentsTexture.destroy(); + extentsTexture = undefined; + this._extentsTexture = undefined; + } + } + + if (!defined(extentsTexture)) { + const requiredResolution = ClippingPolygonCollection.getTextureResolution( + extentsTexture, + this.pixelsNeededForExtents, + textureResolutionScratch + ); + + extentsTexture = new Texture({ + context: context, + width: requiredResolution.x, + height: requiredResolution.y, + pixelFormat: PixelFormat.RGBA, + pixelDatatype: PixelDatatype.FLOAT, + sampler: Sampler.NEAREST, + flipY: false, + }); + this._extentsFloat32View = new Float32Array( + requiredResolution.x * requiredResolution.y * 4 + ); + + this._extentsTexture = extentsTexture; + } + + packPolygonsAsFloats(this); + + extentsTexture.copyFrom({ + source: { + width: extentsTexture.width, + height: extentsTexture.height, + arrayBufferView: this._extentsFloat32View, + }, + }); + + polygonsTexture.copyFrom({ + source: { + width: polygonsTexture.width, + height: polygonsTexture.height, + arrayBufferView: this._float32View, + }, + }); + + if (!defined(signedDistanceTexture)) { + const textureDimensions = ClippingPolygonCollection.getClippingDistanceTextureResolution( + this, + textureResolutionScratch + ); + signedDistanceTexture = new Texture({ + context: context, + width: textureDimensions.x, + height: textureDimensions.y, + pixelFormat: context.webgl2 ? PixelFormat.RED : PixelFormat.LUMINANCE, + pixelDatatype: PixelDatatype.FLOAT, + sampler: new Sampler({ + wrapS: TextureWrap.CLAMP_TO_EDGE, + wrapT: TextureWrap.CLAMP_TO_EDGE, + minificationFilter: TextureMinificationFilter.LINEAR, + magnificationFilter: TextureMagnificationFilter.LINEAR, + }), + flipY: false, + }); + + this._signedDistanceTexture = signedDistanceTexture; + } + + this._signedDistanceComputeCommand = createSignedDistanceTextureCommand(this); +}; + +/** + * Called when {@link Viewer} or {@link CesiumWidget} render the scene to + * build the resources for clipping polygons. + *

+ * Do not call this function directly. + *

+ * @private + * @param {FrameState} frameState + */ +ClippingPolygonCollection.prototype.queueCommands = function (frameState) { + if (defined(this._signedDistanceComputeCommand)) { + frameState.commandList.push(this._signedDistanceComputeCommand); + } +}; + +function createSignedDistanceTextureCommand(collection) { + const polygonTexture = collection._polygonsTexture; + const extentsTexture = collection._extentsTexture; + + return new ComputeCommand({ + fragmentShaderSource: PolygonSignedDistanceFS, + outputTexture: collection._signedDistanceTexture, + uniformMap: { + u_polygonsLength: function () { + return collection.length; + }, + u_extentsLength: function () { + return collection.extentsCount; + }, + u_extentsTexture: function () { + return extentsTexture; + }, + u_polygonTexture: function () { + return polygonTexture; + }, + }, + persists: false, + owner: collection, + postExecute: () => { + collection._signedDistanceComputeCommand = undefined; + }, + }); +} + +const scratchRectangleTile = new Rectangle(); +const scratchRectangleIntersection = new Rectangle(); +/** + * Determines the type intersection with the polygons of this ClippingPolygonCollection instance and the specified {@link TileBoundingVolume}. + * @private + * + * @param {object} tileBoundingVolume The volume to determine the intersection with the polygons. + * @param {Ellipsoid} [ellipsoid=WGS84] The ellipsoid on which the bounding volumes are defined. + * @returns {Intersect} The intersection type: {@link Intersect.OUTSIDE} if the entire volume is not clipped, {@link Intersect.INSIDE} + * if the entire volume should be clipped, and {@link Intersect.INTERSECTING} if the volume intersects the polygons and will partially clipped. + */ +ClippingPolygonCollection.prototype.computeIntersectionWithBoundingVolume = function ( + tileBoundingVolume, + ellipsoid +) { + const polygons = this._polygons; + const length = polygons.length; + + let intersection = Intersect.OUTSIDE; + if (this.inverse) { + intersection = Intersect.INSIDE; + } + + for (let i = 0; i < length; ++i) { + const polygon = polygons[i]; + + const polygonBoundingRectangle = polygon.computeRectangle(); + let tileBoundingRectangle = tileBoundingVolume.rectangle; + if ( + !defined(tileBoundingRectangle) && + defined(tileBoundingVolume.boundingVolume?.computeCorners) + ) { + const points = tileBoundingVolume.boundingVolume.computeCorners(); + tileBoundingRectangle = Rectangle.fromCartesianArray( + points, + ellipsoid, + scratchRectangleTile + ); + } + + if (!defined(tileBoundingRectangle)) { + tileBoundingRectangle = Rectangle.fromBoundingSphere( + tileBoundingVolume.boundingSphere, + ellipsoid, + scratchRectangleTile + ); + } + + const result = Rectangle.simpleIntersection( + tileBoundingRectangle, + polygonBoundingRectangle, + scratchRectangleIntersection + ); + + if (defined(result)) { + intersection = Intersect.INTERSECTING; + } + } + + return intersection; +}; + +/** + * Sets the owner for the input ClippingPolygonCollection if there wasn't another owner. + * Destroys the owner's previous ClippingPolygonCollection if setting is successful. + * + * @param {ClippingPolygonCollection} [clippingPolygonsCollection] A ClippingPolygonCollection (or undefined) being attached to an object + * @param {object} owner An Object that should receive the new ClippingPolygonCollection + * @param {string} key The Key for the Object to reference the ClippingPolygonCollection + * @private + */ +ClippingPolygonCollection.setOwner = function ( + clippingPolygonsCollection, + owner, + key +) { + // Don't destroy the ClippingPolygonCollection if it is already owned by newOwner + if (clippingPolygonsCollection === owner[key]) { + return; + } + // Destroy the existing ClippingPolygonCollection, if any + owner[key] = owner[key] && owner[key].destroy(); + if (defined(clippingPolygonsCollection)) { + //>>includeStart('debug', pragmas.debug); + if (defined(clippingPolygonsCollection._owner)) { + throw new DeveloperError( + "ClippingPolygonCollection should only be assigned to one object" + ); + } + //>>includeEnd('debug'); + clippingPolygonsCollection._owner = owner; + owner[key] = clippingPolygonsCollection; + } +}; + +/** + * Function for checking if the context will allow clipping polygons, which require floating point textures. + * + * @param {Scene|object} scene The scene that will contain clipped objects and clipping textures. + * @returns {boolean} true if the context supports clipping polygons. + */ +ClippingPolygonCollection.isSupported = function (scene) { + return scene?.context.webgl2; +}; + +/** + * Function for getting packed texture resolution. + * If the ClippingPolygonCollection hasn't been updated, returns the resolution that will be + * allocated based on the provided needed pixels. + * + * @param {Texture} texture The texture to be packed. + * @param {number} pixelsNeeded The number of pixels needed based on the current polygon count. + * @param {Cartesian2} result A Cartesian2 for the result. + * @returns {Cartesian2} The required resolution. + * @private + */ +ClippingPolygonCollection.getTextureResolution = function ( + texture, + pixelsNeeded, + result +) { + if (defined(texture)) { + result.x = texture.width; + result.y = texture.height; + return result; + } + + const maxSize = ContextLimits.maximumTextureSize; + result.x = Math.min(pixelsNeeded, maxSize); + result.y = Math.ceil(pixelsNeeded / result.x); + + // Allocate twice as much space as needed to avoid frequent texture reallocation. + result.y *= 2; + + return result; +}; + +/** + * Function for getting the clipping collection's signed distance texture resolution. + * If the ClippingPolygonCollection hasn't been updated, returns the resolution that will be + * allocated based on the current settings + * + * @param {ClippingPolygonCollection} clippingPolygonCollection The clipping polygon collection + * @param {Cartesian2} result A Cartesian2 for the result. + * @returns {Cartesian2} The required resolution. + * @private + */ +ClippingPolygonCollection.getClippingDistanceTextureResolution = function ( + clippingPolygonCollection, + result +) { + const texture = clippingPolygonCollection.signedDistanceTexture; + if (defined(texture)) { + result.x = texture.width; + result.y = texture.height; + return result; + } + + result.x = Math.min(ContextLimits.maximumTextureSize, 4096); + result.y = Math.min(ContextLimits.maximumTextureSize, 4096); + + return result; +}; + +/** + * Function for getting the clipping collection's extents texture resolution. + * If the ClippingPolygonCollection hasn't been updated, returns the resolution that will be + * allocated based on the current polygon count. + * + * @param {ClippingPolygonCollection} clippingPolygonCollection The clipping polygon collection + * @param {Cartesian2} result A Cartesian2 for the result. + * @returns {Cartesian2} The required resolution. + * @private + */ +ClippingPolygonCollection.getClippingExtentsTextureResolution = function ( + clippingPolygonCollection, + result +) { + const texture = clippingPolygonCollection.extentsTexture; + if (defined(texture)) { + result.x = texture.width; + result.y = texture.height; + return result; + } + + return ClippingPolygonCollection.getTextureResolution( + texture, + clippingPolygonCollection.pixelsNeededForExtents, + result + ); +}; + +/** + * Returns true if this object was destroyed; otherwise, false. + *

+ * If this object was destroyed, it should not be used; calling any function other than + * isDestroyed will result in a {@link DeveloperError} exception. + * + * @returns {boolean} true if this object was destroyed; otherwise, false. + * + * @see ClippingPolygonCollection#destroy + */ +ClippingPolygonCollection.prototype.isDestroyed = function () { + return false; +}; + +/** + * Destroys the WebGL resources held by this object. Destroying an object allows for deterministic + * release of WebGL resources, instead of relying on the garbage collector to destroy this object. + *

+ * Once an object is destroyed, it should not be used; calling any function other than + * isDestroyed will result in a {@link DeveloperError} exception. Therefore, + * assign the return value (undefined) to the object as done in the example. + * + * @exception {DeveloperError} This object was destroyed, i.e., destroy() was called. + * + * + * @example + * clippingPolygons = clippingPolygons && clippingPolygons.destroy(); + * + * @see ClippingPolygonCollection#isDestroyed + */ +ClippingPolygonCollection.prototype.destroy = function () { + if (defined(this._signedDistanceComputeCommand)) { + this._signedDistanceComputeCommand.canceled = true; + } + + this._polygonsTexture = + this._polygonsTexture && this._polygonsTexture.destroy(); + this._extentsTexture = this._extentsTexture && this._extentsTexture.destroy(); + this._signedDistanceTexture = + this._signedDistanceTexture && this._signedDistanceTexture.destroy(); + return destroyObject(this); +}; + +export default ClippingPolygonCollection; diff --git a/packages/engine/Source/Scene/Globe.js b/packages/engine/Source/Scene/Globe.js index 07d2b83d6316..1a406902e0af 100644 --- a/packages/engine/Source/Scene/Globe.js +++ b/packages/engine/Source/Scene/Globe.js @@ -449,6 +449,20 @@ Object.defineProperties(Globe.prototype, { this._surface.tileProvider.clippingPlanes = value; }, }, + /** + * A property specifying a {@link ClippingPolygonCollection} used to selectively disable rendering inside or outside a list of polygons. + * + * @memberof Globe.prototype + * @type {ClippingPolygonCollection} + */ + clippingPolygons: { + get: function () { + return this._surface.tileProvider.clippingPolygons; + }, + set: function (value) { + this._surface.tileProvider.clippingPolygons = value; + }, + }, /** * A property specifying a {@link Rectangle} used to limit globe rendering to a cartographic area. * Defaults to the maximum extent of cartographic coordinates. diff --git a/packages/engine/Source/Scene/GlobeSurfaceShaderSet.js b/packages/engine/Source/Scene/GlobeSurfaceShaderSet.js index 813f8296131b..a7a48d624b87 100644 --- a/packages/engine/Source/Scene/GlobeSurfaceShaderSet.js +++ b/packages/engine/Source/Scene/GlobeSurfaceShaderSet.js @@ -10,13 +10,15 @@ function GlobeSurfaceShader( flags, material, shaderProgram, - clippingShaderState + clippingShaderState, + clippingPolygonShaderState ) { this.numberOfDayTextures = numberOfDayTextures; this.flags = flags; this.material = material; this.shaderProgram = shaderProgram; this.clippingShaderState = clippingShaderState; + this.clippingPolygonShaderState = clippingPolygonShaderState; } /** @@ -60,6 +62,31 @@ function getPositionMode(sceneMode) { return positionMode; } +function getPolygonClippingFunction(context) { + // return a noop for webgl1 + if (!context.webgl2) { + return `void clipPolygons(highp sampler2D clippingDistance, int regionsLength, vec2 clippingPosition, int regionIndex) { + }`; + } + + return `void clipPolygons(highp sampler2D clippingDistance, int regionsLength, vec2 clippingPosition, int regionIndex) { + czm_clipPolygons(clippingDistance, regionsLength, clippingPosition, regionIndex); + }`; +} + +function getUnpackClippingFunction(context) { + // return a noop for webgl1 + if (!context.webgl2) { + return `vec4 unpackClippingExtents(highp sampler2D extentsTexture, int index) { + return vec4(); + }`; + } + + return `vec4 unpackClippingExtents(highp sampler2D extentsTexture, int index) { + return czm_unpackClippingExtents(extentsTexture, index); + }`; +} + function get2DYPositionFraction(useWebMercatorProjection) { const get2DYPositionFractionGeographicProjection = "float get2DYPositionFraction(vec2 textureCoordinates) { return get2DGeographicYPositionFraction(textureCoordinates); }"; @@ -95,6 +122,8 @@ GlobeSurfaceShaderSet.prototype.getShaderProgram = function (options) { const enableFog = options.enableFog; const enableClippingPlanes = options.enableClippingPlanes; const clippingPlanes = options.clippingPlanes; + const enableClippingPolygons = options.enableClippingPolygons; + const clippingPolygons = options.clippingPolygons; const clippedByBoundaries = options.clippedByBoundaries; const hasImageryLayerCutout = options.hasImageryLayerCutout; const colorCorrect = options.colorCorrect; @@ -152,16 +181,17 @@ GlobeSurfaceShaderSet.prototype.getShaderProgram = function (options) { (quantization << 18) | (applySplit << 19) | (enableClippingPlanes << 20) | - (cartographicLimitRectangleFlag << 21) | - (imageryCutoutFlag << 22) | - (colorCorrect << 23) | - (highlightFillTile << 24) | - (colorToAlpha << 25) | - (hasGeodeticSurfaceNormals << 26) | - (hasExaggeration << 27) | - (showUndergroundColor << 28) | - (translucent << 29) | - (applyDayNightAlpha << 30); + (enableClippingPolygons << 21) | + (cartographicLimitRectangleFlag << 22) | + (imageryCutoutFlag << 23) | + (colorCorrect << 24) | + (highlightFillTile << 25) | + (colorToAlpha << 26) | + (hasGeodeticSurfaceNormals << 27) | + (hasExaggeration << 28) | + (showUndergroundColor << 29) | + (translucent << 30) | + (applyDayNightAlpha << 31); let currentClippingShaderState = 0; if (defined(clippingPlanes) && clippingPlanes.length > 0) { @@ -169,13 +199,23 @@ GlobeSurfaceShaderSet.prototype.getShaderProgram = function (options) { ? clippingPlanes.clippingPlanesState : 0; } + + let currentClippingPolygonsShaderState = 0; + if (defined(clippingPolygons) && clippingPolygons.length > 0) { + currentClippingPolygonsShaderState = enableClippingPolygons + ? clippingPolygons.clippingPolygonsState + : 0; + } + let surfaceShader = surfaceTile.surfaceShader; if ( defined(surfaceShader) && surfaceShader.numberOfDayTextures === numberOfDayTextures && surfaceShader.flags === flags && surfaceShader.material === this.material && - surfaceShader.clippingShaderState === currentClippingShaderState + surfaceShader.clippingShaderState === currentClippingShaderState && + surfaceShader.clippingPolygonShaderState === + currentClippingPolygonsShaderState ) { return surfaceShader.shaderProgram; } @@ -190,16 +230,25 @@ GlobeSurfaceShaderSet.prototype.getShaderProgram = function (options) { if ( !defined(surfaceShader) || surfaceShader.material !== this.material || - surfaceShader.clippingShaderState !== currentClippingShaderState + surfaceShader.clippingShaderState !== currentClippingShaderState || + surfaceShader.clippingPolygonShaderState !== + currentClippingPolygonsShaderState ) { // Cache miss - we've never seen this combination of numberOfDayTextures and flags before. const vs = this.baseVertexShaderSource.clone(); const fs = this.baseFragmentShaderSource.clone(); + // Need to go before GlobeFS if (currentClippingShaderState !== 0) { fs.sources.unshift( getClippingFunction(clippingPlanes, frameState.context) - ); // Need to go before GlobeFS + ); + } + + // Need to go before GlobeFS + if (currentClippingPolygonsShaderState !== 0) { + fs.sources.unshift(getPolygonClippingFunction(frameState.context)); + vs.sources.unshift(getUnpackClippingFunction(frameState.context)); } vs.defines.push(quantizationDefine); @@ -292,6 +341,22 @@ GlobeSurfaceShaderSet.prototype.getShaderProgram = function (options) { fs.defines.push("ENABLE_CLIPPING_PLANES"); } + if (enableClippingPolygons) { + fs.defines.push("ENABLE_CLIPPING_POLYGONS"); + vs.defines.push("ENABLE_CLIPPING_POLYGONS"); + + if (clippingPolygons.inverse) { + fs.defines.push("CLIPPING_INVERSE"); + } + + fs.defines.push( + `CLIPPING_POLYGON_REGIONS_LENGTH ${clippingPolygons.extentsCount}` + ); + vs.defines.push( + `CLIPPING_POLYGON_REGIONS_LENGTH ${clippingPolygons.extentsCount}` + ); + } + if (colorCorrect) { fs.defines.push("COLOR_CORRECT"); } @@ -377,7 +442,8 @@ GlobeSurfaceShaderSet.prototype.getShaderProgram = function (options) { flags, this.material, shader, - currentClippingShaderState + currentClippingShaderState, + currentClippingPolygonsShaderState ); } diff --git a/packages/engine/Source/Scene/GlobeSurfaceTileProvider.js b/packages/engine/Source/Scene/GlobeSurfaceTileProvider.js index a6851b02e469..ab4ec1e9a9e7 100644 --- a/packages/engine/Source/Scene/GlobeSurfaceTileProvider.js +++ b/packages/engine/Source/Scene/GlobeSurfaceTileProvider.js @@ -38,6 +38,7 @@ import RenderState from "../Renderer/RenderState.js"; import VertexArray from "../Renderer/VertexArray.js"; import BlendingState from "./BlendingState.js"; import ClippingPlaneCollection from "./ClippingPlaneCollection.js"; +import ClippingPolygonCollection from "./ClippingPolygonCollection.js"; import DepthFunction from "./DepthFunction.js"; import GlobeSurfaceTile from "./GlobeSurfaceTile.js"; import ImageryLayer from "./ImageryLayer.js"; @@ -170,6 +171,13 @@ function GlobeSurfaceTileProvider(options) { */ this._clippingPlanes = undefined; + /** + * A property specifying a {@link ClippingPolygonCollection} used to selectively disable rendering inside or outside a list of polygons. + * @type {ClippingPolygonCollection} + * @private + */ + this._clippingPolygons = undefined; + /** * A property specifying a {@link Rectangle} used to selectively limit terrain and imagery rendering. * @type {Rectangle} @@ -290,7 +298,7 @@ Object.defineProperties(GlobeSurfaceTileProvider.prototype, { }, }, /** - * The {@link ClippingPlaneCollection} used to selectively disable rendering the tileset. + * The {@link ClippingPlaneCollection} used to selectively disable rendering. * * @type {ClippingPlaneCollection} * @@ -304,6 +312,22 @@ Object.defineProperties(GlobeSurfaceTileProvider.prototype, { ClippingPlaneCollection.setOwner(value, this, "_clippingPlanes"); }, }, + + /** + * The {@link ClippingPolygonCollection} used to selectively disable rendering inside or outside a list of polygons. + * + * @type {ClippingPolygonCollection} + * + * @private + */ + clippingPolygons: { + get: function () { + return this._clippingPolygons; + }, + set: function (value) { + ClippingPolygonCollection.setOwner(value, this, "_clippingPolygons"); + }, + }, }); function sortTileImageryByLayerIndex(a, b) { @@ -391,6 +415,14 @@ GlobeSurfaceTileProvider.prototype.beginUpdate = function (frameState) { if (defined(clippingPlanes) && clippingPlanes.enabled) { clippingPlanes.update(frameState); } + + // update clipping polygons + const clippingPolygons = this._clippingPolygons; + if (defined(clippingPolygons) && clippingPolygons.enabled) { + clippingPolygons.update(frameState); + clippingPolygons.queueCommands(frameState); + } + this._usedDrawCommands = 0; this._hasLoadedTilesThisFrame = false; @@ -657,6 +689,11 @@ function isUndergroundVisible(tileProvider, frameState) { return true; } + const clippingPolygons = tileProvider._clippingPolygons; + if (defined(clippingPolygons) && clippingPolygons.enabled) { + return true; + } + if ( !Rectangle.equals( tileProvider.cartographicLimitRectangle, @@ -775,6 +812,16 @@ GlobeSurfaceTileProvider.prototype.computeTileVisibility = function ( } } + const clippingPolygons = this._clippingPolygons; + if (defined(clippingPolygons) && clippingPolygons.enabled) { + const polygonIntersection = clippingPolygons.computeIntersectionWithBoundingVolume( + tileBoundingRegion + ); + tile.isClipped = polygonIntersection !== Intersect.OUTSIDE; + // Polygon clipping intersections are determined by outer rectangles, therefore we cannot + // preemptively determine if a tile is completely clipped or not here. + } + let visibility; const intersection = cullingVolume.computeVisibility(boundingVolume); @@ -1364,6 +1411,8 @@ GlobeSurfaceTileProvider.prototype.isDestroyed = function () { GlobeSurfaceTileProvider.prototype.destroy = function () { this._tileProvider = this._tileProvider && this._tileProvider.destroy(); this._clippingPlanes = this._clippingPlanes && this._clippingPlanes.destroy(); + this._clippingPolygons = + this._clippingPolygons && this._clippingPolygons.destroy(); this._removeLayerAddedListener = this._removeLayerAddedListener && this._removeLayerAddedListener(); this._removeLayerRemovedListener = @@ -1755,6 +1804,21 @@ function createTileUniformMap(frameState, globeSurfaceTileProvider) { style.alpha = this.properties.clippingPlanesEdgeWidth; return style; }, + u_clippingDistance: function () { + const texture = + globeSurfaceTileProvider._clippingPolygons.clippingTexture; + if (defined(texture)) { + return texture; + } + return frameState.context.defaultTexture; + }, + u_clippingExtents: function () { + const texture = globeSurfaceTileProvider._clippingPolygons.extentsTexture; + if (defined(texture)) { + return texture; + } + return frameState.context.defaultTexture; + }, u_minimumBrightness: function () { return frameState.fog.minimumBrightness; }, @@ -2039,6 +2103,8 @@ const surfaceShaderSetOptionsScratch = { enableFog: undefined, enableClippingPlanes: undefined, clippingPlanes: undefined, + enableClippingPolygons: undefined, + clippingPolygons: undefined, clippedByBoundaries: undefined, hasImageryLayerCutout: undefined, colorCorrect: undefined, @@ -2166,6 +2232,13 @@ function addDrawCommandsForTile(tileProvider, tile, frameState) { ) { --maxTextures; } + if ( + defined(tileProvider.clippingPolygons) && + tileProvider.clippingPolygons.enabled + ) { + --maxTextures; + --maxTextures; + } maxTextures -= globeTranslucencyState.numberOfTextureUniforms; @@ -2726,6 +2799,11 @@ function addDrawCommandsForTile(tileProvider, tile, frameState) { uniformMapProperties.clippingPlanesEdgeWidth = clippingPlanes.edgeWidth; } + // update clipping polygons + const clippingPolygons = tileProvider._clippingPolygons; + const clippingPolygonsEnabled = + defined(clippingPolygons) && clippingPolygons.enabled && tile.isClipped; + surfaceShaderSetOptions.numberOfDayTextures = numberOfDayTextures; surfaceShaderSetOptions.applyBrightness = applyBrightness; surfaceShaderSetOptions.applyContrast = applyContrast; @@ -2738,6 +2816,8 @@ function addDrawCommandsForTile(tileProvider, tile, frameState) { surfaceShaderSetOptions.enableFog = applyFog; surfaceShaderSetOptions.enableClippingPlanes = clippingPlanesEnabled; surfaceShaderSetOptions.clippingPlanes = clippingPlanes; + surfaceShaderSetOptions.enableClippingPolygons = clippingPolygonsEnabled; + surfaceShaderSetOptions.clippingPolygons = clippingPolygons; surfaceShaderSetOptions.hasImageryLayerCutout = applyCutout; surfaceShaderSetOptions.colorCorrect = colorCorrect; surfaceShaderSetOptions.highlightFillTile = highlightFillTile; diff --git a/packages/engine/Source/Scene/Model/Model.js b/packages/engine/Source/Scene/Model/Model.js index 548e25f3478c..5cc4449ef316 100644 --- a/packages/engine/Source/Scene/Model/Model.js +++ b/packages/engine/Source/Scene/Model/Model.js @@ -17,6 +17,7 @@ import Resource from "../../Core/Resource.js"; import RuntimeError from "../../Core/RuntimeError.js"; import Pass from "../../Renderer/Pass.js"; import ClippingPlaneCollection from "../ClippingPlaneCollection.js"; +import ClippingPolygonCollection from "../ClippingPolygonCollection.js"; import ColorBlendMode from "../ColorBlendMode.js"; import GltfLoader from "../GltfLoader.js"; import HeightReference, { @@ -147,6 +148,7 @@ import pickModel from "./pickModel.js"; * @privateParam {boolean} [options.showOutline=true] Whether to display the outline for models using the {@link https://github.com/KhronosGroup/glTF/tree/master/extensions/2.0/Vendor/CESIUM_primitive_outline|CESIUM_primitive_outline} extension. When true, outlines are displayed. When false, outlines are not displayed. * @privateParam {Color} [options.outlineColor=Color.BLACK] The color to use when rendering outlines. * @privateParam {ClippingPlaneCollection} [options.clippingPlanes] The {@link ClippingPlaneCollection} used to selectively disable rendering the model. + * @privateParam {ClippingPolygonCollection} [options.clippingPolygons] The {@link ClippingPolygonCollection} used to selectively disable rendering the model. * @privateParam {Cartesian3} [options.lightColor] The light color when shading the model. When undefined the scene's light color is used instead. * @privateParam {ImageBasedLighting} [options.imageBasedLighting] The properties for managing image-based lighting on this model. * @privateParam {boolean} [options.backFaceCulling=true] Whether to cull back-facing geometry. When true, back face culling is determined by the material's doubleSided property; when false, back face culling is disabled. Back faces are not culled if the model's color is translucent. @@ -370,6 +372,20 @@ function Model(options) { this._clippingPlanesState = 0; // If this value changes, the shaders need to be regenerated. this._clippingPlanesMatrix = Matrix4.clone(Matrix4.IDENTITY); // Derived from reference matrix and the current view matrix + // If the given clipping polygons don't have an owner, make this model its owner. + // Otherwise, the clipping polygons are passed down from a tileset. + const clippingPolygons = options.clippingPolygons; + if (defined(clippingPolygons) && clippingPolygons.owner === undefined) { + ClippingPolygonCollection.setOwner( + clippingPolygons, + this, + "_clippingPolygons" + ); + } else { + this._clippingPolygons = clippingPolygons; + } + this._clippingPolygonsState = 0; // If this value changes, the shaders need to be regenerated. + this._lightColor = Cartesian3.clone(options.lightColor); this._imageBasedLighting = defined(options.imageBasedLighting) @@ -1303,6 +1319,26 @@ Object.defineProperties(Model.prototype, { }, }, + /** + * The {@link ClippingPolygonCollection} used to selectively disable rendering the model. + * + * @memberof Model.prototype + * + * @type {ClippingPolygonCollection} + */ + clippingPolygons: { + get: function () { + return this._clippingPolygons; + }, + set: function (value) { + if (value !== this._clippingPolygons) { + // Handle destroying old clipping polygons, new clipping polygons ownership + ClippingPolygonCollection.setOwner(value, this, "_clippingPolygons"); + this.resetDrawCommands(); + } + }, + }, + /** * The light color when shading the model. When undefined the scene's light color is used instead. *

@@ -1801,6 +1837,7 @@ Model.prototype.update = function (frameState) { updateSilhouette(this, frameState); updateSkipLevelOfDetail(this, frameState); updateClippingPlanes(this, frameState); + updateClippingPolygons(this, frameState); updateSceneMode(this, frameState); updateFog(this, frameState); updateVerticalExaggeration(this, frameState); @@ -1987,6 +2024,24 @@ function updateClippingPlanes(model, frameState) { } } +function updateClippingPolygons(model, frameState) { + // Update the clipping polygon collection / state for this model to detect any changes. + let currentClippingPolygonsState = 0; + if (model.isClippingPolygonsEnabled()) { + if (model._clippingPolygons.owner === model) { + model._clippingPolygons.update(frameState); + model._clippingPolygons.queueCommands(frameState); + } + currentClippingPolygonsState = + model._clippingPolygons.clippingPolygonsState; + } + + if (currentClippingPolygonsState !== model._clippingPolygonsState) { + model.resetDrawCommands(); + model._clippingPolygonsState = currentClippingPolygonsState; + } +} + function updateSceneMode(model, frameState) { if (frameState.mode !== model._sceneMode) { if (model._projectTo2D) { @@ -2537,6 +2592,21 @@ Model.prototype.pick = function ( ); }; +/** + * Gets whether or not clipping polygons are enabled for this model. + * + * @returns {boolean} true if clipping polygons are enabled for this model, false. + * @private + */ +Model.prototype.isClippingPolygonsEnabled = function () { + const clippingPolygons = this._clippingPolygons; + return ( + defined(clippingPolygons) && + clippingPolygons.enabled && + clippingPolygons.length !== 0 + ); +}; + /** * Returns true if this object was destroyed; otherwise, false. *

@@ -2606,6 +2676,17 @@ Model.prototype.destroy = function () { } this._clippingPlanes = undefined; + // Only destroy the ClippingPolygonCollection if this is the owner. + const clippingPolygonCollection = this._clippingPolygons; + if ( + defined(clippingPolygonCollection) && + !clippingPolygonCollection.isDestroyed() && + clippingPolygonCollection.owner === this + ) { + clippingPolygonCollection.destroy(); + } + this._clippingPolygons = undefined; + // Only destroy the ImageBasedLighting if this is the owner. if ( this._shouldDestroyImageBasedLighting && @@ -2689,6 +2770,7 @@ Model.prototype.destroyModelResources = function () { * @param {boolean} [options.showOutline=true] Whether to display the outline for models using the {@link https://github.com/KhronosGroup/glTF/tree/master/extensions/2.0/Vendor/CESIUM_primitive_outline|CESIUM_primitive_outline} extension. When true, outlines are displayed. When false, outlines are not displayed. * @param {Color} [options.outlineColor=Color.BLACK] The color to use when rendering outlines. * @param {ClippingPlaneCollection} [options.clippingPlanes] The {@link ClippingPlaneCollection} used to selectively disable rendering the model. + * @param {ClippingPolygonCollection} [options.clippingPolygons] The {@link ClippingPolygonCollection} used to selectively disable rendering the model. * @param {Cartesian3} [options.lightColor] The light color when shading the model. When undefined the scene's light color is used instead. * @param {ImageBasedLighting} [options.imageBasedLighting] The properties for managing image-based lighting on this model. * @param {boolean} [options.backFaceCulling=true] Whether to cull back-facing geometry. When true, back face culling is determined by the material's doubleSided property; when false, back face culling is disabled. Back faces are not culled if the model's color is translucent. @@ -3051,6 +3133,7 @@ function makeModelOptions(loader, modelType, options) { showOutline: options.showOutline, outlineColor: options.outlineColor, clippingPlanes: options.clippingPlanes, + clippingPolygons: options.clippingPolygons, lightColor: options.lightColor, imageBasedLighting: options.imageBasedLighting, backFaceCulling: options.backFaceCulling, diff --git a/packages/engine/Source/Scene/Model/Model3DTileContent.js b/packages/engine/Source/Scene/Model/Model3DTileContent.js index 20707a03909f..a32206d3dba6 100644 --- a/packages/engine/Source/Scene/Model/Model3DTileContent.js +++ b/packages/engine/Source/Scene/Model/Model3DTileContent.js @@ -255,6 +255,27 @@ Model3DTileContent.prototype.update = function (tileset, frameState) { model._clippingPlanesState = 0; } + // Updating clipping polygons requires more effort because of ownership checks + const tilesetClippingPolygons = tileset.clippingPolygons; + if (defined(tilesetClippingPolygons) && tile.clippingPolygonsDirty) { + // Dereference the clipping polygons from the model if they are irrelevant. + model._clippingPolygons = + tilesetClippingPolygons.enabled && tile._isClippedByPolygon + ? tilesetClippingPolygons + : undefined; + } + + // If the model references a different ClippingPolygonCollection from the tileset, + // update the model to use the new ClippingPolygonCollection. + if ( + defined(tilesetClippingPolygons) && + defined(model._clippingPolygons) && + model._clippingPolygons !== tilesetClippingPolygons + ) { + model._clippingPolygons = tilesetClippingPolygons; + model._clippingPolygonsState = 0; + } + model.update(frameState); if (!this._ready && model.ready) { diff --git a/packages/engine/Source/Scene/Model/ModelClippingPolygonsPipelineStage.js b/packages/engine/Source/Scene/Model/ModelClippingPolygonsPipelineStage.js new file mode 100644 index 000000000000..c9eb597140c7 --- /dev/null +++ b/packages/engine/Source/Scene/Model/ModelClippingPolygonsPipelineStage.js @@ -0,0 +1,92 @@ +import combine from "../../Core/combine.js"; +import ModelClippingPolygonsStageVS from "../../Shaders/Model/ModelClippingPolygonsStageVS.js"; +import ModelClippingPolygonsStageFS from "../../Shaders/Model/ModelClippingPolygonsStageFS.js"; +import ShaderDestination from "../../Renderer/ShaderDestination.js"; + +/** + * The model clipping planes stage is responsible for applying clipping planes to the model. + * + * @namespace ModelClippingPolygonsPipelineStage + * + * @private + */ +const ModelClippingPolygonsPipelineStage = { + name: "ModelClippingPolygonsPipelineStage", // Helps with debugging +}; + +/** + * Process a model for polygon clipping. This modifies the following parts of the render resources: + * + *

+ * + * @param {ModelRenderResources} renderResources The render resources for this model. + * @param {Model} model The model. + * @param {FrameState} frameState The frameState. + * + * @private + */ +ModelClippingPolygonsPipelineStage.process = function ( + renderResources, + model, + frameState +) { + const clippingPolygons = model.clippingPolygons; + const shaderBuilder = renderResources.shaderBuilder; + + shaderBuilder.addDefine( + "ENABLE_CLIPPING_POLYGONS", + undefined, + ShaderDestination.BOTH + ); + + if (clippingPolygons.inverse) { + shaderBuilder.addDefine( + "CLIPPING_INVERSE", + undefined, + ShaderDestination.FRAGMENT + ); + } + + shaderBuilder.addDefine( + "CLIPPING_POLYGON_REGIONS_LENGTH", + clippingPolygons.extentsCount, + ShaderDestination.BOTH + ); + + shaderBuilder.addUniform( + "sampler2D", + "model_clippingDistance", + ShaderDestination.FRAGMENT + ); + + shaderBuilder.addUniform( + "sampler2D", + "model_clippingExtents", + ShaderDestination.VERTEX + ); + + shaderBuilder.addVarying("vec2", "v_clippingPosition"); + shaderBuilder.addVarying("int", "v_regionIndex", "flat"); + shaderBuilder.addVertexLines(ModelClippingPolygonsStageVS); + shaderBuilder.addFragmentLines(ModelClippingPolygonsStageFS); + + const uniformMap = { + model_clippingDistance: function () { + return clippingPolygons.clippingTexture; + }, + model_clippingExtents: function () { + return clippingPolygons.extentsTexture; + }, + }; + + renderResources.uniformMap = combine(uniformMap, renderResources.uniformMap); +}; + +export default ModelClippingPolygonsPipelineStage; diff --git a/packages/engine/Source/Scene/Model/ModelSceneGraph.js b/packages/engine/Source/Scene/Model/ModelSceneGraph.js index 0dacc4c85b5d..b6a4a45b5a67 100644 --- a/packages/engine/Source/Scene/Model/ModelSceneGraph.js +++ b/packages/engine/Source/Scene/Model/ModelSceneGraph.js @@ -14,6 +14,7 @@ import ImageBasedLightingPipelineStage from "./ImageBasedLightingPipelineStage.j import ModelArticulation from "./ModelArticulation.js"; import ModelColorPipelineStage from "./ModelColorPipelineStage.js"; import ModelClippingPlanesPipelineStage from "./ModelClippingPlanesPipelineStage.js"; +import ModelClippingPolygonsPipelineStage from "./ModelClippingPolygonsPipelineStage.js"; import ModelNode from "./ModelNode.js"; import ModelRuntimeNode from "./ModelRuntimeNode.js"; import ModelRuntimePrimitive from "./ModelRuntimePrimitive.js"; @@ -626,6 +627,10 @@ ModelSceneGraph.prototype.configurePipeline = function (frameState) { modelPipelineStages.push(ModelClippingPlanesPipelineStage); } + if (model.isClippingPolygonsEnabled()) { + modelPipelineStages.push(ModelClippingPolygonsPipelineStage); + } + if (model.hasSilhouette(frameState)) { modelPipelineStages.push(ModelSilhouettePipelineStage); } diff --git a/packages/engine/Source/Shaders/Builtin/Functions/clipPolygons.glsl b/packages/engine/Source/Shaders/Builtin/Functions/clipPolygons.glsl new file mode 100644 index 000000000000..b2d391361a80 --- /dev/null +++ b/packages/engine/Source/Shaders/Builtin/Functions/clipPolygons.glsl @@ -0,0 +1,37 @@ +float getSignedDistance(vec2 uv, highp sampler2D clippingDistance) { + float signedDistance = texture(clippingDistance, uv).r; + return (signedDistance - 0.5) * 2.0; +} + +void czm_clipPolygons(highp sampler2D clippingDistance, int extentsLength, vec2 clippingPosition, int regionIndex) { + // Position is completely outside of polygons bounds + vec2 rectUv = clippingPosition; + if (regionIndex < 0 || rectUv.x <= 0.0 || rectUv.y <= 0.0 || rectUv.x >= 1.0 || rectUv.y >= 1.0) { + #ifdef CLIPPING_INVERSE + discard; + #endif + return; + } + + vec2 clippingDistanceTextureDimensions = vec2(textureSize(clippingDistance, 0)); + vec2 sampleOffset = max(1.0 / clippingDistanceTextureDimensions, vec2(0.005)); + float dimension = float(extentsLength); + if (extentsLength > 2) { + dimension = ceil(log2(float(extentsLength))); + } + + vec2 textureOffset = vec2(mod(float(regionIndex), dimension), floor(float(regionIndex) / dimension)) / dimension; + vec2 uv = textureOffset + rectUv / dimension; + + float signedDistance = getSignedDistance(uv, clippingDistance); + + #ifdef CLIPPING_INVERSE + if (signedDistance > 0.0) { + discard; + } + #else + if (signedDistance < 0.0) { + discard; + } + #endif +} diff --git a/packages/engine/Source/Shaders/Builtin/Functions/unpackClippingExtents.glsl b/packages/engine/Source/Shaders/Builtin/Functions/unpackClippingExtents.glsl new file mode 100644 index 000000000000..925a23c812bf --- /dev/null +++ b/packages/engine/Source/Shaders/Builtin/Functions/unpackClippingExtents.glsl @@ -0,0 +1,14 @@ +vec2 getLookupUv(vec2 dimensions, int i) { + int pixY = i / int(dimensions.x); + int pixX = i - (pixY * int(dimensions.x)); + float pixelWidth = 1.0 / dimensions.x; + float pixelHeight = 1.0 / dimensions.y; + float u = (float(pixX) + 0.5) * pixelWidth; // sample from center of pixel + float v = (float(pixY) + 0.5) * pixelHeight; + return vec2(u, v); +} + +vec4 czm_unpackClippingExtents(highp sampler2D extentsTexture, int index) { + vec2 textureDimensions = vec2(textureSize(extentsTexture, 0)); + return texture(extentsTexture, getLookupUv(textureDimensions, index)); +} \ No newline at end of file diff --git a/packages/engine/Source/Shaders/GlobeFS.glsl b/packages/engine/Source/Shaders/GlobeFS.glsl index 74c568efb406..11072943caf6 100644 --- a/packages/engine/Source/Shaders/GlobeFS.glsl +++ b/packages/engine/Source/Shaders/GlobeFS.glsl @@ -77,6 +77,12 @@ uniform mat4 u_clippingPlanesMatrix; uniform vec4 u_clippingPlanesEdgeStyle; #endif +#ifdef ENABLE_CLIPPING_POLYGONS +uniform highp sampler2D u_clippingDistance; +in vec2 v_clippingPosition; +flat in int v_regionIndex; +#endif + #if defined(GROUND_ATMOSPHERE) || defined(FOG) && defined(DYNAMIC_ATMOSPHERE_LIGHTING) && (defined(ENABLE_VERTEX_LIGHTING) || defined(ENABLE_DAYNIGHT_SHADING)) uniform float u_minimumBrightness; #endif @@ -413,6 +419,12 @@ void main() } #endif +#ifdef ENABLE_CLIPPING_POLYGONS + vec2 clippingPosition = v_clippingPosition; + int regionIndex = v_regionIndex; + clipPolygons(u_clippingDistance, CLIPPING_POLYGON_REGIONS_LENGTH, clippingPosition, regionIndex); +#endif + #ifdef HIGHLIGHT_FILL_TILE finalColor = vec4(mix(finalColor.rgb, u_fillHighlightColor.rgb, u_fillHighlightColor.a), finalColor.a); #endif diff --git a/packages/engine/Source/Shaders/GlobeVS.glsl b/packages/engine/Source/Shaders/GlobeVS.glsl index a89153bb2718..13fd7946027f 100644 --- a/packages/engine/Source/Shaders/GlobeVS.glsl +++ b/packages/engine/Source/Shaders/GlobeVS.glsl @@ -46,6 +46,12 @@ out vec3 v_atmosphereMieColor; out float v_atmosphereOpacity; #endif +#ifdef ENABLE_CLIPPING_POLYGONS +uniform highp sampler2D u_clippingExtents; +out vec2 v_clippingPosition; +flat out int v_regionIndex; +#endif + // These functions are generated at runtime. vec4 getPosition(vec3 position, float height, vec2 textureCoordinates); float get2DYPositionFraction(vec2 textureCoordinates); @@ -207,6 +213,32 @@ void main() v_normalEC = czm_normal3D * v_normalMC; #endif +#ifdef ENABLE_CLIPPING_POLYGONS + vec2 sphericalLatLong = czm_approximateSphericalCoordinates(position3DWC); + sphericalLatLong.y = czm_branchFreeTernary(sphericalLatLong.y < czm_pi, sphericalLatLong.y, sphericalLatLong.y - czm_twoPi); + + vec2 minDistance = vec2(czm_infinity); + v_clippingPosition = vec2(czm_infinity); + v_regionIndex = -1; + + for (int regionIndex = 0; regionIndex < CLIPPING_POLYGON_REGIONS_LENGTH; regionIndex++) { + vec4 extents = unpackClippingExtents(u_clippingExtents, regionIndex); + vec2 rectUv = (sphericalLatLong.yx - extents.yx) * extents.wz; + + vec2 clamped = clamp(rectUv, vec2(0.0), vec2(1.0)); + vec2 distance = abs(rectUv - clamped) * extents.wz; + + float threshold = 0.01; + if (minDistance.x > distance.x || minDistance.y > distance.y) { + minDistance = distance; + v_clippingPosition = rectUv; + if (rectUv.x > threshold && rectUv.y > threshold && rectUv.x < 1.0 - threshold && rectUv.y < 1.0 - threshold) { + v_regionIndex = regionIndex; + } + } + } +#endif + #if defined(FOG) || (defined(GROUND_ATMOSPHERE) && !defined(PER_FRAGMENT_GROUND_ATMOSPHERE)) bool dynamicLighting = false; diff --git a/packages/engine/Source/Shaders/Model/GeometryStageVS.glsl b/packages/engine/Source/Shaders/Model/GeometryStageVS.glsl index 58ebdff53291..d220347cfb72 100644 --- a/packages/engine/Source/Shaders/Model/GeometryStageVS.glsl +++ b/packages/engine/Source/Shaders/Model/GeometryStageVS.glsl @@ -16,7 +16,7 @@ vec4 geometryStage(inout ProcessedAttributes attributes, mat4 modelView, mat3 no #endif // Sometimes the custom shader and/or style needs this - #if defined(COMPUTE_POSITION_WC_CUSTOM_SHADER) || defined(COMPUTE_POSITION_WC_STYLE) || defined(COMPUTE_POSITION_WC_ATMOSPHERE) + #if defined(COMPUTE_POSITION_WC_CUSTOM_SHADER) || defined(COMPUTE_POSITION_WC_STYLE) || defined(COMPUTE_POSITION_WC_ATMOSPHERE) || defined(ENABLE_CLIPPING_POLYGONS) // Note that this is a 32-bit position which may result in jitter on small // scales. v_positionWC = (czm_model * vec4(positionMC, 1.0)).xyz; diff --git a/packages/engine/Source/Shaders/Model/ModelClippingPolygonsStageFS.glsl b/packages/engine/Source/Shaders/Model/ModelClippingPolygonsStageFS.glsl new file mode 100644 index 000000000000..8608f90dd7ca --- /dev/null +++ b/packages/engine/Source/Shaders/Model/ModelClippingPolygonsStageFS.glsl @@ -0,0 +1,6 @@ +void modelClippingPolygonsStage() +{ + vec2 clippingPosition = v_clippingPosition; + int regionIndex = v_regionIndex; + czm_clipPolygons(model_clippingDistance, CLIPPING_POLYGON_REGIONS_LENGTH, clippingPosition, regionIndex); +} diff --git a/packages/engine/Source/Shaders/Model/ModelClippingPolygonsStageVS.glsl b/packages/engine/Source/Shaders/Model/ModelClippingPolygonsStageVS.glsl new file mode 100644 index 000000000000..ac90df4de237 --- /dev/null +++ b/packages/engine/Source/Shaders/Model/ModelClippingPolygonsStageVS.glsl @@ -0,0 +1,27 @@ +void modelClippingPolygonsStage(ProcessedAttributes attributes) +{ + vec2 sphericalLatLong = czm_approximateSphericalCoordinates(v_positionWC); + sphericalLatLong.y = czm_branchFreeTernary(sphericalLatLong.y < czm_pi, sphericalLatLong.y, sphericalLatLong.y - czm_twoPi); + + vec2 minDistance = vec2(czm_infinity); + v_regionIndex = -1; + v_clippingPosition = vec2(czm_infinity); + + for (int regionIndex = 0; regionIndex < CLIPPING_POLYGON_REGIONS_LENGTH; regionIndex++) { + vec4 extents = czm_unpackClippingExtents(model_clippingExtents, regionIndex); + vec2 rectUv = (sphericalLatLong.yx - extents.yx) * extents.wz; + + vec2 clamped = clamp(rectUv, vec2(0.0), vec2(1.0)); + vec2 distance = abs(rectUv - clamped) * extents.wz; + + if (minDistance.x > distance.x || minDistance.y > distance.y) { + minDistance = distance; + v_clippingPosition = rectUv; + } + + float threshold = 0.01; + if (rectUv.x > threshold && rectUv.y > threshold && rectUv.x < 1.0 - threshold && rectUv.y < 1.0 - threshold) { + v_regionIndex = regionIndex; + } + } +} diff --git a/packages/engine/Source/Shaders/Model/ModelFS.glsl b/packages/engine/Source/Shaders/Model/ModelFS.glsl index 13d2aec6663e..9756cbe5cda3 100644 --- a/packages/engine/Source/Shaders/Model/ModelFS.glsl +++ b/packages/engine/Source/Shaders/Model/ModelFS.glsl @@ -1,3 +1,5 @@ + +precision highp float; czm_modelMaterial defaultModelMaterial() { czm_modelMaterial material; @@ -75,6 +77,10 @@ void main() modelClippingPlanesStage(color); #endif + #ifdef ENABLE_CLIPPING_POLYGONS + modelClippingPolygonsStage(); + #endif + #if defined(HAS_SILHOUETTE) && defined(HAS_NORMALS) silhouetteStage(color); #endif diff --git a/packages/engine/Source/Shaders/Model/ModelVS.glsl b/packages/engine/Source/Shaders/Model/ModelVS.glsl index 36a65de3e1e3..b95b329f59ff 100644 --- a/packages/engine/Source/Shaders/Model/ModelVS.glsl +++ b/packages/engine/Source/Shaders/Model/ModelVS.glsl @@ -111,6 +111,10 @@ void main() atmosphereStage(attributes); #endif + #ifdef ENABLE_CLIPPING_POLYGONS + modelClippingPolygonsStage(attributes); + #endif + #ifdef HAS_SILHOUETTE silhouetteStage(attributes, positionClip); #endif diff --git a/packages/engine/Source/Shaders/PolygonSignedDistanceFS.glsl b/packages/engine/Source/Shaders/PolygonSignedDistanceFS.glsl new file mode 100644 index 000000000000..c9aea8d4e7ee --- /dev/null +++ b/packages/engine/Source/Shaders/PolygonSignedDistanceFS.glsl @@ -0,0 +1,100 @@ +in vec2 v_textureCoordinates; + +uniform int u_polygonsLength; +uniform int u_extentsLength; +uniform highp sampler2D u_polygonTexture; +uniform highp sampler2D u_extentsTexture; + +int getPolygonIndex(float dimension, vec2 coord) { + vec2 uv = coord.xy * dimension; + return int(floor(uv.y) * dimension + floor(uv.x)); +} + +vec2 getLookupUv(ivec2 dimensions, int i) { + int pixY = i / dimensions.x; + int pixX = i - (pixY * dimensions.x); + float pixelWidth = 1.0 / float(dimensions.x); + float pixelHeight = 1.0 / float(dimensions.y); + float u = (float(pixX) + 0.5) * pixelWidth; // sample from center of pixel + float v = (float(pixY) + 0.5) * pixelHeight; + return vec2(u, v); +} + +vec4 getExtents(int i) { + return texture(u_extentsTexture, getLookupUv(textureSize(u_extentsTexture, 0), i)); +} + +ivec2 getPositionsLengthAndExtentsIndex(int i) { + vec2 uv = getLookupUv(textureSize(u_polygonTexture, 0), i); + vec4 value = texture(u_polygonTexture, uv); + return ivec2(int(value.x), int(value.y)); +} + +vec2 getPolygonPosition(int i) { + vec2 uv = getLookupUv(textureSize(u_polygonTexture, 0), i); + return texture(u_polygonTexture, uv).xy; +} + +vec2 getCoordinates(vec2 textureCoordinates, vec4 extents) { + float latitude = mix(extents.x, extents.x + 1.0 / extents.z, textureCoordinates.y); + float longitude = mix(extents.y, extents.y + 1.0 / extents.w, textureCoordinates.x); + return vec2(latitude, longitude); +} + +void main() { + int lastPolygonIndex = 0; + out_FragColor = vec4(1.0); + + // Get the relevant region of the texture + float dimension = float(u_extentsLength); + if (u_extentsLength > 2) { + dimension = ceil(log2(float(u_extentsLength))); + } + int regionIndex = getPolygonIndex(dimension, v_textureCoordinates); + + for (int polygonIndex = 0; polygonIndex < u_polygonsLength; polygonIndex++) { + ivec2 positionsLengthAndExtents = getPositionsLengthAndExtentsIndex(lastPolygonIndex); + int positionsLength = positionsLengthAndExtents.x; + int polygonExtentsIndex = positionsLengthAndExtents.y; + lastPolygonIndex += 1; + + // Only compute signed distance for the relevant part of the atlas + if (polygonExtentsIndex == regionIndex) { + float clipAmount = czm_infinity; + vec4 extents = getExtents(polygonExtentsIndex); + vec2 textureOffset = vec2(mod(float(polygonExtentsIndex), dimension), floor(float(polygonExtentsIndex) / dimension)) / dimension; + vec2 p = getCoordinates((v_textureCoordinates - textureOffset) * dimension, extents); + float s = 1.0; + + // Check each edge for absolute distance + for (int i = 0, j = positionsLength - 1; i < positionsLength; j = i, i++) { + vec2 a = getPolygonPosition(lastPolygonIndex + i); + vec2 b = getPolygonPosition(lastPolygonIndex + j); + + vec2 ab = b - a; + vec2 pa = p - a; + float t = dot(pa, ab) / dot(ab, ab); + t = clamp(t, 0.0, 1.0); + + vec2 pq = pa - t * ab; + float d = length(pq); + + // Inside / outside computation to determine sign + bvec3 cond = bvec3(p.y >= a.y, + p.y < b.y, + ab.x * pa.y > ab.y * pa.x); + if (all(cond) || all(not(cond))) s = -s; + if (abs(d) < abs(clipAmount)) { + clipAmount = d; + } + } + + // Normalize the range to [0,1] + vec4 result = (s * vec4(clipAmount * length(extents.zw))) / 2.0 + 0.5; + // In the case where we've iterated through multiple polygons, take the minimum + out_FragColor = min(out_FragColor, result); + } + + lastPolygonIndex += positionsLength; + } +} \ No newline at end of file diff --git a/packages/engine/Specs/Core/RectangleSpec.js b/packages/engine/Specs/Core/RectangleSpec.js index 4474c80401eb..c1ddbcc627a9 100644 --- a/packages/engine/Specs/Core/RectangleSpec.js +++ b/packages/engine/Specs/Core/RectangleSpec.js @@ -1,7 +1,11 @@ -import { Cartesian3, Cartographic, Ellipsoid, Rectangle } from "../../index.js"; - -import { Math as CesiumMath } from "../../index.js"; - +import { + BoundingSphere, + Cartesian3, + Cartographic, + Ellipsoid, + Math as CesiumMath, + Rectangle, +} from "../../index.js"; import createPackableSpecs from "../../../../Specs/createPackableSpecs.js"; describe("Core/Rectangle", function () { @@ -1441,6 +1445,69 @@ describe("Core/Rectangle", function () { }).toThrowDeveloperError(); }); + it("fromBoundingSphere works with zero values", function () { + const boundingSphere = new BoundingSphere(); + const result = Rectangle.fromBoundingSphere(boundingSphere); + const expectedRectangle = Rectangle.MAX_VALUE; + expect(result).toEqualEpsilon(expectedRectangle, CesiumMath.EPSILON14); + }); + + it("fromBoundingSphere works with non-zero values", function () { + const boundingSphere = new BoundingSphere( + new Cartesian3(10000000.0, 0.0, 0.0), + 1000.0 + ); + const result = Rectangle.fromBoundingSphere(boundingSphere); + const expectedRectangle = new Rectangle(); + expectedRectangle.west = -0.00009999999966666667; + expectedRectangle.south = -0.00010042880729608389; + expectedRectangle.east = 0.00009999999966666667; + expectedRectangle.north = 0.00010042880729608389; + expect(result).toEqualEpsilon(expectedRectangle, CesiumMath.EPSILON14); + }); + + it("fromBoundingSphere works with bounding sphere centered at the poles", function () { + const boundingSphere = new BoundingSphere( + new Cartesian3(0.0, 0.0, Ellipsoid.WGS84.radii.z), + 1000.0 + ); + const result = Rectangle.fromBoundingSphere(boundingSphere); + const expectedRectangle = new Rectangle(); + expectedRectangle.west = -CesiumMath.PI_OVER_TWO; + expectedRectangle.south = 1.5706400668742968; + expectedRectangle.east = CesiumMath.PI; + expectedRectangle.north = CesiumMath.PI_OVER_TWO; + expect(result).toEqualEpsilon(expectedRectangle, CesiumMath.EPSILON14); + }); + + it("fromBoundingSphere uses result parameter", function () { + const boundingSphere = new BoundingSphere( + new Cartesian3(10000000.0, 0.0, 0.0), + 1000.0 + ); + const result = new Rectangle(); + const returned = Rectangle.fromBoundingSphere( + boundingSphere, + Ellipsoid.WGS84, + result + ); + + const expectedRectangle = new Rectangle(); + expectedRectangle.west = -0.00009999999966666667; + expectedRectangle.south = -0.00010042880729608389; + expectedRectangle.east = 0.00009999999966666667; + expectedRectangle.north = 0.00010042880729608389; + + expect(result).toEqualEpsilon(expectedRectangle, CesiumMath.EPSILON14); + expect(returned).toBe(result); + }); + + it("fromBoundingSphere throws with no bounding sphere", function () { + expect(function () { + Rectangle.fromBoundingSphere(undefined); + }).toThrowDeveloperError(); + }); + const rectangle = new Rectangle(west, south, east, north); const packedInstance = [west, south, east, north]; createPackableSpecs(Rectangle, rectangle, packedInstance); diff --git a/packages/engine/Specs/Scene/Cesium3DTilesetSpec.js b/packages/engine/Specs/Scene/Cesium3DTilesetSpec.js index 8f5d0c3d7a07..3befe8dc6d14 100644 --- a/packages/engine/Specs/Scene/Cesium3DTilesetSpec.js +++ b/packages/engine/Specs/Scene/Cesium3DTilesetSpec.js @@ -16,6 +16,8 @@ import { ClearCommand, ClippingPlane, ClippingPlaneCollection, + ClippingPolygon, + ClippingPolygonCollection, Color, ContextLimits, Credit, @@ -4679,6 +4681,99 @@ describe( ); }); + describe("clippingPolygons", () => { + const positions = Cartesian3.fromRadiansArray([ + -1.3194369277314022, + 0.6988062530900625, + -1.31941, + 0.69879, + -1.3193955980204217, + 0.6988091578771254, + -1.3193931220959367, + 0.698743632490865, + -1.3194358224045408, + 0.6987471965556998, + ]); + let polygon; + + beforeEach(() => { + polygon = new ClippingPolygon({ positions }); + }); + + it("destroys attached ClippingPolygonCollections and ClippingPolygonCollections that have been detached", async function () { + const tileset = await Cesium3DTilesTester.loadTileset( + scene, + tilesetUrl + ); + const collectionA = new ClippingPolygonCollection({ + polygons: [polygon], + }); + expect(collectionA.owner).not.toBeDefined(); + + tileset.clippingPolygons = collectionA; + const collectionB = new ClippingPolygonCollection({ + polygons: [polygon], + }); + + tileset.clippingPolygons = collectionB; + expect(collectionA.isDestroyed()).toBe(true); + + scene.primitives.remove(tileset); + expect(collectionB.isDestroyed()).toBe(true); + }); + + it("throws a DeveloperError when given a ClippingPolygonCollection attached to another tileset", async function () { + const tilesetA = await Cesium3DTilesTester.loadTileset( + scene, + tilesetUrl + ); + + const tilesetB = await Cesium3DTilesTester.loadTileset( + scene, + tilesetUrl + ); + + const collection = new ClippingPolygonCollection({ + polygons: [polygon], + }); + tilesetA.clippingPolygons = collection; + + expect(function () { + tilesetB.clippingPolygons = collection; + }).toThrowDeveloperError(); + }); + + it("cull hidden content", async function () { + if (!scene.context.webgl2) { + return; + } + + const tileset = await Cesium3DTilesTester.loadTileset( + scene, + tilesetUrl + ); + + let visibility = tileset.root.contentVisibility(scene.frameState); + + expect(visibility).not.toBe(Intersect.OUTSIDE); + expect(visibility).not.toBe(Intersect.MASK_OUTSIDE); + + tileset.clippingPolygons = new ClippingPolygonCollection({ + polygons: [polygon], + }); + + visibility = tileset.root.contentVisibility(scene.frameState); + + expect(visibility).not.toBe(Intersect.OUTSIDE); + expect(visibility).not.toBe(Intersect.MASK_OUTSIDE); + + tileset.clippingPolygons.inverse = true; + visibility = tileset.root.contentVisibility(scene.frameState); + + expect(visibility).toBe(Intersect.OUTSIDE); + }); + }); + it("throws if pointCloudShading is set to undefined", function () { return Cesium3DTilesTester.loadTileset(scene, tilesetUrl).then(function ( tileset diff --git a/packages/engine/Specs/Scene/ClippingPlaneCollectionSpec.js b/packages/engine/Specs/Scene/ClippingPlaneCollectionSpec.js index 77003fb0510a..2fb563e969d6 100644 --- a/packages/engine/Specs/Scene/ClippingPlaneCollectionSpec.js +++ b/packages/engine/Specs/Scene/ClippingPlaneCollectionSpec.js @@ -6,6 +6,7 @@ import { Cartesian4, Color, Intersect, + Math as CesiumMath, Matrix4, PixelFormat, Plane, @@ -16,8 +17,6 @@ import { ClippingPlaneCollection, } from "../../index.js"; -import { Math as CesiumMath } from "../../index.js"; - import createScene from "../../../../Specs/createScene.js"; describe("Scene/ClippingPlaneCollection", function () { diff --git a/packages/engine/Specs/Scene/ClippingPlaneSpec.js b/packages/engine/Specs/Scene/ClippingPlaneSpec.js index ecbafaab988f..fae79735ef87 100644 --- a/packages/engine/Specs/Scene/ClippingPlaneSpec.js +++ b/packages/engine/Specs/Scene/ClippingPlaneSpec.js @@ -1,13 +1,12 @@ import { Cartesian3, + Math as CesiumMath, Matrix3, Matrix4, Plane, ClippingPlane, } from "../../index.js"; -import { Math as CesiumMath } from "../../index.js"; - describe("Scene/ClippingPlane", function () { it("constructs", function () { const normal = Cartesian3.UNIT_X; diff --git a/packages/engine/Specs/Scene/ClippingPolygonCollectionSpec.js b/packages/engine/Specs/Scene/ClippingPolygonCollectionSpec.js new file mode 100644 index 000000000000..9ea882abc1f2 --- /dev/null +++ b/packages/engine/Specs/Scene/ClippingPolygonCollectionSpec.js @@ -0,0 +1,540 @@ +import { + BoundingSphere, + Cartesian2, + Cartesian3, + Math as CesiumMath, + ContextLimits, + ClippingPolygon, + ClippingPolygonCollection, + Intersect, + OrientedBoundingBox, + Rectangle, + TileBoundingRegion, + TileBoundingSphere, + TileOrientedBoundingBox, +} from "../../index.js"; + +import createScene from "../../../../Specs/createScene.js"; + +describe("Scene/ClippingPolygonCollection", function () { + const positions = Cartesian3.fromRadiansArray([ + -1.3194369277314022, + 0.6988062530900625, + -1.31941, + 0.69879, + -1.3193955980204217, + 0.6988091578771254, + -1.3193931220959367, + 0.698743632490865, + -1.3194358224045408, + 0.6987471965556998, + ]); + const positionsB = Cartesian3.fromRadiansArray([ + -1.3194369277314022, + 0.6988062530900625, + -1.31941, + 0.69879, + -1.3193931220959367, + 0.698743632490865, + ]); + + it("default constructor", function () { + const polygons = new ClippingPolygonCollection(); + expect(polygons.length).toEqual(0); + expect(polygons.enabled).toBeTrue(); + expect(polygons.inverse).toBeFalse(); + expect(polygons.totalPositions).toBe(0); + }); + + it("gets the length of the list of polygons", function () { + const polygons = new ClippingPolygonCollection(); + expect(polygons.length).toBe(0); + + const polygon = polygons.add(new ClippingPolygon({ positions })); + polygons.add(new ClippingPolygon({ positions })); + + expect(polygons.length).toBe(2); + + polygons.remove(polygon); + + expect(polygons.length).toBe(1); + }); + + it("add adds a polygon to the collection", function () { + const polygons = new ClippingPolygonCollection(); + polygons.add(new ClippingPolygon({ positions })); + + expect(polygons.length).toBe(1); + }); + + it("fires the polygonAdded event when a polygon is added", function () { + const polygons = new ClippingPolygonCollection(); + const spy = jasmine.createSpy(); + polygons.polygonAdded.addEventListener(spy); + + let polygon = polygons.add(new ClippingPolygon({ positions })); + expect(spy).toHaveBeenCalledWith(polygon, 0); + + polygon = polygons.add(new ClippingPolygon({ positions: positionsB })); + expect(spy).toHaveBeenCalledWith(polygon, 1); + }); + + it("gets the polygon at an index", function () { + const polygonA = new ClippingPolygon({ positions }); + const polygonB = new ClippingPolygon({ positions: positionsB }); + const polygons = new ClippingPolygonCollection({ + polygons: [polygonA, polygonB], + }); + + let polygon = polygons.get(0); + expect(polygon).toBe(polygonA); + + polygon = polygons.get(1); + expect(polygon).toBe(polygonB); + }); + + it("contain checks if the collection contains a polygon", function () { + const polygonA = new ClippingPolygon({ positions }); + const polygonB = new ClippingPolygon({ positions: positionsB }); + const polygons = new ClippingPolygonCollection({ + polygons: [polygonA], + }); + + expect(polygons.contains(polygonA)).toBeTrue(); + expect(polygons.contains(polygonB)).toBeFalse(); + }); + + it("remove removes the first occurrence of a polygon", function () { + const polygonA = new ClippingPolygon({ positions }); + const polygonB = new ClippingPolygon({ positions: positionsB }); + const polygons = new ClippingPolygonCollection({ + polygons: [polygonA, polygonB], + }); + + let result = polygons.remove(polygonA); + + expect(polygons.contains(polygonA)).toBeFalse(); + expect(polygons.length).toBe(1); + expect(polygons.get(0)).toEqual(polygonB); + expect(result).toBeTrue(); + + result = polygons.remove(polygonA); + expect(result).toBeFalse(); + }); + + it("remove fires polygonRemoved event", function () { + const polygon = new ClippingPolygon({ positions }); + const polygons = new ClippingPolygonCollection({ + polygons: [polygon], + }); + + const spy = jasmine.createSpy(); + polygons.polygonRemoved.addEventListener(spy); + + polygons.remove(polygon); + expect(spy).toHaveBeenCalledWith(polygon, 0); + }); + + it("removeAll removes all of the polygons in the collection", function () { + const polygonA = new ClippingPolygon({ positions }); + const polygonB = new ClippingPolygon({ positions: positionsB }); + const polygons = new ClippingPolygonCollection({ + polygons: [polygonA, polygonB], + }); + + expect(polygons.length).toEqual(2); + + polygons.removeAll(); + + expect(polygons.length).toBe(0); + }); + + it("removeAll fires polygonRemoved event", function () { + const polygonA = new ClippingPolygon({ positions }); + const polygonB = new ClippingPolygon({ positions: positionsB }); + const polygons = new ClippingPolygonCollection({ + polygons: [polygonA, polygonB], + }); + + const spy = jasmine.createSpy(); + polygons.polygonRemoved.addEventListener(spy); + + polygons.removeAll(); + + expect(spy).toHaveBeenCalledWith(polygonA, 0); + expect(spy).toHaveBeenCalledWith(polygonB, 1); + }); + + it("throws on update if float textures aren't supported", function () { + spyOn(ClippingPolygonCollection, "isSupported").and.returnValue(false); + + const polygons = new ClippingPolygonCollection(); + + const scene = createScene(); + scene.context._textureFloat = false; + + expect(() => { + polygons.update(scene.frameState); + }).toThrowError( + "ClippingPolygonCollections are only supported for WebGL 2." + ); + + scene.destroyForSpecs(); + }); + + it("only creates textures and compute commands when polygons are added", function () { + const scene = createScene(); + if (!scene.context.webgl2) { + scene.destroyForSpecs(); + return; + } + + const polygons = new ClippingPolygonCollection(); + + polygons.update(scene.frameState); + + expect(polygons.extentsTexture).toBeUndefined(); + expect(polygons.clippingTexture).toBeUndefined(); + expect(polygons._polygonsTexture).toBeUndefined(); + expect(polygons._signedDistanceComputeCommand).toBeUndefined(); + + polygons.destroy(); + scene.destroyForSpecs(); + }); + + it("creates textures and compute commands when polygons are added", function () { + const scene = createScene(); + if (!scene.context.webgl2) { + scene.destroyForSpecs(); + return; + } + + const polygon = new ClippingPolygon({ positions }); + const polygons = new ClippingPolygonCollection({ + polygons: [polygon], + }); + + polygons.update(scene.frameState); + + expect(polygons.extentsTexture).toBeDefined(); + expect(polygons.extentsTexture.width).toBeGreaterThan(0); + expect(polygons.extentsTexture.height).toBeGreaterThan(0); + + expect(polygons.clippingTexture).toBeDefined(); + expect(polygons.clippingTexture.width).toBeGreaterThan(0); + expect(polygons.clippingTexture.height).toBeGreaterThan(0); + + expect(polygons._polygonsTexture).toBeDefined(); + expect(polygons._polygonsTexture.width).toBeGreaterThan(0); + expect(polygons._polygonsTexture.height).toBeGreaterThan(0); + + expect(polygons._signedDistanceComputeCommand).toBeDefined(); + + polygons.destroy(); + scene.destroyForSpecs(); + }); + + it("fills texture with packed polygon positions", function () { + const scene = createScene(); + if (!scene.context.webgl2) { + scene.destroyForSpecs(); + return; + } + + const polygon = new ClippingPolygon({ positions }); + const polygons = new ClippingPolygonCollection({ + polygons: [polygon], + }); + + const gl = scene.frameState.context._gl; + const spy = spyOn(gl, "texImage2D").and.callThrough(); + + polygons.update(scene.frameState); + + const args = spy.calls.argsFor(spy.calls.count() - 2); + const arrayBufferView = args[8]; + expect(arrayBufferView).toBeDefined(); + expect(arrayBufferView[0]).toBe(5); // number of positions + expect(arrayBufferView[1]).toBe(0); // extents index + expect(arrayBufferView[2]).toEqualEpsilon( + 0.6969271302223206, + CesiumMath.EPSILON10 + ); // first position in spherical coordinates + expect(arrayBufferView[3]).toEqualEpsilon( + -1.3191630840301514, + CesiumMath.EPSILON10 + ); + expect(arrayBufferView[10]).toEqualEpsilon( + 0.6968677043914795, + CesiumMath.EPSILON10 + ); // last position in spherical coordinates + expect(arrayBufferView[11]).toEqualEpsilon( + -1.3191620111465454, + CesiumMath.EPSILON10 + ); + expect(arrayBufferView[12]).toBe(0); // padding + + polygons.destroy(); + scene.destroyForSpecs(); + }); + + it("fills texture with packed extents", function () { + const scene = createScene(); + if (!scene.context.webgl2) { + scene.destroyForSpecs(); + return; + } + + const polygon = new ClippingPolygon({ positions }); + const polygons = new ClippingPolygonCollection({ + polygons: [polygon], + }); + + const gl = scene.frameState.context._gl; + const spy = spyOn(gl, "texImage2D").and.callThrough(); + + polygons.update(scene.frameState); + + const args = spy.calls.argsFor(spy.calls.count() - 3); // extents are packed after polygon positions + const arrayBufferView = args[8]; + expect(arrayBufferView).toBeDefined(); + expect(arrayBufferView[0]).toEqualEpsilon( + 0.6958641409873962, + CesiumMath.EPSILON10 + ); // south + expect(arrayBufferView[1]).toEqualEpsilon( + -1.3201631307601929, + CesiumMath.EPSILON10 + ); // west + expect(arrayBufferView[2]).toEqualEpsilon( + 484.0434265136719, + CesiumMath.EPSILON10 + ); // 1 / (north - south) + expect(arrayBufferView[3]).toEqualEpsilon( + 489.4261779785156, + CesiumMath.EPSILON10 + ); // 1 / (east - west) + expect(arrayBufferView[4]).toBe(0); // padding + expect(arrayBufferView[5]).toBe(0); // padding + expect(arrayBufferView[6]).toBe(0); // padding + expect(arrayBufferView[7]).toBe(0); // padding + + polygons.destroy(); + scene.destroyForSpecs(); + }); + + it("Combines overlapping extents", function () { + const scene = createScene(); + if (!scene.context.webgl2) { + scene.destroyForSpecs(); + return; + } + + const polygonA = new ClippingPolygon({ positions }); + const polygonB = new ClippingPolygon({ positions: positionsB }); + const polygons = new ClippingPolygonCollection({ + polygons: [polygonA, polygonB], + }); + + const gl = scene.frameState.context._gl; + const spy = spyOn(gl, "texImage2D").and.callThrough(); + + polygons.update(scene.frameState); + + let args = spy.calls.argsFor(spy.calls.count() - 2); + let arrayBufferView = args[8]; + expect(arrayBufferView).toBeDefined(); + expect(arrayBufferView[1]).toBe(0); // polygonA extents index + expect(arrayBufferView[13]).toBe(0); // polygonB extents index + + args = spy.calls.argsFor(spy.calls.count() - 3); // extents are packed after polygon positions + arrayBufferView = args[8]; + expect(arrayBufferView).toBeDefined(); + expect(arrayBufferView[0]).toEqualEpsilon( + 0.6958641409873962, + CesiumMath.EPSILON10 + ); // south + expect(arrayBufferView[1]).toEqualEpsilon( + -1.3201631307601929, + CesiumMath.EPSILON10 + ); // west + expect(arrayBufferView[2]).toEqualEpsilon( + 484.0434265136719, + CesiumMath.EPSILON10 + ); // north - south + expect(arrayBufferView[3]).toEqualEpsilon( + 489.4261779785156, + CesiumMath.EPSILON10 + ); // east - west + expect(arrayBufferView[4]).toBe(0); // padding + expect(arrayBufferView[5]).toBe(0); // padding + expect(arrayBufferView[6]).toBe(0); // padding + expect(arrayBufferView[7]).toBe(0); // padding + + polygons.destroy(); + scene.destroyForSpecs(); + }); + + it("does not perform texture updates if the polygons are unchanged", function () { + const scene = createScene(); + if (!scene.context.webgl2) { + scene.destroyForSpecs(); + return; + } + + const polygon = new ClippingPolygon({ positions }); + const polygons = new ClippingPolygonCollection({ + polygons: [polygon], + }); + + const gl = scene.frameState.context._gl; + const spy = spyOn(gl, "texImage2D").and.callThrough(); + + polygons.update(scene.frameState); + + const currentCount = spy.calls.count(); + + polygons.update(scene.frameState); + expect(spy.calls.count()).toEqual(currentCount); + + polygons.destroy(); + scene.destroyForSpecs(); + }); + + it("provides a function for attaching the ClippingPolygonCollection to objects", function () { + const polygon = new ClippingPolygon({ positions }); + const clippedObject1 = { + polygons: undefined, + }; + const clippedObject2 = { + polygons: undefined, + }; + + const polygons1 = new ClippingPolygonCollection({ + polygons: [polygon], + enabled: false, + }); + + ClippingPolygonCollection.setOwner(polygons1, clippedObject1, "polygons"); + expect(clippedObject1.polygons).toBe(polygons1); + expect(polygons1._owner).toBe(clippedObject1); + + const polygons2 = new ClippingPolygonCollection({ + polygons: [polygon], + enabled: false, + }); + + // Expect detached clipping polygons to be destroyed + ClippingPolygonCollection.setOwner(polygons2, clippedObject1, "polygons"); + expect(polygons1.isDestroyed()).toBe(true); + + // Expect setting the same ClippingPolygonCollection again to not destroy the ClippingPolygonCollection + ClippingPolygonCollection.setOwner(polygons2, clippedObject1, "polygons"); + expect(polygons2.isDestroyed()).toBe(false); + + // Expect failure when attaching one ClippingPolygonCollection to two objects + expect(function () { + ClippingPolygonCollection.setOwner(polygons2, clippedObject2, "polygons"); + }).toThrowDeveloperError(); + }); + + it("getClippingDistanceTextureResolution works before textures are created", function () { + const polygon = new ClippingPolygon({ positions }); + const polygons = new ClippingPolygonCollection({ + polygons: [polygon], + }); + const scene = createScene(); + // Set this to the minimum possible value so texture sizes can be consistently tested + ContextLimits._maximumTextureSize = 64; + + const result = ClippingPolygonCollection.getClippingDistanceTextureResolution( + polygons, + new Cartesian2() + ); + expect(result.x).toBe(64); + expect(result.y).toBe(64); + + polygons.destroy(); + scene.destroyForSpecs(); + }); + + it("getClippingExtentsTextureResolution works before textures are created", function () { + const polygon = new ClippingPolygon({ positions }); + const polygons = new ClippingPolygonCollection({ + polygons: [polygon], + }); + const scene = createScene(); + // Set this to the minimum possible value so texture sizes can be consistently tested + ContextLimits._maximumTextureSize = 64; + + const result = ClippingPolygonCollection.getClippingExtentsTextureResolution( + polygons, + new Cartesian2() + ); + expect(result.x).toBe(1); + expect(result.y).toBe(2); + + polygons.destroy(); + scene.destroyForSpecs(); + }); + + it("computes intersections with bounding volumes", function () { + const polygons = new ClippingPolygonCollection(); + let boundingVolume = new TileBoundingRegion({ + rectangle: Rectangle.fromCartesianArray(positions), + }); + + let intersect = polygons.computeIntersectionWithBoundingVolume( + boundingVolume + ); + expect(intersect).toEqual(Intersect.OUTSIDE); + + polygons.add(new ClippingPolygon({ positions })); + intersect = polygons.computeIntersectionWithBoundingVolume(boundingVolume); + expect(intersect).toEqual(Intersect.INTERSECTING); + + const boundingSphere = BoundingSphere.fromPoints(positions); + boundingVolume = new TileBoundingSphere( + boundingSphere.center, + boundingSphere.radius + ); + intersect = polygons.computeIntersectionWithBoundingVolume(boundingVolume); + expect(intersect).toEqual(Intersect.INTERSECTING); + + const box = OrientedBoundingBox.fromPoints(positions); + boundingVolume = new TileOrientedBoundingBox(box.center, box.halfAxes); + intersect = polygons.computeIntersectionWithBoundingVolume(boundingVolume); + expect(intersect).toEqual(Intersect.INTERSECTING); + }); + + it("computes intersections with bounding volumes when inverse is true", function () { + const polygons = new ClippingPolygonCollection({ + inverse: true, + }); + let boundingVolume = new TileBoundingRegion({ + rectangle: Rectangle.fromCartesianArray(positions), + }); + + let intersect = polygons.computeIntersectionWithBoundingVolume( + boundingVolume + ); + expect(intersect).toEqual(Intersect.INSIDE); + + polygons.add(new ClippingPolygon({ positions })); + intersect = polygons.computeIntersectionWithBoundingVolume(boundingVolume); + expect(intersect).toEqual(Intersect.INTERSECTING); + + const boundingSphere = BoundingSphere.fromPoints(positions); + boundingVolume = new TileBoundingSphere( + boundingSphere.center, + boundingSphere.radius + ); + intersect = polygons.computeIntersectionWithBoundingVolume(boundingVolume); + expect(intersect).toEqual(Intersect.INTERSECTING); + + const box = OrientedBoundingBox.fromPoints(positions); + boundingVolume = new TileOrientedBoundingBox(box.center, box.halfAxes); + intersect = polygons.computeIntersectionWithBoundingVolume(boundingVolume); + expect(intersect).toEqual(Intersect.INTERSECTING); + }); +}); diff --git a/packages/engine/Specs/Scene/ClippingPolygonSpec.js b/packages/engine/Specs/Scene/ClippingPolygonSpec.js new file mode 100644 index 000000000000..2519e35cb006 --- /dev/null +++ b/packages/engine/Specs/Scene/ClippingPolygonSpec.js @@ -0,0 +1,322 @@ +import { + Cartesian3, + ClippingPolygon, + Ellipsoid, + Math as CesiumMath, + Rectangle, +} from "../../index.js"; + +describe("Scene/ClippingPolygon", function () { + it("constructs", function () { + const positions = Cartesian3.fromRadiansArray([ + -1.3194369277314022, + 0.6988062530900625, + -1.31941, + 0.69879, + -1.3193955980204217, + 0.6988091578771254, + -1.3193931220959367, + 0.698743632490865, + -1.3194358224045408, + 0.6987471965556998, + ]); + + const polygon = new ClippingPolygon({ + positions: positions, + }); + + expect(polygon.length).toBe(5); + expect(polygon.positions).toEqual(positions); + expect(polygon.ellipsoid).toEqual(Ellipsoid.WGS84); + }); + + it("throws when constructing polygon with fewer than 3 positions", function () { + expect(() => { + // eslint-disable-next-line no-unused-vars + const polygon = new ClippingPolygon(); + }).toThrowDeveloperError(); + + const positions = Cartesian3.fromRadiansArray([ + -1.3194369277314022, + 0.6988062530900625, + -1.31941, + 0.69879, + ]); + + expect(() => { + // eslint-disable-next-line no-unused-vars + const polygon = new ClippingPolygon({ + positions: positions, + }); + }).toThrowDeveloperError(); + }); + + it("clones", function () { + const positions = Cartesian3.fromRadiansArray([ + -1.3194369277314022, + 0.6988062530900625, + -1.31941, + 0.69879, + -1.3193955980204217, + 0.6988091578771254, + -1.3193931220959367, + 0.698743632490865, + -1.3194358224045408, + 0.6987471965556998, + ]); + + const polygon = new ClippingPolygon({ + ellipsoid: Ellipsoid.MOON, + positions: positions, + }); + let clonedPolygon = ClippingPolygon.clone(polygon); + expect(polygon.positions).toEqual(clonedPolygon.positions); + expect(polygon.positions).not.toBe(clonedPolygon.positions); + expect(polygon.ellipsoid).toEqual(clonedPolygon.ellipsoid); + + const scratchClippingPolygon = new ClippingPolygon({ + positions: [new Cartesian3(), new Cartesian3(), new Cartesian3()], + }); + clonedPolygon = ClippingPolygon.clone(polygon, scratchClippingPolygon); + expect(polygon.positions).toEqual(clonedPolygon.positions); + expect(polygon.positions).not.toBe(clonedPolygon.positions); + expect(polygon.ellipsoid).toEqual(clonedPolygon.ellipsoid); + }); + + it("clone throws without argument", function () { + expect(() => { + ClippingPolygon.clone(undefined); + }).toThrowDeveloperError(); + }); + + it("equals verifies equality", function () { + const positions = Cartesian3.fromRadiansArray([ + -1.3194369277314022, + 0.6988062530900625, + -1.31941, + 0.69879, + -1.3193955980204217, + 0.6988091578771254, + -1.3193931220959367, + 0.698743632490865, + -1.3194358224045408, + 0.6987471965556998, + ]); + + const polygonA = new ClippingPolygon({ + ellipsoid: Ellipsoid.MOON, + positions: positions, + }); + + let polygonB = new ClippingPolygon({ + positions: positions, + }); + + expect(ClippingPolygon.equals(polygonA, polygonB)).toBeFalse(); + + polygonB = new ClippingPolygon({ + ellipsoid: Ellipsoid.MOON, + positions: Cartesian3.fromRadiansArray([ + -1.3194369277314022, + 0.6988062530900625, + -1.31941, + 0.69879, + -1.3193955980204217, + 0.6988091578771254, + -1.3193931220959367, + 0.698743632490865, + -1.3194358224045408, + 0.6987471965556998, + ]), + }); + + expect(ClippingPolygon.equals(polygonA, polygonB)).toBeFalse(); + + polygonB = new ClippingPolygon({ + ellipsoid: Ellipsoid.MOON, + positions: positions, + }); + + expect(ClippingPolygon.equals(polygonA, polygonA)).toBeTrue(); + }); + + it("equals throws without arguments", function () { + const positions = Cartesian3.fromRadiansArray([ + -1.3194369277314022, + 0.6988062530900625, + -1.31941, + 0.69879, + -1.3193955980204217, + 0.6988091578771254, + -1.3193931220959367, + 0.698743632490865, + -1.3194358224045408, + 0.6987471965556998, + ]); + + const polygon = new ClippingPolygon({ + positions: positions, + }); + + expect(() => { + ClippingPolygon.equals(polygon, undefined); + }).toThrowDeveloperError(); + expect(() => { + ClippingPolygon.equals(undefined, polygon); + }).toThrowDeveloperError(); + }); + + it("computeRectangle returns rectangle enclosing the polygon", function () { + const positions = Cartesian3.fromRadiansArray([ + -1.3194369277314022, + 0.6988062530900625, + -1.31941, + 0.69879, + -1.3193955980204217, + 0.6988091578771254, + -1.3193931220959367, + 0.698743632490865, + -1.3194358224045408, + 0.6987471965556998, + ]); + + const polygon = new ClippingPolygon({ + positions: positions, + }); + + const result = polygon.computeRectangle(); + expect(result).toBeInstanceOf(Rectangle); + expect(result.west).toEqualEpsilon( + -1.3194369277314024, + CesiumMath.EPSILON10 + ); + expect(result.south).toEqualEpsilon( + 0.6987436324908647, + CesiumMath.EPSILON10 + ); + expect(result.east).toEqualEpsilon( + -1.3193931220959367, + CesiumMath.EPSILON10 + ); + expect(result.north).toEqualEpsilon( + 0.6988091578771254, + CesiumMath.EPSILON10 + ); + }); + + it("computeRectangle uses result parameter", function () { + const positions = Cartesian3.fromRadiansArray([ + -1.3194369277314022, + 0.6988062530900625, + -1.31941, + 0.69879, + -1.3193955980204217, + 0.6988091578771254, + -1.3193931220959367, + 0.698743632490865, + -1.3194358224045408, + 0.6987471965556998, + ]); + + const polygon = new ClippingPolygon({ + positions: positions, + }); + + const result = new Rectangle(); + const returnedValue = polygon.computeRectangle(result); + expect(returnedValue).toBe(result); + expect(result.west).toEqualEpsilon( + -1.3194369277314024, + CesiumMath.EPSILON10 + ); + expect(result.south).toEqualEpsilon( + 0.6987436324908647, + CesiumMath.EPSILON10 + ); + expect(result.east).toEqualEpsilon( + -1.3193931220959367, + CesiumMath.EPSILON10 + ); + expect(result.north).toEqualEpsilon( + 0.6988091578771254, + CesiumMath.EPSILON10 + ); + }); + + it("computeSphericalExtents returns rectangle enclosing the polygon defined in spherical coordinates", function () { + const positions = Cartesian3.fromRadiansArray([ + -1.3194369277314022, + 0.6988062530900625, + -1.31941, + 0.69879, + -1.3193955980204217, + 0.6988091578771254, + -1.3193931220959367, + 0.698743632490865, + -1.3194358224045408, + 0.6987471965556998, + ]); + + const polygon = new ClippingPolygon({ + positions: positions, + }); + + const result = polygon.computeSphericalExtents(); + expect(result).toBeInstanceOf(Rectangle); + expect(result.west).toEqualEpsilon( + -1.3191630776640944, + CesiumMath.EPSILON10 + ); + expect(result.south).toEqualEpsilon( + 0.6968641167123716, + CesiumMath.EPSILON10 + ); + expect(result.east).toEqualEpsilon( + -1.3191198686316543, + CesiumMath.EPSILON10 + ); + expect(result.north).toEqualEpsilon( + 0.6969300470954187, + CesiumMath.EPSILON10 + ); + }); + + it("computeSphericalExtents uses result parameter", function () { + const positions = Cartesian3.fromRadiansArray([ + -1.3194369277314022, + 0.6988062530900625, + -1.31941, + 0.69879, + -1.3193955980204217, + 0.6988091578771254, + -1.3193931220959367, + 0.698743632490865, + -1.3194358224045408, + 0.6987471965556998, + ]); + + const polygon = new ClippingPolygon({ + positions: positions, + }); + + const result = new Rectangle(); + const returnedValue = polygon.computeSphericalExtents(result); + expect(returnedValue).toBe(result); + expect(result.west).toEqualEpsilon( + -1.3191630776640944, + CesiumMath.EPSILON10 + ); + expect(result.south).toEqualEpsilon( + 0.6968641167123716, + CesiumMath.EPSILON10 + ); + expect(result.east).toEqualEpsilon( + -1.3191198686316543, + CesiumMath.EPSILON10 + ); + expect(result.north).toEqualEpsilon( + 0.6969300470954187, + CesiumMath.EPSILON10 + ); + }); +}); diff --git a/packages/engine/Specs/Scene/GlobeSurfaceTileProviderSpec.js b/packages/engine/Specs/Scene/GlobeSurfaceTileProviderSpec.js index a8ac6133584c..1d14e3bffe09 100644 --- a/packages/engine/Specs/Scene/GlobeSurfaceTileProviderSpec.js +++ b/packages/engine/Specs/Scene/GlobeSurfaceTileProviderSpec.js @@ -19,6 +19,8 @@ import { BlendingState, ClippingPlane, ClippingPlaneCollection, + ClippingPolygon, + ClippingPolygonCollection, Fog, Globe, GlobeSurfaceShaderSet, @@ -1260,6 +1262,198 @@ describe( }).toThrowDeveloperError(); }); + describe("clippingPolygons", () => { + const positions = Cartesian3.fromRadiansArray([ + -1.3194369277314022, + 0.6988062530900625, + -1.31941, + 0.69879, + -1.3193955980204217, + 0.6988091578771254, + -1.3193931220959367, + 0.698743632490865, + -1.3194358224045408, + 0.6987471965556998, + ]); + let polygon; + + beforeEach(() => { + polygon = new ClippingPolygon({ positions }); + }); + + it("selectively disable rendering globe surface", async function () { + if (!scene.context.webgl2) { + return; + } + + expect(scene).toRender([0, 0, 0, 255]); + + switchViewMode( + SceneMode.SCENE3D, + new GeographicProjection(Ellipsoid.WGS84) + ); + + await updateUntilDone(scene.globe); + expect(scene).notToRender([0, 0, 0, 255]); + + let result; + expect(scene).toRenderAndCall(function (rgba) { + result = rgba; + expect(rgba).not.toEqual([0, 0, 0, 255]); + }); + + scene.globe.clippingPolygons = new ClippingPolygonCollection({ + polygons: [polygon], + }); + + expect(scene).toRender(result); + + scene.globe.clippingPolygons.inverse = true; + + expect(scene).not.toRender(result); + + scene.globe.clippingPolygons = undefined; + }); + + it("renders with multiple clipping regions", async function () { + if (!scene.context.webgl2) { + return; + } + + expect(scene).toRender([0, 0, 0, 255]); + + switchViewMode( + SceneMode.SCENE3D, + new GeographicProjection(Ellipsoid.WGS84) + ); + + await updateUntilDone(scene.globe); + expect(scene).notToRender([0, 0, 0, 255]); + + let result; + expect(scene).toRenderAndCall(function (rgba) { + result = rgba; + expect(rgba).not.toEqual([0, 0, 0, 255]); + }); + + const positionsB = Cartesian3.fromDegreesArray([ + 153.033834435422932, + -27.569622925766826, + 153.033836082527984, + -27.569616899897252, + 153.033905701988772, + -27.569628939963906, + 153.033999779170614, + -27.569639093357882, + ]); + + scene.globe.clippingPolygons = new ClippingPolygonCollection({ + polygons: [polygon, new ClippingPolygon({ positions: positionsB })], + }); + + expect(scene).toRender(result); + + scene.globe.clippingPolygons.inverse = true; + + expect(scene).not.toRender(result); + + scene.globe.clippingPolygons = undefined; + }); + + it("Clips tiles when completely inside clipping region", async function () { + if (!scene.context.webgl2) { + return; + } + + const globe = scene.globe; + scene.globe.clippingPolygons = new ClippingPolygonCollection({ + polygons: [polygon], + inverse: true, + }); + + switchViewMode( + SceneMode.SCENE3D, + new GeographicProjection(Ellipsoid.WGS84) + ); + + await updateUntilDone(globe); + const surface = globe._surface; + const tile = surface._levelZeroTiles[0]; + expect(tile.isClipped).toBe(true); + }); + + it("Clips tiles that intersect a clipping region", async function () { + if (!scene.context.webgl2) { + return; + } + + const globe = scene.globe; + scene.globe.clippingPolygons = new ClippingPolygonCollection({ + polygons: [polygon], + }); + + switchViewMode( + SceneMode.SCENE3D, + new GeographicProjection(Ellipsoid.WGS84) + ); + + await updateUntilDone(globe); + const surface = globe._surface; + const tile = surface._levelZeroTiles[1]; + expect(tile.isClipped).toBe(true); + }); + + it("Doesn't clip tiles when completely outside clipping region", async function () { + if (!scene.context.webgl2) { + return; + } + + const globe = scene.globe; + scene.globe.clippingPolygons = new ClippingPolygonCollection({ + polygons: [polygon], + }); + + switchViewMode( + SceneMode.SCENE3D, + new GeographicProjection(Ellipsoid.WGS84) + ); + + await updateUntilDone(globe); + const surface = globe._surface; + const tile = surface._levelZeroTiles[0]; + expect(tile.isClipped).toBe(false); + }); + + it("destroys attached ClippingPolygonCollections that have been detached", function () { + const globe = scene.globe; + const collection = new ClippingPolygonCollection({ + polygons: [polygon], + }); + globe.clippingPolygons = collection; + expect(collection.isDestroyed()).toBe(false); + + globe.clippingPolygons = undefined; + expect(collection.isDestroyed()).toBe(true); + }); + + it("throws a DeveloperError when given a ClippingPolygonCollection attached to a Model", async function () { + const collection = new ClippingPolygonCollection({ + polygons: [polygon], + }); + const model = scene.primitives.add( + await Model.fromGltfAsync({ + url: "./Data/Models/glTF-2.0/BoxTextured/glTF/BoxTextured.gltf", + }) + ); + model.clippingPolygons = collection; + const globe = scene.globe; + + expect(function () { + globe.clippingPolygons = collection; + }).toThrowDeveloperError(); + }); + }); + it("cartographicLimitRectangle selectively enables rendering globe surface", function () { expect(scene).toRender([0, 0, 0, 255]); switchViewMode( diff --git a/packages/engine/Specs/Scene/Model/Model3DTileContentSpec.js b/packages/engine/Specs/Scene/Model/Model3DTileContentSpec.js index 857ba637ed7d..9977725b918f 100644 --- a/packages/engine/Specs/Scene/Model/Model3DTileContentSpec.js +++ b/packages/engine/Specs/Scene/Model/Model3DTileContentSpec.js @@ -8,6 +8,8 @@ import { ClassificationType, ClippingPlane, ClippingPlaneCollection, + ClippingPolygon, + ClippingPolygonCollection, Color, ColorGeometryInstanceAttribute, ContentMetadata, @@ -1646,6 +1648,50 @@ describe( }); }); + describe("clipping polygons", function () { + const positions = Cartesian3.fromRadiansArray([ + centerLongitude + 0.001, + centerLatitude + 0.001, + centerLongitude + 0.001, + centerLatitude - 0.001, + centerLongitude - 0.001, + centerLatitude - 0.001, + centerLongitude - 0.001, + centerLatitude + 0.001, + ]); + let polygon; + beforeEach(function () { + setCamera(centerLongitude, centerLatitude, 15.0); + polygon = new ClippingPolygon({ positions }); + }); + + it("clipping planes selectively disable rendering", async function () { + if (!scene.context.webgl2) { + return; + } + + const tileset = await Cesium3DTilesTester.loadTileset( + scene, + withBatchTableUrl + ); + let color; + expect(scene).toRenderAndCall(function (rgba) { + color = rgba; + }); + + const collection = new ClippingPolygonCollection({ + polygons: [polygon], + }); + tileset.clippingPolygons = collection; + + expect(scene).notToRender(color); + + collection.inverse = true; + + expect(scene).toRender(color); + }); + }); + describe("classification", function () { let globePrimitive; let tilesetPrimitive; diff --git a/packages/engine/Specs/Scene/Model/ModelClippingPolygonsPipelineStageSpec.js b/packages/engine/Specs/Scene/Model/ModelClippingPolygonsPipelineStageSpec.js new file mode 100644 index 000000000000..756d865e8688 --- /dev/null +++ b/packages/engine/Specs/Scene/Model/ModelClippingPolygonsPipelineStageSpec.js @@ -0,0 +1,175 @@ +import { + Cartesian3, + ClippingPolygon, + ClippingPolygonCollection, + ContextLimits, + Model, + ModelClippingPolygonsPipelineStage, + ShaderBuilder, + _shadersModelClippingPolygonsStageFS, + _shadersModelClippingPolygonsStageVS, +} from "../../../index.js"; +import ShaderBuilderTester from "../../../../../Specs/ShaderBuilderTester.js"; +import createContext from "../../../../../Specs/createContext.js"; + +describe("Scene/Model/ModelClippingPolygonsPipelineStage", function () { + const positions = Cartesian3.fromRadiansArray([ + -1.3194369277314022, + 0.6988062530900625, + -1.31941, + 0.69879, + -1.3193931220959367, + 0.698743632490865, + ]); + let polygon, clippingPolygons, context, model; + + beforeEach(function () { + polygon = new ClippingPolygon({ positions }); + clippingPolygons = new ClippingPolygonCollection({ + polygons: [polygon], + }); + clippingPolygons._clippingPolygonsTexture = { + width: 1, + height: 1, + }; + + model = new Model({ + loader: {}, + resource: {}, + }); + + context = createContext(); + // Set this to the minimum possible value so texture sizes can be consistently tested + ContextLimits._maximumTextureSize = 64; + }); + + afterEach(function () { + context.destroyForSpecs(); + }); + + it("configures the render resources for default clipping polygons", function () { + if (!context.webgl2) { + return; + } + + const mockFrameState = { + context: context, + }; + + const renderResources = { + shaderBuilder: new ShaderBuilder(), + uniformMap: {}, + model: model, + }; + const shaderBuilder = renderResources.shaderBuilder; + + model.clippingPolygons = clippingPolygons; + clippingPolygons.update(mockFrameState); + + ModelClippingPolygonsPipelineStage.process( + renderResources, + model, + mockFrameState + ); + + ShaderBuilderTester.expectHasVertexDefines(shaderBuilder, [ + "CLIPPING_POLYGON_REGIONS_LENGTH 1", + "ENABLE_CLIPPING_POLYGONS", + ]); + + ShaderBuilderTester.expectHasFragmentDefines(shaderBuilder, [ + "CLIPPING_POLYGON_REGIONS_LENGTH 1", + "ENABLE_CLIPPING_POLYGONS", + ]); + + ShaderBuilderTester.expectHasVertexUniforms(shaderBuilder, [ + "uniform sampler2D model_clippingExtents;", + ]); + + ShaderBuilderTester.expectHasFragmentUniforms(shaderBuilder, [ + "uniform sampler2D model_clippingDistance;", + ]); + + ShaderBuilderTester.expectHasVaryings(shaderBuilder, [ + "vec2 v_clippingPosition;", + "int v_regionIndex;", + ]); + + const uniformMap = renderResources.uniformMap; + + expect(uniformMap.model_clippingDistance()).toBeDefined(); + expect(uniformMap.model_clippingExtents()).toBeDefined(); + + ShaderBuilderTester.expectVertexLinesEqual(shaderBuilder, [ + _shadersModelClippingPolygonsStageVS, + ]); + + ShaderBuilderTester.expectFragmentLinesEqual(shaderBuilder, [ + _shadersModelClippingPolygonsStageFS, + ]); + }); + + it("configures the render resources for inverse clipping", function () { + if (!context.webgl2) { + return; + } + + const mockFrameState = { + context: context, + }; + + const renderResources = { + shaderBuilder: new ShaderBuilder(), + uniformMap: {}, + model: model, + }; + const shaderBuilder = renderResources.shaderBuilder; + + clippingPolygons.inverse = true; + model.clippingPolygons = clippingPolygons; + clippingPolygons.update(mockFrameState); + + ModelClippingPolygonsPipelineStage.process( + renderResources, + model, + mockFrameState + ); + + ShaderBuilderTester.expectHasVertexDefines(shaderBuilder, [ + "CLIPPING_POLYGON_REGIONS_LENGTH 1", + "ENABLE_CLIPPING_POLYGONS", + ]); + + ShaderBuilderTester.expectHasFragmentDefines(shaderBuilder, [ + "CLIPPING_POLYGON_REGIONS_LENGTH 1", + "ENABLE_CLIPPING_POLYGONS", + "CLIPPING_INVERSE", + ]); + + ShaderBuilderTester.expectHasVertexUniforms(shaderBuilder, [ + "uniform sampler2D model_clippingExtents;", + ]); + + ShaderBuilderTester.expectHasFragmentUniforms(shaderBuilder, [ + "uniform sampler2D model_clippingDistance;", + ]); + + ShaderBuilderTester.expectHasVaryings(shaderBuilder, [ + "vec2 v_clippingPosition;", + "int v_regionIndex;", + ]); + + const uniformMap = renderResources.uniformMap; + + expect(uniformMap.model_clippingDistance()).toBeDefined(); + expect(uniformMap.model_clippingExtents()).toBeDefined(); + + ShaderBuilderTester.expectVertexLinesEqual(shaderBuilder, [ + _shadersModelClippingPolygonsStageVS, + ]); + + ShaderBuilderTester.expectFragmentLinesEqual(shaderBuilder, [ + _shadersModelClippingPolygonsStageFS, + ]); + }); +}); diff --git a/packages/engine/Specs/Scene/Model/ModelSpec.js b/packages/engine/Specs/Scene/Model/ModelSpec.js index 7e9bacb2efed..767b78df552a 100644 --- a/packages/engine/Specs/Scene/Model/ModelSpec.js +++ b/packages/engine/Specs/Scene/Model/ModelSpec.js @@ -8,6 +8,8 @@ import { ClassificationType, ClippingPlane, ClippingPlaneCollection, + ClippingPolygon, + ClippingPolygonCollection, Color, ColorBlendMode, Credit, @@ -4336,6 +4338,172 @@ describe( }); }); + describe("clipping polygons", () => { + let polygon; + beforeEach(() => { + const positions = Cartesian3.fromRadiansArray([ + -CesiumMath.PI_OVER_TWO, + -CesiumMath.PI_OVER_TWO, + CesiumMath.PI_OVER_TWO, + -CesiumMath.PI_OVER_TWO, + CesiumMath.PI_OVER_TWO, + CesiumMath.PI_OVER_TWO, + -CesiumMath.PI_OVER_TWO, + CesiumMath.PI_OVER_TWO, + ]); + polygon = new ClippingPolygon({ positions }); + }); + + it("throws when given clipping planes attached to another model", async function () { + if (!scene.context.webgl2) { + return; + } + + const collection = new ClippingPolygonCollection({ + polygons: [polygon], + }); + const modelA = await loadAndZoomToModelAsync( + { gltf: boxTexturedGlbUrl }, + scene + ); + modelA.clippingPolygons = collection; + + const modelB = await loadAndZoomToModelAsync( + { gltf: boxTexturedGlbUrl }, + scene + ); + + expect(function () { + modelB.clippingPolygons = collection; + }).toThrowDeveloperError(); + }); + + it("selectively hides model regions", async function () { + if (!scene.context.webgl2) { + return; + } + + const collection = new ClippingPolygonCollection({ + polygons: [polygon], + }); + const model = await loadAndZoomToModelAsync( + { gltf: boxTexturedGlbUrl }, + scene + ); + model.clippingPolygons = collection; + verifyRender(model, false); + + model.clippingPolygons = undefined; + verifyRender(model, true); + }); + + it("inverse works", async function () { + if (!scene.context.webgl2) { + return; + } + + const collection = new ClippingPolygonCollection({ + polygons: [polygon], + }); + const model = await loadAndZoomToModelAsync( + { gltf: boxTexturedGlbUrl }, + scene + ); + let modelColor; + verifyRender(model, true); + expect(scene).toRenderAndCall(function (rgba) { + modelColor = rgba; + }); + + model.clippingPolygons = collection; + expect(scene).toRenderAndCall(function (rgba) { + expect(rgba).not.toEqual(modelColor); + }); + + model.clippingPolygons.inverse = true; + expect(scene).toRenderAndCall(function (rgba) { + expect(rgba).toEqual(modelColor); + }); + }); + + it("adding polygon to collection works", async function () { + if (!scene.context.webgl2) { + return; + } + + const collection = new ClippingPolygonCollection(); + const model = await loadAndZoomToModelAsync( + { + gltf: boxTexturedGlbUrl, + }, + scene + ); + model.clippingPolygons = collection; + verifyRender(model, true); + + collection.add(polygon); + verifyRender(model, false); + }); + + it("removing polygon from collection works", async function () { + if (!scene.context.webgl2) { + return; + } + + const collection = new ClippingPolygonCollection({ + polygons: [polygon], + }); + const model = await loadAndZoomToModelAsync( + { + gltf: boxTexturedGlbUrl, + }, + scene + ); + model.clippingPolygons = collection; + verifyRender(model, false); + + model.clippingPolygons.remove(polygon); + verifyRender(model, true); + }); + + it("destroys attached ClippingPolygonCollections", async function () { + const model = await loadAndZoomToModelAsync( + { + gltf: boxTexturedGlbUrl, + }, + scene + ); + const collection = new ClippingPolygonCollection({ + polygons: [polygon], + }); + + model.clippingPolygons = collection; + expect(model.isDestroyed()).toEqual(false); + expect(collection.isDestroyed()).toEqual(false); + + scene.primitives.remove(model); + expect(model.isDestroyed()).toEqual(true); + expect(collection.isDestroyed()).toEqual(true); + }); + + it("destroys ClippingPolygonCollections that are detached", async function () { + const model = await loadAndZoomToModelAsync( + { + gltf: boxTexturedGlbUrl, + }, + scene + ); + const collection = new ClippingPolygonCollection({ + polygons: [polygon], + }); + model.clippingPolygons = collection; + expect(collection.isDestroyed()).toBe(false); + + model.clippingPolygons = undefined; + expect(collection.isDestroyed()).toBe(true); + }); + }); + it("renders with classificationType", function () { return loadAndZoomToModelAsync( {