diff --git a/Apps/Sandcastle/gallery/3D Tiles Feature Picking.html b/Apps/Sandcastle/gallery/3D Tiles Feature Picking.html index 23ae523d1666..18f05ae35e33 100644 --- a/Apps/Sandcastle/gallery/3D Tiles Feature Picking.html +++ b/Apps/Sandcastle/gallery/3D Tiles Feature Picking.html @@ -37,6 +37,7 @@ terrain: Cesium.Terrain.fromWorldTerrain(), }); + let async = false; viewer.scene.globe.depthTestAgainstTerrain = true; // Set the initial camera view to look at Manhattan @@ -133,6 +134,34 @@ return description; } + Sandcastle.addToggleButton("Async Picking", async, function (checked) { + async = checked; + }); + + let picking = false; + let debounceTime = 0; + const DEBOUNCE_TIMEOUT_MS = 34; // One pick per frame (30FPS) + + async function doPick(scene, position, debounce, async, result) { + const now = performance.now(); + if (debounce && picking && now - debounceTime < DEBOUNCE_TIMEOUT_MS) { + return true; + } + if (debounce) { + picking = true; + debounceTime = now; + } + if (async) { + result[0] = await scene.pickAsync(position); + } else { + result[0] = scene.pick(position); + } + if (debounce) { + picking = false; + } + return false; + } + // If silhouettes are supported, silhouette features in blue on mouse over and silhouette green on mouse click. // If silhouettes are not supported, change the feature color to yellow on mouse over and green on mouse click. if (Cesium.PostProcessStageLibrary.isSilhouetteSupported(viewer.scene)) { @@ -157,13 +186,26 @@ ); // Silhouette a feature blue on hover. - viewer.screenSpaceEventHandler.setInputAction(function onMouseMove(movement) { + viewer.screenSpaceEventHandler.setInputAction(async function onMouseMove( + movement, + ) { + // Pick a new feature + let pickedFeature = []; + const debounced = await doPick( + viewer.scene, + movement.endPosition, + true, + async, + pickedFeature, + ); + if (debounced) { + return; + } + pickedFeature = pickedFeature[0]; + // If a feature was previously highlighted, undo the highlight silhouetteBlue.selected = []; - // Pick a new feature - const pickedFeature = viewer.scene.pick(movement.endPosition); - updateNameOverlay(pickedFeature, movement.endPosition); if (!Cesium.defined(pickedFeature)) { @@ -177,12 +219,26 @@ }, Cesium.ScreenSpaceEventType.MOUSE_MOVE); // Silhouette a feature on selection and show metadata in the InfoBox. - viewer.screenSpaceEventHandler.setInputAction(function onLeftClick(movement) { + viewer.screenSpaceEventHandler.setInputAction(async function onLeftClick( + movement, + ) { + // Pick a new feature + let pickedFeature = []; + const debounced = await doPick( + viewer.scene, + movement.position, + false, + async, + pickedFeature, + ); + if (debounced) { + return; + } + pickedFeature = pickedFeature[0]; + // If a feature was previously selected, undo the highlight silhouetteGreen.selected = []; - // Pick a new feature - const pickedFeature = viewer.scene.pick(movement.position); if (!Cesium.defined(pickedFeature)) { clickHandler(movement); return; @@ -216,14 +272,29 @@ }; // Color a feature yellow on hover. - viewer.screenSpaceEventHandler.setInputAction(function onMouseMove(movement) { + viewer.screenSpaceEventHandler.setInputAction(async function onMouseMove( + movement, + ) { + // Pick a new feature + let pickedFeature = []; + const debounced = await doPick( + viewer.scene, + movement.endPosition, + true, + async, + pickedFeature, + ); + if (debounced) { + return; + } + pickedFeature = pickedFeature[0]; + // If a feature was previously highlighted, undo the highlight if (Cesium.defined(highlighted.feature)) { highlighted.feature.color = highlighted.originalColor; highlighted.feature = undefined; } - // Pick a new feature - const pickedFeature = viewer.scene.pick(movement.endPosition); + updateNameOverlay(pickedFeature, movement.endPosition); if (!Cesium.defined(pickedFeature)) { @@ -239,14 +310,29 @@ }, Cesium.ScreenSpaceEventType.MOUSE_MOVE); // Color a feature on selection and show metadata in the InfoBox. - viewer.screenSpaceEventHandler.setInputAction(function onLeftClick(movement) { + viewer.screenSpaceEventHandler.setInputAction(async function onLeftClick( + movement, + ) { + // Pick a new feature + let pickedFeature = []; + const debounced = await doPick( + viewer.scene, + movement.position, + false, + async, + pickedFeature, + ); + if (debounced) { + return; + } + pickedFeature = pickedFeature[0]; + // If a feature was previously selected, undo the highlight if (Cesium.defined(selected.feature)) { selected.feature.color = selected.originalColor; selected.feature = undefined; } - // Pick a new feature - const pickedFeature = viewer.scene.pick(movement.position); + if (!Cesium.defined(pickedFeature)) { clickHandler(movement); return; diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 19bb2bd48111..91867694260d 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -104,6 +104,7 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for details on how to contribute to Cesiu - [Erin Ingram](https://github.com/eringram) - [Daniel Zhong](https://github.com/danielzhong) - [Mark Schlosser](https://github.com/markschlosseratbentley) + - [Adam Larkeryd](https://github.com/alarkbentley) - [Flightradar24 AB](https://www.flightradar24.com) - [Aleksei Kalmykov](https://github.com/kalmykov) - [BIT Systems](http://www.caci.com/bit-systems) diff --git a/packages/engine/Source/Renderer/Buffer.js b/packages/engine/Source/Renderer/Buffer.js index 039650950ccb..6e67f26ffa27 100644 --- a/packages/engine/Source/Renderer/Buffer.js +++ b/packages/engine/Source/Renderer/Buffer.js @@ -72,6 +72,24 @@ function Buffer(options) { this.vertexArrayDestroyable = true; } +Buffer.createPixelBuffer = function (options) { + //>>includeStart('debug', pragmas.debug); + Check.defined("options.context", options.context); + //>>includeEnd('debug'); + + if (!options.context._webgl2) { + throw new DeveloperError("A WebGL 2 context is required."); + } + + return new Buffer({ + context: options.context, + bufferTarget: WebGLConstants.PIXEL_PACK_BUFFER, + typedArray: options.typedArray, + sizeInBytes: options.sizeInBytes, + usage: options.usage, + }); +}; + /** * Creates a vertex buffer, which contains untyped vertex data in GPU-controlled memory. *

