diff --git a/CHANGES.md b/CHANGES.md index 86aaf51407a..43803b6c9f1 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -8,6 +8,7 @@ - Added `Texture.defaultColor` static property to allow customizing the default placeholder texture color. [#13597](https://github.com/CesiumGS/cesium/pull/13597) - Added support for draping clamped vector tile polylines onto terrain, with screen-space-constant line width and per-feature styling via `Cesium3DTileStyle`. [#13577](https://github.com/CesiumGS/cesium/pull/13577) +- Added support for draping clamped vector tile polygons onto terrain, complementing the polyline support added in [#13577](https://github.com/CesiumGS/cesium/pull/13577). [#13627](https://github.com/CesiumGS/cesium/pull/13627) #### Fixes :wrench: diff --git a/packages/engine/Source/Core/VectorPipeline.js b/packages/engine/Source/Core/VectorPipeline.js index 52523e47d58..d3c0083488a 100644 --- a/packages/engine/Source/Core/VectorPipeline.js +++ b/packages/engine/Source/Core/VectorPipeline.js @@ -3,6 +3,8 @@ import PixelDatatype from "../Renderer/PixelDatatype.js"; import Sampler from "../Renderer/Sampler.js"; import Texture from "../Renderer/Texture.js"; +import BufferPolygon from "../Scene/BufferPolygon.js"; +import BufferPolygonMaterial from "../Scene/BufferPolygonMaterial.js"; import BufferPolyline from "../Scene/BufferPolyline.js"; import BufferPolylineMaterial from "../Scene/BufferPolylineMaterial.js"; import Cartesian2 from "./Cartesian2.js"; @@ -17,6 +19,7 @@ import Rectangle from "./Rectangle.js"; /** @import BufferPrimitive from "../Scene/BufferPrimitive.js"; */ /** @import BufferPrimitiveCollection from "../Scene/BufferPrimitiveCollection.js"; */ +/** @import BufferPolygonCollection from "../Scene/BufferPolygonCollection.js"; */ /** @import BufferPolylineCollection from "../Scene/BufferPolylineCollection.js"; */ /** @import Context from "../Renderer/Context.js"; */ /** @import Ellipsoid from "./Ellipsoid.js"; */ @@ -25,8 +28,14 @@ import Rectangle from "./Rectangle.js"; const GRID_TARGET_SEGMENTS_PER_CELL = 16; const GRID_NEIGHBOR_PADDING_SCALE = 0.35; +// Grid cells expand by this UV epsilon when clipping polygon rings, so every +// fragment mapping to a cell lies strictly inside the cell's clipped loops. +const POLYGON_CELL_CLIP_EPSILON = 1.0e-5; + const scratchPolyline = new BufferPolyline(); const scratchPolylineMaterial = new BufferPolylineMaterial(); +const scratchPolygon = new BufferPolygon(); +const scratchPolygonMaterial = new BufferPolygonMaterial(); const scratchLocalPosition = new Cartesian3(); const scratchWorldPosition = new Cartesian3(); const scratchCartographic = new Cartographic(); @@ -40,11 +49,13 @@ const scratchSegmentEnd = new Cartesian2(); * * @property {boolean} show Whether this vector data should be rendered. * - * Stage 1: Collect vector segments intersecting tile. + * Stage 1: Collect vector segments and polygon rings intersecting tile. * @property {number[][]} [segments] * @property {number[]} [segmentPrimitiveIndices] Index per segment, mapping to material for the segment. - * @property {Uint8Array[]} [widths] Segment widths, by primitive index. - * @property {Uint8Array[]} [colors] Segment colors, by primitive index. + * @property {Float64Array[]} [polygonRings] Tile-clipped polygon rings as flat [x0, y0, x1, y1, ...] in tile UV space. + * @property {number[]} [polygonRingPrimitiveIndices] Index per ring, mapping to material for the ring. + * @property {Uint8Array[]} [widths] Primitive widths, by primitive index. + * @property {Uint8Array[]} [colors] Primitive colors, by primitive index. * @property {number} [primitiveCount] Number of vector primitives in tile. * * Stage 2: Build CPU grid structures. @@ -53,19 +64,27 @@ const scratchSegmentEnd = new Cartesian2(); * @property {number} [segmentTextureHeight] Height of the segment texture, in texels. * @property {Float32Array} [segmentPrimitiveIndicesTexels] Index per segment, mapping to material for the segment. * @property {Uint32Array} [gridCellIndices] Grid header [gridWidth, gridHeight, ...per-cell end offsets]. + * @property {Float32Array} [polygonEdgeTexels] Packed RGBA polygon ring edges (ax, ay, bx, by), clipped per grid cell, -1 filled. + * @property {number} [polygonEdgeTextureWidth] Width of the polygon edge texture, in texels. + * @property {number} [polygonEdgeTextureHeight] Height of the polygon edge texture, in texels. + * @property {Float32Array} [polygonEdgePrimitiveIndicesTexels] Index per polygon edge, mapping to material for the edge. + * @property {Uint32Array} [polygonGridCellIndices] Polygon grid header [gridWidth, gridHeight, ...per-cell end offsets]. * * Stage 3: Build GPU texture resources, uploaded lazily at draw time. * @property {Texture} [segmentTexture] GPU texture of segmentTexels. * @property {Texture} [segmentPrimitiveIndicesTexture] GPU texture of primitive indices per segment. - * @property {Texture} [widthTexture] GPU texture of segment widths, by primitive index. - * @property {Texture} [colorTexture] GPU texture of segment colors, by primitive index. + * @property {Texture} [widthTexture] GPU texture of primitive widths, by primitive index. + * @property {Texture} [colorTexture] GPU texture of primitive colors, by primitive index. * @property {Texture} [gridCellIndicesTexture] GPU texture of gridCellIndices. + * @property {Texture} [polygonEdgeTexture] GPU texture of polygonEdgeTexels. + * @property {Texture} [polygonEdgePrimitiveIndicesTexture] GPU texture of primitive indices per polygon edge. + * @property {Texture} [polygonGridCellIndicesTexture] GPU texture of polygonGridCellIndices. * * @private */ /** - * Snapshot of a polyline collection — projected vertex positions and + * Snapshot of a vector collection — projected vertex positions and * per-primitive material properties — extracted in a single pass so the * collection can be marked clean immediately afterward. * @@ -74,7 +93,7 @@ const scratchSegmentEnd = new Cartesian2(); * @property {number} version State of `collection._version` at time data was last updated. * @property {Rectangle} rectangle * @property {Float64Array} positions Collection positions, projected to the ellipsoid as [lng, lat] in radians. - * @property {Uint8Array} widths Primitive widths, by primitive index. + * @property {Uint8Array} widths Primitive widths, by primitive index. Zero-filled for polygon collections. * @property {Uint8Array} colors Primitive colors, by primitive index. * * @private @@ -152,22 +171,16 @@ class VectorPipeline { * @param {BufferPolylineCollection} collection * @param {VectorCollectionData} collectionData * @param {Rectangle} rectangle - * @param {number} width * @param {VectorTileData} result */ - static packPolylineSegments( - collection, - collectionData, - rectangle, - width, - result, - ) { + static packPolylineSegments(collection, collectionData, rectangle, result) { result.segments ??= []; result.widths ??= []; result.colors ??= []; result.segmentPrimitiveIndices ??= []; result.primitiveCount ??= 0; + const width = rectangle.width; const primitiveCount = collection.primitiveCount; const positions = collectionData.positions; @@ -316,23 +329,267 @@ class VectorPipeline { } /** - * @param {Context} context + * @param {BufferPolygonCollection} collection + * @param {TilingScheme} tilingScheme + * @param {VectorCollectionData} [result] + * @returns {VectorCollectionData} + */ + static packPolygonCollectionData(collection, tilingScheme, result) { + if ( + defined(result) && + collection._dirtyCount === 0 && + collection._version === result.version + ) { + return result; + } + + const primitiveCount = collection.primitiveCount; + const boundingVolume = collection.boundingVolume; + const ellipsoid = tilingScheme.ellipsoid; + + const rectangle = Rectangle.fromBoundingSphere(boundingVolume, ellipsoid); + const positions = _getProjectedPositions(collection, ellipsoid); + + // Widths are unused by polygon fills; zero-filled so polygons share the + // primitive index space (and width/color textures) with polylines. + const widths = new Uint8Array(primitiveCount); + const colors = new Uint8Array(primitiveCount * 4); + + for (let i = 0; i < primitiveCount; i++) { + const polygon = /** @type {BufferPolygon} */ ( + collection.get(i, scratchPolygon) + ); + + // Append materials unconditionally, to simplify indexing and updates. + const polygonMaterial = /** @type {BufferPolygonMaterial} */ ( + polygon.getMaterial(scratchPolygonMaterial) + ); + + colors[i * 4] = Color.floatToByte(polygonMaterial.color.red); + colors[i * 4 + 1] = Color.floatToByte(polygonMaterial.color.green); + colors[i * 4 + 2] = Color.floatToByte(polygonMaterial.color.blue); + colors[i * 4 + 3] = Color.floatToByte(polygonMaterial.color.alpha); + } + + return Object.assign( + result ?? {}, + /** @type {VectorCollectionData} */ ({ + version: collection._version, + rectangle: rectangle, + positions: positions, + widths: widths, + colors: colors, + }), + ); + } + + /** + * Projects all visible polygon rings (outer rings and holes) in a collection + * into tile-local UV space, clipped to the tile, appending each surviving + * ring as a flat [x0, y0, x1, y1, ...] closed loop. + * + * @param {BufferPolygonCollection} collection + * @param {VectorCollectionData} collectionData + * @param {Rectangle} rectangle * @param {VectorTileData} result */ - static packPolylineTextures(context, result) { - result.segmentTexture = new Texture({ - context, - pixelFormat: PixelFormat.RGBA, - pixelDatatype: PixelDatatype.FLOAT, - source: { - width: result.segmentTextureWidth, - height: result.segmentTextureHeight, - arrayBufferView: result.segmentTexels, - }, - sampler: Sampler.NEAREST, - flipY: false, - }); + static packPolygonRings(collection, collectionData, rectangle, result) { + result.polygonRings ??= []; + result.polygonRingPrimitiveIndices ??= []; + result.widths ??= []; + result.colors ??= []; + result.primitiveCount ??= 0; + + const width = rectangle.width; + const primitiveCount = collection.primitiveCount; + const positions = collectionData.positions; + + for (let i = 0; i < primitiveCount; i++) { + const polygon = /** @type {BufferPolygon} */ ( + collection.get(i, scratchPolygon) + ); + if (!polygon.show) { + continue; + } + + const vertexOffset = polygon.vertexOffset; + const vertexCount = polygon.vertexCount; + const holes = polygon.getHoles(); + + // Ring r spans [ringStart, ringEnd): the outer ring, then each hole. + // The shader's even-odd test makes hole rings cancel enclosing coverage. + for (let r = 0; r <= holes.length; r++) { + const ringStart = r === 0 ? 0 : holes[r - 1]; + const ringEnd = r === holes.length ? vertexCount : holes[r]; + const ringVertexCount = ringEnd - ringStart; + if (ringVertexCount < 3) { + continue; + } + + const ringUv = _projectRingToTileUv( + positions, + vertexOffset + ringStart, + ringVertexCount, + rectangle, + width, + ); + + // Clip to the tile plus the polyline clip margin. A ring enclosing + // the whole tile clips to the tile rectangle itself, so tiles + // interior to a large polygon remain covered. + const clippedCount = _clipRingToRect( + ringUv, + ringVertexCount, + -CesiumMath.EPSILON3, + 1.0 + CesiumMath.EPSILON3, + -CesiumMath.EPSILON3, + 1.0 + CesiumMath.EPSILON3, + ); + if (clippedCount < 3) { + continue; + } + + result.polygonRings.push(scratchClipB.slice(0, clippedCount * 2)); + result.polygonRingPrimitiveIndices.push(result.primitiveCount + i); + } + } + + // Append materials unconditionally, to simplify indexing and updates. + result.widths.push(collectionData.widths); + result.colors.push(collectionData.colors); + + result.primitiveCount += primitiveCount; + } + + /** + * Packs UV-space polygon rings into a grid-indexed edge lookup. Each ring + * is clipped to every grid cell it overlaps — unlike polyline segments, + * which are only assigned to cells — so a cell's edges form closed loops + * and a fragment can evaluate even-odd coverage from its own cell alone. + * + * @param {VectorTileData} result + */ + static packPolygonGrid(result) { + const rings = result.polygonRings; + const ringPrimitiveIndices = result.polygonRingPrimitiveIndices; + + let ringEdgeCount = 0; + for (let i = 0; i < rings.length; i++) { + ringEdgeCount += rings[i].length / 2; + } + + const gridSize = Math.max( + 1, + Math.ceil(Math.sqrt(ringEdgeCount / GRID_TARGET_SEGMENTS_PER_CELL)), + ); + + // Per-cell packed edges [ax, ay, bx, by, primitiveIndex, ...]. + /** @type {number[][]} */ + const grid = new Array(gridSize * gridSize); + for (let i = 0; i < grid.length; i++) { + grid[i] = []; + } + + // Rings are appended in primitive order, so each cell's edge list stays + // grouped by primitive; the shader resolves parity per group. + let packedEdgeCount = 0; + for (let r = 0; r < rings.length; r++) { + const ring = rings[r]; + const primitiveIndex = ringPrimitiveIndices[r]; + const ringVertexCount = ring.length / 2; + + let minX = Infinity; + let minY = Infinity; + let maxX = -Infinity; + let maxY = -Infinity; + for (let k = 0; k < ringVertexCount; k++) { + minX = Math.min(minX, ring[k * 2]); + maxX = Math.max(maxX, ring[k * 2]); + minY = Math.min(minY, ring[k * 2 + 1]); + maxY = Math.max(maxY, ring[k * 2 + 1]); + } + + const startCellX = _clampCellIndex(Math.floor(minX * gridSize), gridSize); + const endCellX = _clampCellIndex(Math.floor(maxX * gridSize), gridSize); + const startCellY = _clampCellIndex(Math.floor(minY * gridSize), gridSize); + const endCellY = _clampCellIndex(Math.floor(maxY * gridSize), gridSize); + + for (let y = startCellY; y <= endCellY; y++) { + for (let x = startCellX; x <= endCellX; x++) { + // Expand the clip rectangle slightly so fragments at shared cell + // boundaries fall strictly inside the clipped loops. + const clippedCount = _clipRingToRect( + ring, + ringVertexCount, + x / gridSize - POLYGON_CELL_CLIP_EPSILON, + (x + 1) / gridSize + POLYGON_CELL_CLIP_EPSILON, + y / gridSize - POLYGON_CELL_CLIP_EPSILON, + (y + 1) / gridSize + POLYGON_CELL_CLIP_EPSILON, + ); + if (clippedCount < 3) { + continue; + } + + const cell = grid[y * gridSize + x]; + for (let k = 0; k < clippedCount; k++) { + const k2 = (k + 1) % clippedCount; + const ax = scratchClipB[k * 2]; + const ay = scratchClipB[k * 2 + 1]; + const bx = scratchClipB[k2 * 2]; + const by = scratchClipB[k2 * 2 + 1]; + // Horizontal edges never toggle parity against the shader's + // horizontal ray; clipping produces many along cell bounds. + if (ay === by) { + continue; + } + cell.push(ax, ay, bx, by, primitiveIndex); + packedEdgeCount++; + } + } + } + } + + const [textureWidth, textureHeight] = _nextPowerOfTwoSize(packedEdgeCount); + const capacity = textureWidth * textureHeight; + + const edgeTexels = new Float32Array(capacity * 4).fill(-1.0); + const edgePrimitiveIndicesTexels = new Float32Array(capacity).fill(-1.0); + + const gridCellIndices = new Uint32Array(grid.length + 2); + gridCellIndices[0] = gridSize; + gridCellIndices[1] = gridSize; + + let offset = 0; + for (let i = 0; i < grid.length; i++) { + const cellEdges = grid[i]; + for (let j = 0; j < cellEdges.length; j += 5) { + edgeTexels[offset * 4] = cellEdges[j]; // R + edgeTexels[offset * 4 + 1] = cellEdges[j + 1]; // G + edgeTexels[offset * 4 + 2] = cellEdges[j + 2]; // B + edgeTexels[offset * 4 + 3] = cellEdges[j + 3]; // A + edgePrimitiveIndicesTexels[offset] = cellEdges[j + 4]; + + offset++; + } + gridCellIndices[i + 2] = offset; + } + + result.polygonEdgeTexels = edgeTexels; + result.polygonEdgeTextureWidth = textureWidth; + result.polygonEdgeTextureHeight = textureHeight; + result.polygonEdgePrimitiveIndicesTexels = edgePrimitiveIndicesTexels; + result.polygonGridCellIndices = gridCellIndices; + } + + /** + * Creates the width and color textures indexed by the primitive index space + * shared between polylines and polygons. + * + * @param {Context} context + * @param {VectorTileData} result + */ + static packPrimitiveTextures(context, result) { const [primTextureWidth, primTextureHeight] = _nextPowerOfTwoSize( result.primitiveCount, ); @@ -370,6 +627,25 @@ class VectorPipeline { sampler: Sampler.NEAREST, flipY: false, }); + } + + /** + * @param {Context} context + * @param {VectorTileData} result + */ + static packPolylineTextures(context, result) { + result.segmentTexture = new Texture({ + context, + pixelFormat: PixelFormat.RGBA, + pixelDatatype: PixelDatatype.FLOAT, + source: { + width: result.segmentTextureWidth, + height: result.segmentTextureHeight, + arrayBufferView: result.segmentTexels, + }, + sampler: Sampler.NEAREST, + flipY: false, + }); result.segmentPrimitiveIndicesTexture = new Texture({ context, @@ -384,18 +660,47 @@ class VectorPipeline { flipY: false, }); - result.gridCellIndicesTexture = new Texture({ - context: context, + result.gridCellIndicesTexture = _createGridCellIndicesTexture( + context, + result.gridCellIndices, + ); + } + + /** + * @param {Context} context + * @param {VectorTileData} result + */ + static packPolygonTextures(context, result) { + result.polygonEdgeTexture = new Texture({ + context, + pixelFormat: PixelFormat.RGBA, + pixelDatatype: PixelDatatype.FLOAT, + source: { + width: result.polygonEdgeTextureWidth, + height: result.polygonEdgeTextureHeight, + arrayBufferView: result.polygonEdgeTexels, + }, + sampler: Sampler.NEAREST, + flipY: false, + }); + + result.polygonEdgePrimitiveIndicesTexture = new Texture({ + context, pixelFormat: PixelFormat.RED, pixelDatatype: PixelDatatype.FLOAT, source: { - width: result.gridCellIndices.length, - height: 1, - arrayBufferView: new Float32Array(result.gridCellIndices), + width: result.polygonEdgeTextureWidth, + height: result.polygonEdgeTextureHeight, + arrayBufferView: result.polygonEdgePrimitiveIndicesTexels, }, sampler: Sampler.NEAREST, flipY: false, }); + + result.polygonGridCellIndicesTexture = _createGridCellIndicesTexture( + context, + result.polygonGridCellIndices, + ); } /** @@ -407,6 +712,9 @@ class VectorPipeline { data.colorTexture?.destroy(); data.segmentPrimitiveIndicesTexture?.destroy(); data.gridCellIndicesTexture?.destroy(); + data.polygonEdgeTexture?.destroy(); + data.polygonEdgePrimitiveIndicesTexture?.destroy(); + data.polygonGridCellIndicesTexture?.destroy(); } } @@ -517,6 +825,148 @@ function _clampCellIndex(index, gridSize) { return Math.max(0, Math.min(gridSize - 1, index)); } +// Growable module scratch for ring projection and Sutherland-Hodgman clipping. +let scratchRingUv = new Float64Array(512); +let scratchClipA = new Float64Array(512); +let scratchClipB = new Float64Array(512); + +/** + * Projects a polygon ring ([lng, lat] radian pairs from `positions`) into the + * tile's [0,1]^2 UV domain. Longitudes are sequentially unwrapped so an + * antimeridian-crossing ring stays continuous, then the whole ring is shifted + * to the 2π frame nearest the tile center (matching the polyline projection). + * Returns a module scratch valid until the next call. + * + * Only supporting geographic tiling scheme (UV maps linearly to lon/lat) + * + * @param {Float64Array} positions Projected collection positions ([lng, lat] radians). + * @param {number} vertexStart Index of the ring's first vertex. + * @param {number} vertexCount Number of vertices in the ring. + * @param {Rectangle} rectangle + * @param {number} width + * @returns {Float64Array} Flat [u0, v0, u1, v1, ...] ring coordinates. + * @private + */ +function _projectRingToTileUv( + positions, + vertexStart, + vertexCount, + rectangle, + width, +) { + if (scratchRingUv.length < vertexCount * 2) { + scratchRingUv = new Float64Array(vertexCount * 4); + } + const result = scratchRingUv; + const height = rectangle.north - rectangle.south; + + let previousLon = positions[vertexStart * 2]; + result[0] = previousLon; + result[1] = positions[vertexStart * 2 + 1]; + for (let i = 1; i < vertexCount; i++) { + const lon = + previousLon + + CesiumMath.negativePiToPi(positions[(vertexStart + i) * 2] - previousLon); + result[i * 2] = lon; + result[i * 2 + 1] = positions[(vertexStart + i) * 2 + 1]; + previousLon = lon; + } + + const center = rectangle.west + width * 0.5; + const lon0 = result[0]; + const shift = center + CesiumMath.negativePiToPi(lon0 - center) - lon0; + + for (let i = 0; i < vertexCount; i++) { + result[i * 2] = (result[i * 2] + shift - rectangle.west) / width; + result[i * 2 + 1] = (result[i * 2 + 1] - rectangle.south) / height; + } + + return result; +} + +/** + * Clips a closed ring to an axis-aligned rectangle with Sutherland-Hodgman. + * Returns the clipped vertex count; the clipped ring is left in + * `scratchClipB` (flat [x0, y0, ...]), valid until the next call. Returns 0 + * when the ring is entirely outside. + * + * @param {Float64Array} ring Flat [x0, y0, x1, y1, ...] ring coordinates. + * @param {number} vertexCount + * @param {number} minX + * @param {number} maxX + * @param {number} minY + * @param {number} maxY + * @returns {number} + * @private + */ +function _clipRingToRect(ring, vertexCount, minX, maxX, minY, maxY) { + let input = ring; + let inputCount = vertexCount; + + // axis (0 = x, 1 = y), keep side (1 = keep >= limit, -1 = keep <= limit). + for (let plane = 0; plane < 4; plane++) { + const axis = plane >> 1; + const sign = (plane & 1) === 0 ? 1.0 : -1.0; + const limit = + plane === 0 ? minX : plane === 1 ? maxX : plane === 2 ? minY : maxY; + + // Each input vertex emits at most 2 output vertices. + const requiredLength = inputCount * 4 + 4; + let output = (plane & 1) === 0 ? scratchClipA : scratchClipB; + if (output.length < requiredLength) { + output = new Float64Array(requiredLength * 2); + if ((plane & 1) === 0) { + scratchClipA = output; + } else { + scratchClipB = output; + } + } + + let outputCount = 0; + let prevX = input[(inputCount - 1) * 2]; + let prevY = input[(inputCount - 1) * 2 + 1]; + let prevInside = sign * ((axis === 0 ? prevX : prevY) - limit) >= 0.0; + + for (let i = 0; i < inputCount; i++) { + const x = input[i * 2]; + const y = input[i * 2 + 1]; + const inside = sign * ((axis === 0 ? x : y) - limit) >= 0.0; + + if (inside !== prevInside) { + // Pin the clipped coordinate exactly to the plane so boundary edges + // stay axis-aligned. + const t = + axis === 0 + ? (limit - prevX) / (x - prevX) + : (limit - prevY) / (y - prevY); + output[outputCount * 2] = axis === 0 ? limit : prevX + t * (x - prevX); + output[outputCount * 2 + 1] = + axis === 0 ? prevY + t * (y - prevY) : limit; + outputCount++; + } + if (inside) { + output[outputCount * 2] = x; + output[outputCount * 2 + 1] = y; + outputCount++; + } + + prevX = x; + prevY = y; + prevInside = inside; + } + + if (outputCount === 0) { + return 0; + } + + input = output; + inputCount = outputCount; + } + + // Four passes always end in scratchClipB (ring → A → B → A → B). + return inputCount; +} + /** * @param {number} count * @returns {number[]} @@ -532,6 +982,33 @@ function _nextPowerOfTwoSize(count) { return [width, height]; } +/** + * Creates a grid header texture, wrapped to a 2D power-of-two size so the + * header length is not limited by the maximum texture width. + * + * @param {Context} context + * @param {Uint32Array} gridCellIndices + * @returns {Texture} + * @private + */ +function _createGridCellIndicesTexture(context, gridCellIndices) { + const [width, height] = _nextPowerOfTwoSize(gridCellIndices.length); + const texels = new Float32Array(width * height); + texels.set(gridCellIndices); + return new Texture({ + context: context, + pixelFormat: PixelFormat.RED, + pixelDatatype: PixelDatatype.FLOAT, + source: { + width: width, + height: height, + arrayBufferView: texels, + }, + sampler: Sampler.NEAREST, + flipY: false, + }); +} + /** * Returns the collection's vertex positions, projected to [lng, lat] * coordinates, in radians, on the given ellipsoid. diff --git a/packages/engine/Source/Core/VectorProvider.js b/packages/engine/Source/Core/VectorProvider.js index 258203a52ce..003c65450a6 100644 --- a/packages/engine/Source/Core/VectorProvider.js +++ b/packages/engine/Source/Core/VectorProvider.js @@ -1,5 +1,6 @@ // @ts-check +import BufferPolygonCollection from "../Scene/BufferPolygonCollection.js"; import BufferPolylineCollection from "../Scene/BufferPolylineCollection.js"; import Rectangle from "./Rectangle.js"; import defined from "./defined.js"; @@ -17,6 +18,54 @@ const scratchTileRectangle = new Rectangle(); const scratchCollectionRectangle = new Rectangle(); const scratchIntersectRectangle = new Rectangle(); +/** + * Extracts a collection's snapshot of projected positions and per-primitive + * material properties. + * + * @callback PackCollectionData + * @param {*} collection + * @param {TilingScheme} tilingScheme + * @param {VectorCollectionData} [result] + * @returns {VectorCollectionData} + * @private + */ + +/** + * Packs a collection's primitives into a tile's vector data. + * + * @callback PackTilePrimitives + * @param {*} collection + * @param {VectorCollectionData} collectionData + * @param {Rectangle} rectangle + * @param {VectorTileData} result + * @returns {void} + * @private + */ + +/** + * Packing functions for a collection type. + * + * @typedef {object} CollectionPacker + * @property {PackCollectionData} packCollectionData Extracts the per-collection snapshot. + * @property {PackTilePrimitives} packTilePrimitives Packs the collection's primitives into a tile. + * @private + */ + +/** + * Per-type packing functions, keyed by collection class. + * @type {Map} + * @private + */ +const collectionPackers = new Map(); +collectionPackers.set(BufferPolylineCollection, { + packCollectionData: VectorPipeline.packPolylineCollectionData, + packTilePrimitives: VectorPipeline.packPolylineSegments, +}); +collectionPackers.set(BufferPolygonCollection, { + packCollectionData: VectorPipeline.packPolygonCollectionData, + packTilePrimitives: VectorPipeline.packPolygonRings, +}); + /** * @typedef {object} VectorProviderConstructorOptions * @property {TilingScheme} tilingScheme @@ -172,12 +221,16 @@ class VectorProvider { requestTileData(x, y, level, context) { const tilingScheme = this._tilingScheme; const tileRectangle = tilingScheme.tileXYToRectangle(x, y, level); - const width = Rectangle.computeWidth(tileRectangle); /** @type {VectorTileData} */ const result = { show: true }; for (const collection of this._collections) { + const packer = collectionPackers.get(collection.constructor); + if (!defined(packer)) { + continue; + } + const collectionRectangle = Rectangle.fromBoundingSphere( collection.boundingVolume, tilingScheme.ellipsoid, @@ -194,25 +247,38 @@ class VectorProvider { continue; } - if (collection instanceof BufferPolylineCollection) { - const collectionData = this._getPolylineDataCached(collection); - VectorPipeline.packPolylineSegments( - collection, - collectionData, - tileRectangle, - width, - result, - ); - } + const collectionData = this._getCollectionDataCached( + collection, + packer.packCollectionData, + ); + packer.packTilePrimitives( + collection, + collectionData, + tileRectangle, + result, + ); } - if (!defined(result.segments) || result.segments.length === 0) { + const hasPolylines = defined(result.segments) && result.segments.length > 0; + const hasPolygons = + defined(result.polygonRings) && result.polygonRings.length > 0; + + if (!hasPolylines && !hasPolygons) { result.show = false; return result; } - VectorPipeline.packPolylineGrid(result); - VectorPipeline.packPolylineTextures(context, result); + if (hasPolylines) { + VectorPipeline.packPolylineGrid(result); + VectorPipeline.packPolylineTextures(context, result); + } + + if (hasPolygons) { + VectorPipeline.packPolygonGrid(result); + VectorPipeline.packPolygonTextures(context, result); + } + + VectorPipeline.packPrimitiveTextures(context, result); return result; } @@ -290,11 +356,12 @@ class VectorProvider { * re-extracted when the collection has changed. The collection is marked * clean only after everything has been read back. * - * @param {BufferPolylineCollection} collection + * @param {BufferPrimitiveCollection} collection + * @param {PackCollectionData} packCollectionData * @returns {VectorCollectionData} * @private */ - _getPolylineDataCached(collection) { + _getCollectionDataCached(collection, packCollectionData) { const cache = this._collectionDataCache.get(collection); const dirty = collection._dirtyCount > 0; const outdated = cache?.version !== collection._version; @@ -303,11 +370,7 @@ class VectorProvider { return cache; } - const data = VectorPipeline.packPolylineCollectionData( - collection, - this._tilingScheme, - cache, - ); + const data = packCollectionData(collection, this._tilingScheme, cache); // If dirty, the version increments +1 when marked clean below. data.version = collection._version + (dirty ? 1 : 0); diff --git a/packages/engine/Source/Scene/GlobeSurfaceTileProvider.js b/packages/engine/Source/Scene/GlobeSurfaceTileProvider.js index 538e44e3a91..f97cc62e24d 100644 --- a/packages/engine/Source/Scene/GlobeSurfaceTileProvider.js +++ b/packages/engine/Source/Scene/GlobeSurfaceTileProvider.js @@ -1984,6 +1984,24 @@ function createTileUniformMap(frameState, globeSurfaceTileProvider) { frameState.context.defaultTexture ); }, + u_vectorPolygonEdgeTexture: function () { + return ( + this.properties.vectorPolygonEdgeTexture ?? + frameState.context.defaultTexture + ); + }, + u_vectorPolygonEdgePrimitiveIndicesTexture: function () { + return ( + this.properties.vectorPolygonEdgePrimitiveIndicesTexture ?? + frameState.context.defaultTexture + ); + }, + u_vectorPolygonGridCellIndicesTexture: function () { + return ( + this.properties.vectorPolygonGridCellIndicesTexture ?? + frameState.context.defaultTexture + ); + }, // make a separate object so that changes to the properties are seen on // derived commands that combine another uniform map with this one. @@ -2052,6 +2070,9 @@ function createTileUniformMap(frameState, globeSurfaceTileProvider) { vectorColorTexture: undefined, vectorSegmentPrimitiveIndicesTexture: undefined, vectorGridCellIndicesTexture: undefined, + vectorPolygonEdgeTexture: undefined, + vectorPolygonEdgePrimitiveIndicesTexture: undefined, + vectorPolygonGridCellIndicesTexture: undefined, }, }; @@ -3001,6 +3022,12 @@ function addDrawCommandsForTile(tileProvider, tile, frameState) { vectorData.segmentPrimitiveIndicesTexture; uniformMapProperties.vectorGridCellIndicesTexture = vectorData.gridCellIndicesTexture; + uniformMapProperties.vectorPolygonEdgeTexture = + vectorData.polygonEdgeTexture; + uniformMapProperties.vectorPolygonEdgePrimitiveIndicesTexture = + vectorData.polygonEdgePrimitiveIndicesTexture; + uniformMapProperties.vectorPolygonGridCellIndicesTexture = + vectorData.polygonGridCellIndicesTexture; } // update clipping polygons diff --git a/packages/engine/Source/Shaders/GlobeFS.glsl b/packages/engine/Source/Shaders/GlobeFS.glsl index 5d459fbfec4..773c7acde17 100644 --- a/packages/engine/Source/Shaders/GlobeFS.glsl +++ b/packages/engine/Source/Shaders/GlobeFS.glsl @@ -577,6 +577,7 @@ void main() #endif #ifdef HAS_VECTOR_LAYER + finalColor = vectorPolygonRender(v_textureCoordinates.xy, finalColor); finalColor = vectorPolylineRender(v_textureCoordinates.xy, finalColor); #endif diff --git a/packages/engine/Source/Shaders/VectorCommon.glsl b/packages/engine/Source/Shaders/VectorCommon.glsl index 60eb96260f4..f09c2df61fc 100644 --- a/packages/engine/Source/Shaders/VectorCommon.glsl +++ b/packages/engine/Source/Shaders/VectorCommon.glsl @@ -3,6 +3,9 @@ uniform highp sampler2D u_vectorWidthTexture; uniform highp sampler2D u_vectorColorTexture; uniform highp sampler2D u_vectorSegmentPrimitiveIndicesTexture; uniform highp sampler2D u_vectorGridCellIndicesTexture; +uniform highp sampler2D u_vectorPolygonEdgeTexture; +uniform highp sampler2D u_vectorPolygonEdgePrimitiveIndicesTexture; +uniform highp sampler2D u_vectorPolygonGridCellIndicesTexture; // UV-space offset from the closest point on the segment to p. vec2 vectorOffsetToLine(vec2 p, vec4 line) @@ -32,19 +35,30 @@ ivec2 vectorIndexToUv(int index, ivec2 size) // vector color is alpha-composited over the terrain (no discard). vec4 vectorPolylineRender(vec2 vectorUv, vec4 baseColor) { + // A tile without polylines binds a 1x1 placeholder; a real grid header + // [gridWidth, gridHeight, ...] is at least 3 texels. + ivec2 headerSize = textureSize(u_vectorGridCellIndicesTexture, 0); + if (headerSize.x * headerSize.y < 3) + { + return baseColor; + } + // Inverse UV-per-pixel Jacobian: measures line distance in screen pixels so // width stays constant under anisotropic (oblique) foreshortening. mat2 screenFromUv = inverse(mat2(dFdx(vectorUv), dFdy(vectorUv))); - int gridWidth = int(texelFetch(u_vectorGridCellIndicesTexture, ivec2(0, 0), 0).r); - int gridHeight = int(texelFetch(u_vectorGridCellIndicesTexture, ivec2(1, 0), 0).r); + int gridWidth = int(texelFetch(u_vectorGridCellIndicesTexture, vectorIndexToUv(0, headerSize), 0).r); + int gridHeight = int(texelFetch(u_vectorGridCellIndicesTexture, vectorIndexToUv(1, headerSize), 0).r); int cellX = clamp(int(vectorUv.x * float(gridWidth)), 0, gridWidth - 1); int cellY = clamp(int(vectorUv.y * float(gridHeight)), 0, gridHeight - 1); int cellIndex = cellX + cellY * gridWidth; - int indexEnd = int(texelFetch(u_vectorGridCellIndicesTexture, ivec2(cellIndex + 2, 0), 0).r); + // Cell end offsets follow the two gridWidth/gridHeight texels, so cell + // N's end is at texel N + 2. A cell's start is the previous cell's end + // (texel N + 1); cell 0's start is implicitly 0. + int indexEnd = int(texelFetch(u_vectorGridCellIndicesTexture, vectorIndexToUv(cellIndex + 2, headerSize), 0).r); int indexStart = cellIndex == 0 ? 0 - : int(texelFetch(u_vectorGridCellIndicesTexture, ivec2(cellIndex + 1, 0), 0).r); + : int(texelFetch(u_vectorGridCellIndicesTexture, vectorIndexToUv(cellIndex + 1, headerSize), 0).r); ivec2 segmentTextureSize = textureSize(u_vectorSegmentTexture, 0); ivec2 primitiveTextureSize = textureSize(u_vectorWidthTexture, 0); @@ -71,3 +85,94 @@ vec4 vectorPolylineRender(vec2 vectorUv, vec4 baseColor) return baseColor; } + +// Composites a polygon's fill over baseColor when the pixel is inside it. A +// negative index (empty cell or first iteration) or an outside pixel is a +// no-op. +vec4 vectorCompositePolygonFill(vec4 baseColor, int primitiveIndex, bool inside, ivec2 primitiveTextureSize) +{ + if (!inside || primitiveIndex < 0) + { + return baseColor; + } + + ivec2 primitiveUv = vectorIndexToUv(primitiveIndex, primitiveTextureSize); + vec4 fillColor = texelFetch(u_vectorColorTexture, primitiveUv, 0); + return fillColor * vec4(fillColor.aaa, 1.0) + baseColor * (1.0 - fillColor.a); +} + +// True if a horizontal +x ray from p crosses the edge. The half-open interval +// (> vs <=) counts a ray through a shared vertex exactly once. +bool vectorEdgeCrossesRay(vec4 edge, vec2 p) +{ + if ((edge.y > p.y) == (edge.w > p.y)) + { + return false; + } + + float t = (p.y - edge.y) / (edge.w - edge.y); + float xIntersect = edge.x + t * (edge.z - edge.x); + return p.x < xIntersect; +} + +// Drape clamped vector polygon fills onto the terrain surface. The fragment's +// tile UV picks a grid cell whose edges were clipped to the cell on the CPU, +// forming closed loops, so an even-odd horizontal ray cast within the cell +// decides coverage. Edges arrive grouped by primitive; each covering +// primitive's fill color is alpha-composited in primitive order (no discard). +vec4 vectorPolygonRender(vec2 vectorUv, vec4 baseColor) +{ + // A tile without polygons binds a 1x1 placeholder; a real grid header + // [gridWidth, gridHeight, ...] is at least 3 texels. + ivec2 headerSize = textureSize(u_vectorPolygonGridCellIndicesTexture, 0); + if (headerSize.x * headerSize.y < 3) + { + return baseColor; + } + + int gridWidth = int(texelFetch(u_vectorPolygonGridCellIndicesTexture, vectorIndexToUv(0, headerSize), 0).r); + int gridHeight = int(texelFetch(u_vectorPolygonGridCellIndicesTexture, vectorIndexToUv(1, headerSize), 0).r); + int cellX = clamp(int(vectorUv.x * float(gridWidth)), 0, gridWidth - 1); + int cellY = clamp(int(vectorUv.y * float(gridHeight)), 0, gridHeight - 1); + int cellIndex = cellX + cellY * gridWidth; + + // Cell end offsets follow the two gridWidth/gridHeight texels, so cell + // N's end is at texel N + 2. A cell's start is the previous cell's end + // (texel N + 1); cell 0's start is implicitly 0. + int indexEnd = int(texelFetch(u_vectorPolygonGridCellIndicesTexture, vectorIndexToUv(cellIndex + 2, headerSize), 0).r); + int indexStart = cellIndex == 0 + ? 0 + : int(texelFetch(u_vectorPolygonGridCellIndicesTexture, vectorIndexToUv(cellIndex + 1, headerSize), 0).r); + + ivec2 edgeTextureSize = textureSize(u_vectorPolygonEdgeTexture, 0); + ivec2 primitiveTextureSize = textureSize(u_vectorColorTexture, 0); + + int currentPrimitive = -1; + bool inside = false; + + for (int i = indexStart; i < indexEnd; i++) + { + ivec2 edgeUv = vectorIndexToUv(i, edgeTextureSize); + vec4 edge = texelFetch(u_vectorPolygonEdgeTexture, edgeUv, 0); + int primitiveIndex = int(texelFetch(u_vectorPolygonEdgePrimitiveIndicesTexture, edgeUv, 0).r); + + // A new primitive means the previous group is complete: composite it, + // then start counting the new one fresh. + if (primitiveIndex != currentPrimitive) + { + baseColor = vectorCompositePolygonFill(baseColor, currentPrimitive, inside, primitiveTextureSize); + currentPrimitive = primitiveIndex; + inside = false; + } + + if (vectorEdgeCrossesRay(edge, vectorUv)) + { + inside = !inside; + } + } + + // The last primitive group has no trailing edge to trigger its composite. + baseColor = vectorCompositePolygonFill(baseColor, currentPrimitive, inside, primitiveTextureSize); + + return baseColor; +} diff --git a/packages/engine/Specs/Core/VectorProviderSpec.js b/packages/engine/Specs/Core/VectorProviderSpec.js index c58b7af7b2e..8449b2a66f8 100644 --- a/packages/engine/Specs/Core/VectorProviderSpec.js +++ b/packages/engine/Specs/Core/VectorProviderSpec.js @@ -1,5 +1,7 @@ import { BoundingSphere, + BufferPolygon, + BufferPolygonCollection, BufferPolyline, BufferPolylineCollection, Cartesian3, @@ -177,4 +179,178 @@ describe("Core/VectorProvider", function () { provider.remove(collection); expect(provider._dirtyRectangles.length).toBe(1); }); + + // A quad around the polyline midpoint (lon -100 to -90, lat 35 to 45), + // with a hole in its middle (lon -97 to -93, lat 38 to 42). + function createPolygonCollection(options) { + const collection = new BufferPolygonCollection({ + primitiveCountMax: 1, + vertexCountMax: 8, + holeCountMax: 1, + triangleCountMax: 8, + heightReference: HeightReference.CLAMP_TO_TERRAIN, + }); + const positions = new Float64Array(24); + Cartesian3.pack(Cartesian3.fromDegrees(-100.0, 35.0), positions, 0); + Cartesian3.pack(Cartesian3.fromDegrees(-90.0, 35.0), positions, 3); + Cartesian3.pack(Cartesian3.fromDegrees(-90.0, 45.0), positions, 6); + Cartesian3.pack(Cartesian3.fromDegrees(-100.0, 45.0), positions, 9); + Cartesian3.pack(Cartesian3.fromDegrees(-97.0, 38.0), positions, 12); + Cartesian3.pack(Cartesian3.fromDegrees(-93.0, 38.0), positions, 15); + Cartesian3.pack(Cartesian3.fromDegrees(-93.0, 42.0), positions, 18); + Cartesian3.pack(Cartesian3.fromDegrees(-97.0, 42.0), positions, 21); + const holes = options?.withHole ? new Uint32Array([4]) : undefined; + const vertexCount = options?.withHole ? 8 : 4; + collection.add( + { + positions: positions.subarray(0, vertexCount * 3), + holes: holes, + }, + new BufferPolygon(), + ); + return collection; + } + + it("returns packed polygon lookup data for a tile overlapping a polygon", function () { + const provider = new VectorProvider({ tilingScheme }); + provider.add(createPolygonCollection()); + + const xy = tilingScheme.positionToTileXY(lineMidpoint, level); + const data = provider.requestTileData(xy.x, xy.y, level, context); + + expect(data.show).toBe(true); + expect(data.polygonEdgeTexels).toBeInstanceOf(Float32Array); + expect(data.polygonGridCellIndices).toBeInstanceOf(Uint32Array); + expect(data.colors.length).toBeGreaterThan(0); + + // Grid header: [gridWidth, gridHeight, ...per-cell end offsets]. + const gridWidth = data.polygonGridCellIndices[0]; + const gridHeight = data.polygonGridCellIndices[1]; + expect(gridWidth).toBeGreaterThan(0); + expect(gridHeight).toBeGreaterThan(0); + expect(data.polygonGridCellIndices.length).toBe(gridWidth * gridHeight + 2); + + // At least one real edge texel was packed (fill value is -1). + let packedCount = 0; + for (let i = 0; i < data.polygonEdgeTexels.length; i++) { + if (data.polygonEdgeTexels[i] >= 0.0) { + packedCount++; + } + } + expect(packedCount).toBeGreaterThan(0); + + // No polyline data was packed. + expect(data.segmentTexture).toBeUndefined(); + + // Every cell's edges must balance to even parity along any horizontal + // line: count crossings for a probe through the cell center. + const cellCount = gridWidth * gridHeight; + for (let cell = 0; cell < cellCount; cell++) { + const start = cell === 0 ? 0 : data.polygonGridCellIndices[cell + 1]; + const end = data.polygonGridCellIndices[cell + 2]; + const cellY = Math.floor(cell / gridWidth); + const probeY = (cellY + 0.5) / gridHeight; + let crossings = 0; + for (let e = start; e < end; e++) { + const ay = data.polygonEdgeTexels[e * 4 + 1]; + const by = data.polygonEdgeTexels[e * 4 + 3]; + if (ay > probeY !== by > probeY) { + crossings++; + } + } + expect(crossings % 2).toBe(0); + } + }); + + // Even-odd ray cast against the packed edges of the cell containing + // (uvX, uvY): returns the number of +x crossings. + function countRayCrossings(data, uvX, uvY) { + const gridWidth = data.polygonGridCellIndices[0]; + const gridHeight = data.polygonGridCellIndices[1]; + const cellX = Math.min(Math.floor(uvX * gridWidth), gridWidth - 1); + const cellY = Math.min(Math.floor(uvY * gridHeight), gridHeight - 1); + const cell = cellX + cellY * gridWidth; + const start = cell === 0 ? 0 : data.polygonGridCellIndices[cell + 1]; + const end = data.polygonGridCellIndices[cell + 2]; + + let crossings = 0; + for (let e = start; e < end; e++) { + const ax = data.polygonEdgeTexels[e * 4]; + const ay = data.polygonEdgeTexels[e * 4 + 1]; + const bx = data.polygonEdgeTexels[e * 4 + 2]; + const by = data.polygonEdgeTexels[e * 4 + 3]; + if (ay > uvY !== by > uvY) { + const t = (uvY - ay) / (by - ay); + if (uvX < ax + t * (bx - ax)) { + crossings++; + } + } + } + return crossings; + } + + it("packs hole rings so interior fragments resolve to even parity", function () { + const provider = new VectorProvider({ tilingScheme }); + provider.add(createPolygonCollection({ withHole: true })); + + const xy = tilingScheme.positionToTileXY(lineMidpoint, level); + const data = provider.requestTileData(xy.x, xy.y, level, context); + expect(data.show).toBe(true); + + const tileRectangle = tilingScheme.tileXYToRectangle(xy.x, xy.y, level); + function toUv(lonDegrees, latDegrees) { + return { + x: + (CesiumMath.toRadians(lonDegrees) - tileRectangle.west) / + (tileRectangle.east - tileRectangle.west), + y: + (CesiumMath.toRadians(latDegrees) - tileRectangle.south) / + (tileRectangle.north - tileRectangle.south), + }; + } + + // (-95, 40) lies inside the hole: even parity, outside the fill. + const holePoint = toUv(-95.0, 40.0); + expect(countRayCrossings(data, holePoint.x, holePoint.y) % 2).toBe(0); + + // (-98.5, 40) lies between the hole and the outer ring: odd parity. + const fillPoint = toUv(-98.5, 40.0); + expect(countRayCrossings(data, fillPoint.x, fillPoint.y) % 2).toBe(1); + }); + + it("returns hidden vector data for a tile not overlapping any polygon", function () { + const provider = new VectorProvider({ tilingScheme }); + provider.add(createPolygonCollection()); + + const xy = tilingScheme.positionToTileXY(farPoint, level); + expect(provider.requestTileData(xy.x, xy.y, level, context)).toEqual({ + show: false, + }); + }); + + it("packs polylines and polygons into a shared primitive index space", function () { + const provider = new VectorProvider({ tilingScheme }); + provider.add(createPolylineCollection()); + provider.add(createPolygonCollection()); + + const xy = tilingScheme.positionToTileXY(lineMidpoint, level); + const data = provider.requestTileData(xy.x, xy.y, level, context); + + expect(data.show).toBe(true); + expect(data.segmentTexels).toBeInstanceOf(Float32Array); + expect(data.polygonEdgeTexels).toBeInstanceOf(Float32Array); + + // One polyline primitive + one polygon primitive share the space. + expect(data.primitiveCount).toBe(2); + + // Polygon edges reference a primitive index beyond the polyline's. + let maxPolygonPrimitive = -1; + for (let i = 0; i < data.polygonEdgePrimitiveIndicesTexels.length; i++) { + maxPolygonPrimitive = Math.max( + maxPolygonPrimitive, + data.polygonEdgePrimitiveIndicesTexels[i], + ); + } + expect(maxPolygonPrimitive).toBe(1); + }); });