@@ -242,6 +260,18 @@ Buffer.prototype._getBuffer = function () { return this._buffer; }; +Buffer.prototype._bind = function () { + const gl = this._gl; + const target = this._bufferTarget; + gl.bindBuffer(target, this._buffer); +}; + +Buffer.prototype._unBind = function () { + const gl = this._gl; + const target = this._bufferTarget; + gl.bindBuffer(target, null); +}; + Buffer.prototype.copyFromArrayView = function (arrayView, offsetInBytes) { offsetInBytes = offsetInBytes ?? 0; diff --git a/packages/engine/Source/Renderer/BufferUsage.js b/packages/engine/Source/Renderer/BufferUsage.js index a346938793f1..dc9b3727680e 100644 --- a/packages/engine/Source/Renderer/BufferUsage.js +++ b/packages/engine/Source/Renderer/BufferUsage.js @@ -7,12 +7,14 @@ const BufferUsage = { STREAM_DRAW: WebGLConstants.STREAM_DRAW, STATIC_DRAW: WebGLConstants.STATIC_DRAW, DYNAMIC_DRAW: WebGLConstants.DYNAMIC_DRAW, + DYNAMIC_READ: WebGLConstants.DYNAMIC_READ, validate: function (bufferUsage) { return ( bufferUsage === BufferUsage.STREAM_DRAW || bufferUsage === BufferUsage.STATIC_DRAW || - bufferUsage === BufferUsage.DYNAMIC_DRAW + bufferUsage === BufferUsage.DYNAMIC_DRAW || + bufferUsage === BufferUsage.DYNAMIC_READ ); }, }; diff --git a/packages/engine/Source/Renderer/Context.js b/packages/engine/Source/Renderer/Context.js index b5d4d83a2a2f..9df2769ec76f 100644 --- a/packages/engine/Source/Renderer/Context.js +++ b/packages/engine/Source/Renderer/Context.js @@ -1,3 +1,4 @@ +import Buffer from "./Buffer.js"; import Check from "../Core/Check.js"; import Color from "../Core/Color.js"; import ComponentDatatype from "../Core/ComponentDatatype.js"; @@ -1449,6 +1450,7 @@ Context.prototype.endFrame = function () { * @param {number} [readState.width=this.drawingBufferWidth] The width of the rectangle to read from. * @param {number} [readState.height=this.drawingBufferHeight] The height of the rectangle to read from. * @param {Framebuffer} [readState.framebuffer] The framebuffer to read from. If undefined, the read will be from the default framebuffer. + * @param {Framebuffer} [readState.pbo] If true pixel data is read to PBO instead of TypedArray. * @returns {Uint8Array|Uint16Array|Float32Array|Uint32Array} The pixels in the specified rectangle. */ Context.prototype.readPixels = function (readState) { @@ -1460,6 +1462,11 @@ Context.prototype.readPixels = function (readState) { const width = readState.width ?? this.drawingBufferWidth; const height = readState.height ?? this.drawingBufferHeight; const framebuffer = readState.framebuffer; + const pbo = readState.pbo ?? false; + + if (pbo && !this._webgl2) { + throw new DeveloperError("A WebGL 2 context is required."); + } //>>includeStart('debug', pragmas.debug); Check.typeOf.number.greaterThan("readState.width", width, 0); @@ -1467,28 +1474,58 @@ Context.prototype.readPixels = function (readState) { //>>includeEnd('debug'); let pixelDatatype = PixelDatatype.UNSIGNED_BYTE; + let pixelFormat = PixelFormat.RGBA; if (defined(framebuffer) && framebuffer.numberOfColorAttachments > 0) { pixelDatatype = framebuffer.getColorTexture(0).pixelDatatype; + pixelFormat = framebuffer.getColorTexture(0).pixelFormat; } - const pixels = PixelFormat.createTypedArray( - PixelFormat.RGBA, - pixelDatatype, - width, - height, - ); + let pixels; + if (pbo) { + pixels = Buffer.createPixelBuffer({ + context: this, + sizeInBytes: PixelFormat.textureSizeInBytes( + pixelFormat, + pixelDatatype, + width, + height, + ), + usage: BufferUsage.DYNAMIC_READ, + }); + } else { + pixels = PixelFormat.createTypedArray( + pixelFormat, + pixelDatatype, + width, + height, + ); + } bindFramebuffer(this, framebuffer); - gl.readPixels( - x, - y, - width, - height, - PixelFormat.RGBA, - PixelDatatype.toWebGLConstant(pixelDatatype, this), - pixels, - ); + if (pbo) { + pixels._bind(); + gl.readPixels( + x, + y, + width, + height, + pixelFormat, + PixelDatatype.toWebGLConstant(pixelDatatype, this), + 0, + ); + pixels._unBind(); + } else { + gl.readPixels( + x, + y, + width, + height, + PixelFormat.RGBA, + PixelDatatype.toWebGLConstant(pixelDatatype, this), + pixels, + ); + } return pixels; }; diff --git a/packages/engine/Source/Renderer/Sync.js b/packages/engine/Source/Renderer/Sync.js new file mode 100644 index 000000000000..237f57c2fa0b --- /dev/null +++ b/packages/engine/Source/Renderer/Sync.js @@ -0,0 +1,46 @@ +import Check from "../Core/Check.js"; +import destroyObject from "../Core/destroyObject.js"; +import DeveloperError from "../Core/DeveloperError.js"; +import Frozen from "../Core/Frozen.js"; +import WebGLConstants from "../Core/WebGLConstants.js"; + +/** + * @private + */ +function Sync(options) { + options = options ?? Frozen.EMPTY_OBJECT; + + //>>includeStart('debug', pragmas.debug); + Check.defined("options.context", options.context); + //>>includeEnd('debug'); + + if (!options.context._webgl2) { + throw new DeveloperError("A WebGL 2 context is required."); + } + + const context = options.context; + const gl = context._gl; + + const sync = gl.fenceSync(WebGLConstants.SYNC_GPU_COMMANDS_COMPLETE, 0); + + this._gl = gl; + this._sync = sync; +} +Sync.create = function (options) { + return new Sync(options); +}; +Sync.prototype.getStatus = function () { + const status = this._gl.getSyncParameter( + this._sync, + WebGLConstants.SYNC_STATUS, + ); + return status; +}; +Sync.prototype.isDestroyed = function () { + return false; +}; +Sync.prototype.destroy = function () { + this._gl.deleteSync(this._sync); + return destroyObject(this); +}; +export default Sync; diff --git a/packages/engine/Source/Scene/PickFramebuffer.js b/packages/engine/Source/Scene/PickFramebuffer.js index b6884933b0d2..33736a026d8b 100644 --- a/packages/engine/Source/Scene/PickFramebuffer.js +++ b/packages/engine/Source/Scene/PickFramebuffer.js @@ -4,6 +4,10 @@ import defined from "../Core/defined.js"; import destroyObject from "../Core/destroyObject.js"; import FramebufferManager from "../Renderer/FramebufferManager.js"; import PassState from "../Renderer/PassState.js"; +import PixelDatatype from "../Renderer/PixelDatatype.js"; +import PixelFormat from "../Core/PixelFormat.js"; +import WebGLConstants from "../Core/WebGLConstants.js"; +import Sync from "../Renderer/Sync.js"; /** * @private @@ -27,47 +31,18 @@ function PickFramebuffer(context) { this._height = 0; } -PickFramebuffer.prototype.begin = function (screenSpaceRectangle, viewport) { - const context = this._context; - const { width, height } = viewport; - - BoundingRectangle.clone( - screenSpaceRectangle, - this._passState.scissorTest.rectangle, - ); - - // Create or recreate renderbuffers and framebuffer used for picking - this._width = width; - this._height = height; - this._fb.update(context, width, height); - this._passState.framebuffer = this._fb.framebuffer; - - this._passState.viewport.width = width; - this._passState.viewport.height = height; - - return this._passState; -}; - /** * Return the picked objects rendered within a given rectangle. * - * @param {BoundingRectangle} screenSpaceRectangle + * @private + * @param {object} context The active context. + * @param {Uint8Array|Uint16Array|Float32Array|Uint32Array} pixels The pixels in the specified scratch rectangle. + * @param {number} width The scratch rectangle width. + * @param {number} height The scratch rectangle height. * @param {number} [limit=1] If supplied, stop iterating after collecting this many objects. * @returns {object[]} A list of rendered objects, ordered by distance to the middle of the rectangle. */ -PickFramebuffer.prototype.end = function (screenSpaceRectangle, limit = 1) { - const width = screenSpaceRectangle.width ?? 1.0; - const height = screenSpaceRectangle.height ?? 1.0; - - const context = this._context; - const pixels = context.readPixels({ - x: screenSpaceRectangle.x, - y: screenSpaceRectangle.y, - width: width, - height: height, - framebuffer: this._fb.framebuffer, - }); - +function colorScratchForObject(context, pixels, width, height, limit = 1) { const max = Math.max(width, height); const length = max * max; const halfWidth = Math.floor(width * 0.5); @@ -121,6 +96,152 @@ PickFramebuffer.prototype.end = function (screenSpaceRectangle, limit = 1) { y += dy; } return [...objects]; +} + +/** + * Creates a callback function that will once per frame poll the Sync state until signaled. + * + * @private + * @param {object} pickState An object with the following properties: + * @param {number} [readState.frameState] The active framestate. + * @param {number} [readState.frameNumber] The current frame number. + * @param {number} [readState.sync] The Sync object to poll. + * @param {number} [readState.ttl=10] Max number of frames to poll until reject. + * @param {function} onSignalCallback Callback to execute on Sync Signal. + */ +function createAsyncPick(pickState, onSignalCallback) { + return () => { + const sync = pickState.sync; + const frameState = pickState.frameState; + const ttl = pickState.ttl ?? 10; + const syncStatus = sync.getStatus(); + const signaled = syncStatus === WebGLConstants.SIGNALED; + const frameDelta = frameState.frameNumber - pickState.frameNumber; // how many frames passed since inital request + if (signaled || frameDelta > ttl) { + sync.destroy(); + onSignalCallback(signaled); + } else { + frameState.afterRender.push(createAsyncPick(pickState, onSignalCallback)); + } + }; +} + +PickFramebuffer.prototype.begin = function (screenSpaceRectangle, viewport) { + const context = this._context; + const { width, height } = viewport; + + BoundingRectangle.clone( + screenSpaceRectangle, + this._passState.scissorTest.rectangle, + ); + + // Create or recreate renderbuffers and framebuffer used for picking + this._width = width; + this._height = height; + this._fb.update(context, width, height); + this._passState.framebuffer = this._fb.framebuffer; + + this._passState.viewport.width = width; + this._passState.viewport.height = height; + + return this._passState; +}; + +/** + * Return the picked objects rendered within a given rectangle using asynchronously without stalling the GPU. + * + * @param {BoundingRectangle} screenSpaceRectangle + * @param {FrameState} frameState + * @param {number} [limit=1] If supplied, stop iterating after collecting this many objects. + * @returns {object[]} A list of rendered objects, ordered by distance to the middle of the rectangle. + */ +PickFramebuffer.prototype.endAsync = async function ( + screenSpaceRectangle, + frameState, + limit = 1, +) { + const width = screenSpaceRectangle.width ?? 1.0; + const height = screenSpaceRectangle.height ?? 1.0; + + const context = this._context; + const framebuffer = this._fb.framebuffer; + + let pixelDatatype = PixelDatatype.UNSIGNED_BYTE; + let pixelFormat = PixelFormat.RGBA; + + if (defined(framebuffer) && framebuffer.numberOfColorAttachments > 0) { + pixelDatatype = framebuffer.getColorTexture(0).pixelDatatype; + pixelFormat = framebuffer.getColorTexture(0).pixelFormat; + } + + const pbo = context.readPixels({ + x: screenSpaceRectangle.x, + y: screenSpaceRectangle.y, + width: width, + height: height, + framebuffer: framebuffer, + pbo: true, + }); + + const sync = Sync.create({ + context: context, + }); + + const pickState = { + frameState: frameState, + frameNumber: frameState.frameNumber, + sync: sync, + }; + + return new Promise((resolve, reject) => { + frameState.afterRender.push( + createAsyncPick(pickState, (signaled) => { + const pixels = PixelFormat.createTypedArray( + pixelFormat, + pixelDatatype, + width, + height, + ); + pbo.getBufferData(pixels); + pbo.destroy(); + const pickedObjects = colorScratchForObject( + context, + pixels, + width, + height, + limit, + ); + if (signaled) { + resolve(pickedObjects); + } else { + reject("Picking Request Timeout"); + } + }), + ); + }); +}; + +/** + * Return the picked objects rendered within a given rectangle. + * + * @param {BoundingRectangle} screenSpaceRectangle + * @param {number} [limit=1] If supplied, stop iterating after collecting this many objects. + * @returns {object[]} A list of rendered objects, ordered by distance to the middle of the rectangle. + */ +PickFramebuffer.prototype.end = function (screenSpaceRectangle, limit = 1) { + const width = screenSpaceRectangle.width ?? 1.0; + const height = screenSpaceRectangle.height ?? 1.0; + + const context = this._context; + const pixels = context.readPixels({ + x: screenSpaceRectangle.x, + y: screenSpaceRectangle.y, + width: width, + height: height, + framebuffer: this._fb.framebuffer, + }); + + return colorScratchForObject(context, pixels, width, height, limit); }; /** diff --git a/packages/engine/Source/Scene/Picking.js b/packages/engine/Source/Scene/Picking.js index dc1ff2899179..51510eedff6a 100644 --- a/packages/engine/Source/Scene/Picking.js +++ b/packages/engine/Source/Scene/Picking.js @@ -273,7 +273,8 @@ function computePickingDrawingBufferRectangle( * @param {number} [width=3] Width of the pick rectangle. * @param {number} [height=3] Height of the pick rectangle. * @param {number} [limit=1] If supplied, stop iterating after collecting this many objects. - * @returns {object[]} List of objects containing the picked primitives. + * @param {boolean} [async=false] Use async non GPU blocking picking. + * @returns {object[] | Promise} List of objects containing the picked primitives. */ Picking.prototype.pick = function ( scene, @@ -281,6 +282,7 @@ Picking.prototype.pick = function ( width, height, limit = 1, + async, ) { //>>includeStart('debug', pragmas.debug); Check.defined("windowPosition", windowPosition); @@ -288,6 +290,7 @@ Picking.prototype.pick = function ( const { context, frameState, defaultView } = scene; const { viewport, pickFramebuffer } = defaultView; + async = async ?? false; scene.view = defaultView; @@ -335,7 +338,16 @@ Picking.prototype.pick = function ( scene.updateAndExecuteCommands(passState, scratchColorZero); scene.resolveFramebuffers(passState); - const pickedObjects = pickFramebuffer.end(drawingBufferRectangle, limit); + let pickedObjects; + if (async) { + pickedObjects = pickFramebuffer.endAsync( + drawingBufferRectangle, + frameState, + limit, + ); // Promise + } else { + pickedObjects = pickFramebuffer.end(drawingBufferRectangle, limit); // Object[] + } context.endFrame(); return pickedObjects; }; diff --git a/packages/engine/Source/Scene/Scene.js b/packages/engine/Source/Scene/Scene.js index c0ba8bea5d92..9b70874e3204 100644 --- a/packages/engine/Source/Scene/Scene.js +++ b/packages/engine/Source/Scene/Scene.js @@ -3932,14 +3932,15 @@ function callAfterRenderFunctions(scene) { // Functions are queued up during primitive update and executed here in case // the function modifies scene state that should remain constant over the frame. const functions = scene._frameState.afterRender; - for (let i = 0; i < functions.length; ++i) { - const shouldRequestRender = functions[i](); + const functionsCpy = functions.slice(); // Snapshot before iterate allows callbacks to add functions for next frame + functions.length = 0; + + for (let i = 0; i < functionsCpy.length; ++i) { + const shouldRequestRender = functionsCpy[i](); if (shouldRequestRender) { scene.requestRender(); } } - - functions.length = 0; } function getGlobeHeight(scene) { @@ -4517,6 +4518,27 @@ Scene.prototype.pick = function (windowPosition, width, height) { return this._picking.pick(this, windowPosition, width, height, 1)[0]; }; +/** + * Performs the same operation as Scene.pick but asynchonosly without blocking the main render thread. + * + * @param {Cartesian2} windowPosition Window coordinates to perform picking on. + * @param {number} [width=3] Width of the pick rectangle. + * @param {number} [height=3] Height of the pick rectangle. + * @returns {Promise} Object containing the picked primitive or undefined if nothing is at the location. + * + * @see Scene#pick + */ +Scene.prototype.pickAsync = async function (windowPosition, width, height) { + const result = await this._picking.pick( + this, + windowPosition, + width, + height, + 1, + true, + ); + return result[0]; +}; /** * Returns a {@link VoxelCell} for the voxel sample rendered at a particular window coordinate, * or undefined if no voxel is rendered at that position. diff --git a/packages/engine/Specs/Renderer/BufferSpec.js b/packages/engine/Specs/Renderer/BufferSpec.js index 9f96253ebf0e..e35a39816c57 100644 --- a/packages/engine/Specs/Renderer/BufferSpec.js +++ b/packages/engine/Specs/Renderer/BufferSpec.js @@ -690,6 +690,160 @@ describe( b.destroy(); }).toThrowDeveloperError(); }); + + it(`throws when creating a pixel buffer with no context`, function () { + if (!context.webgl2) { + return; + } + expect(function () { + buffer = Buffer.createPixelBuffer({ + sizeInBytes: 4, + usage: BufferUsage.STATIC_DRAW, + }); + }).toThrowDeveloperError(); + }); + + it(`throws when creating a pixel buffer with an invalid typed array`, function () { + if (!context.webgl2) { + return; + } + expect(function () { + buffer = Buffer.createPixelBuffer({ + context: context, + typedArray: {}, + usage: BufferUsage.STATIC_DRAW, + }); + }).toThrowDeveloperError(); + }); + + it(`throws when creating a pixel buffer with both a typed array and size in bytes`, function () { + if (!context.webgl2) { + return; + } + expect(function () { + buffer = Buffer.createPixelBuffer({ + context: context, + typedArray: new Float32Array([0, 0, 0, 1]), + sizeInBytes: 16, + usage: BufferUsage.STATIC_DRAW, + }); + }).toThrowDeveloperError(); + }); + + it(`throws when creating a pixel buffer without a typed array or size in bytes`, function () { + if (!context.webgl2) { + return; + } + expect(function () { + buffer = Buffer.createPixelBuffer({ + context: context, + usage: BufferUsage.STATIC_DRAW, + }); + }).toThrowDeveloperError(); + }); + + it(`throws when creating a pixel buffer with invalid usage`, function () { + if (!context.webgl2) { + return; + } + expect(function () { + buffer = Buffer.createPixelBuffer({ + context: context, + sizeInBytes: 16, + usage: 0, + }); + }).toThrowDeveloperError(); + }); + + it(`throws when creating a pixel buffer with size of zero`, function () { + if (!context.webgl2) { + return; + } + expect(function () { + buffer = Buffer.createPixelBuffer({ + context: context, + sizeInBytes: 0, + usage: BufferUsage.STATIC_DRAW, + }); + }).toThrowDeveloperError(); + }); + + it(`creates pixel buffer`, function () { + if (!context.webgl2) { + return; + } + buffer = Buffer.createPixelBuffer({ + context: context, + sizeInBytes: 16, + usage: BufferUsage.STATIC_DRAW, + }); + + expect(buffer.sizeInBytes).toEqual(16); + expect(buffer.usage).toEqual(BufferUsage.STATIC_DRAW); + }); + + it(`copies array to a pixel buffer`, function () { + if (!context.webgl2) { + return; + } + const sizeInBytes = 3 * Float32Array.BYTES_PER_ELEMENT; + const vertices = new ArrayBuffer(sizeInBytes); + const positions = new Float32Array(vertices); + positions[0] = 1.0; + positions[1] = 2.0; + positions[2] = 3.0; + + buffer = Buffer.createPixelBuffer({ + context: context, + sizeInBytes: sizeInBytes, + usage: BufferUsage.STATIC_DRAW, + }); + buffer.copyFromArrayView(vertices); + }); + + it(`can create a pixel buffer from a typed array`, function () { + if (!context.webgl2) { + return; + } + const typedArray = new Float32Array(3); + typedArray[0] = 1.0; + typedArray[1] = 2.0; + typedArray[2] = 3.0; + + buffer = Buffer.createPixelBuffer({ + context: context, + typedArray: typedArray, + usage: BufferUsage.STATIC_DRAW, + }); + expect(buffer.sizeInBytes).toEqual(typedArray.byteLength); + expect(buffer.usage).toEqual(BufferUsage.STATIC_DRAW); + }); + + it(`can create a pixel buffer from a size in bytes`, function () { + if (!context.webgl2) { + return; + } + buffer = Buffer.createPixelBuffer({ + context: context, + sizeInBytes: 4, + usage: BufferUsage.STATIC_DRAW, + }); + expect(buffer.sizeInBytes).toEqual(4); + expect(buffer.usage).toEqual(BufferUsage.STATIC_DRAW); + }); + + it(`create a pixel buffer throws without WebGL 2`, function () { + if (context.webgl2) { + return; + } + expect(function () { + buffer = Buffer.createPixelBuffer({ + context: context, + sizeInBytes: 4, + usage: BufferUsage.STATIC_DRAW, + }); + }).toThrowDeveloperError(); + }); } }, "WebGL", diff --git a/packages/engine/Specs/Renderer/ContextSpec.js b/packages/engine/Specs/Renderer/ContextSpec.js index 82526e2ecfdc..515f765d57d6 100644 --- a/packages/engine/Specs/Renderer/ContextSpec.js +++ b/packages/engine/Specs/Renderer/ContextSpec.js @@ -5,6 +5,9 @@ import { BufferUsage, Context, ContextLimits, + ClearCommand, + PixelFormat, + PixelDatatype, } from "../../index.js"; import createContext from "../../../../Specs/createContext.js"; @@ -340,6 +343,65 @@ describe( expect(c2._webgl2).toBe(true); } }); + + it("readPixels", function () { + if (webglStub) { + return; + } + const c = createContext(); + const command = new ClearCommand({ + color: Color.WHITE, + }); + command.execute(c); + const pixels = c.readPixels(); + expect(pixels).toBeDefined(); + expect(pixels).toEqual([255, 255, 255, 255]); + c.destroyForSpecs(); + }); + + it("readPixels using PBO", function () { + if (webglStub) { + return; + } + if (!context.webgl2) { + return; + } + const c = createContext(); + const command = new ClearCommand({ + color: Color.WHITE, + }); + command.execute(c); + const pixelBuffer = c.readPixels({ + width: 1, + height: 1, + pbo: true, + }); + const pixels = PixelFormat.createTypedArray( + PixelFormat.RGBA, + PixelDatatype.UNSIGNED_BYTE, + 1, + 1, + ); + pixelBuffer.getBufferData(pixels); + pixelBuffer.destroy(); + expect(pixels).toBeDefined(); + expect(pixels).toEqual([255, 255, 255, 255]); + c.destroyForSpecs(); + }); + + it(`readPixels using PBO throws without WebGL 2`, function () { + if (webglStub) { + return; + } + if (context.webgl2) { + return; + } + expect(function () { + context.readPixels({ + pbo: true, + }); + }).toThrowDeveloperError(); + }); }, "WebGL", ); diff --git a/packages/engine/Specs/Renderer/SyncSpec.js b/packages/engine/Specs/Renderer/SyncSpec.js new file mode 100644 index 000000000000..77f872118940 --- /dev/null +++ b/packages/engine/Specs/Renderer/SyncSpec.js @@ -0,0 +1,96 @@ +import { Sync, WebGLConstants } from "../../index.js"; + +import createContext from "../../../../Specs/createContext.js"; +import createWebglVersionHelper from "../createWebglVersionHelper.js"; + +describe( + "Renderer/Sync", + function () { + createWebglVersionHelper(createBufferSpecs); + + function createBufferSpecs(contextOptions) { + let context; + let sync; + + beforeAll(function () { + context = createContext(contextOptions); + }); + + afterAll(function () { + context.destroyForSpecs(); + }); + + afterEach(function () { + sync = sync && sync.destroy(); + }); + + it(`throws when creating a sync with no context`, function () { + if (!context.webgl2) { + return; + } + expect(function () { + sync = Sync.create({}); + }).toThrowDeveloperError(); + }); + + it("creates", function () { + if (!context.webgl2) { + return; + } + sync = Sync.create({ + context: context, + }); + expect(sync).toBeDefined(); + }); + + it("destroys", function () { + if (!context.webgl2) { + return; + } + const sync = Sync.create({ + context: context, + }); + expect(sync.isDestroyed()).toEqual(false); + sync.destroy(); + expect(sync.isDestroyed()).toEqual(true); + }); + + it("throws when fails to destroy", function () { + if (!context.webgl2) { + return; + } + const sync = Sync.create({ + context: context, + }); + sync.destroy(); + + expect(function () { + sync.destroy(); + }).toThrowDeveloperError(); + }); + + it(`throws without WebGL 2`, function () { + if (context.webgl2) { + return; + } + expect(function () { + sync = Sync.create({ + context: context, + }); + }).toThrowDeveloperError(); + }); + + it(`returns status unsignaled after create`, function () { + if (!context.webgl2) { + return; + } + sync = Sync.create({ + context: context, + }); + const status = sync.getStatus(); + expect(status).toEqual(WebGLConstants.UNSIGNALED); + }); + } + }, + "WebGL", +); diff --git a/packages/engine/Specs/Scene/PickingSpec.js b/packages/engine/Specs/Scene/PickingSpec.js index 2d757a482357..93e54a0aefb8 100644 --- a/packages/engine/Specs/Scene/PickingSpec.js +++ b/packages/engine/Specs/Scene/PickingSpec.js @@ -93,13 +93,14 @@ describe( scene.mode = SceneMode.SCENE3D; scene.morphTime = SceneMode.getMorphTime(scene.mode); - camera.setView({ - destination: largeRectangle, - }); - + // Note: Important the camera.frustum is set before camera.setView camera.frustum = new PerspectiveFrustum(); camera.frustum.fov = CesiumMath.toRadians(60.0); camera.frustum.aspectRatio = 1.0; + + camera.setView({ + destination: largeRectangle, + }); }); afterEach(function () { @@ -235,6 +236,34 @@ describe( scene.renderForSpecs(); expect(scene).toPickPrimitive(rectangle); }); + + it("picks a primitive async", async function () { + if (webglStub) { + return; + } + if (!scene.context.webgl2) { + return; + } + const rectangle = createLargeRectangle(0.0); + const windowPosition = new Cartesian2(0, 0); + + let actual; + let ready = false; + scene._picking + .pick(scene, windowPosition, undefined, undefined, 1, true) + .then((result) => { + actual = result[0]; + ready = true; + }); + + await pollToPromise(function () { + scene.renderForSpecs(); + return ready; + }); + + expect(actual).toBeDefined(); + expect(actual.primitive).toEqual(rectangle); + }); }); describe("pickVoxelCoordinate", function () { diff --git a/packages/engine/Specs/Scene/SceneSpec.js b/packages/engine/Specs/Scene/SceneSpec.js index bfa16d36f58b..3b74fa8a9781 100644 --- a/packages/engine/Specs/Scene/SceneSpec.js +++ b/packages/engine/Specs/Scene/SceneSpec.js @@ -636,6 +636,10 @@ function pickMetadataAt(scene, schemaId, className, propertyName, x, y) { describe( "Scene/Scene", function () { + // It's not easily possible to mock the most detailed pick functions + // so don't run those tests when using the WebGL stub + const webglStub = !!window.webglStub; + let scene; beforeAll(function () { @@ -846,6 +850,31 @@ describe( expect(spyListener).toHaveBeenCalled(); }); + it("afterRender functions can schedule callbacks for next frame", function () { + const spyListener = jasmine.createSpy("listener"); + const spyListener2 = jasmine.createSpy("listener"); + + const primitive = { + update: function (frameState) { + frameState.afterRender.push(spyListener); + frameState.afterRender.push(() => { + frameState.afterRender.push(spyListener2); + }); + }, + destroy: function () {}, + isDestroyed: () => false, + }; + scene.primitives.add(primitive); + + scene.renderForSpecs(); + expect(spyListener).toHaveBeenCalled(); + expect(spyListener2).not.toHaveBeenCalled(); + + scene.renderForSpecs(); + expect(spyListener).toHaveBeenCalled(); + expect(spyListener2).toHaveBeenCalled(); + }); + function CommandMockPrimitive(command) { this.update = function (frameState) { frameState.commandList.push(command); @@ -1475,6 +1504,56 @@ describe( }); }); + it("pick", async function () { + if (webglStub) { + return; + } + const rectangle = Rectangle.fromDegrees(-1.0, -1.0, 1.0, 1.0); + const rectanglePrimitive = createRectangle(rectangle); + const primitives = scene.primitives; + primitives.add(rectanglePrimitive); + + scene.camera.setView({ destination: rectangle }); + + const windowPosition = new Cartesian2(0, 0); + const result = scene.pick(windowPosition); + + expect(result).toBeDefined(); + expect(result.primitive).toEqual(rectanglePrimitive); + }); + + it("pickAsync", async function () { + if (webglStub) { + return; + } + if (!scene.context.webgl2) { + return; + } + const rectangle = Rectangle.fromDegrees(-1.0, -1.0, 1.0, 1.0); + const rectanglePrimitive = createRectangle(rectangle); + const primitives = scene.primitives; + primitives.add(rectanglePrimitive); + + scene.camera.setView({ destination: rectangle }); + + const windowPosition = new Cartesian2(0, 0); + + let result; + let ready = false; + scene.pickAsync(windowPosition).then((result0) => { + result = result0; + ready = true; + }); + + await pollToPromise(function () { + scene.renderForSpecs(); + return ready; + }); + + expect(result).toBeDefined(); + expect(result.primitive).toEqual(rectanglePrimitive); + }); + it("pickPosition", function () { if (!scene.pickPositionSupported) { return;