playcanvas
Version:
Open-source WebGL/WebGPU 3D engine for the web
366 lines (365 loc) • 14.1 kB
JavaScript
var __defProp = Object.defineProperty;
var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
import { Color } from "../../core/math/color.js";
import { PIXELFORMAT_RGBA8 } from "../../platform/graphics/constants.js";
import { RenderTarget } from "../../platform/graphics/render-target.js";
import { Texture } from "../../platform/graphics/texture.js";
import { Layer } from "../../scene/layer.js";
import { PROJECTION_ORTHOGRAPHIC } from "../../scene/constants.js";
import { Debug } from "../../core/debug.js";
import { RenderPassPicker } from "./render-pass-picker.js";
import { math } from "../../core/math/math.js";
import { Vec3 } from "../../core/math/vec3.js";
import { Vec4 } from "../../core/math/vec4.js";
import { Mat4 } from "../../core/math/mat4.js";
const tempSet = /* @__PURE__ */ new Set();
const _rect = new Vec4();
const _floatView = new Float32Array(1);
const _int32View = new Int32Array(_floatView.buffer);
class Picker {
/**
* Create a new Picker instance.
*
* @param {AppBase} app - The application managing this picker instance.
* @param {number} width - The width of the pick buffer in pixels.
* @param {number} height - The height of the pick buffer in pixels.
* @param {boolean} [depth] - Whether to enable depth picking. When enabled, depth
* information is captured alongside mesh IDs using MRT. Defaults to false.
*/
constructor(app, width, height, depth = false) {
/**
* @type {import('../../platform/graphics/graphics-device.js').GraphicsDevice}
* @private
*/
__publicField(this, "device");
/**
* @type {RenderPassPicker}
* @private
*/
__publicField(this, "renderPass");
/**
* @type {boolean}
* @private
*/
__publicField(this, "depth");
/** @type {number} */
__publicField(this, "width");
/** @type {number} */
__publicField(this, "height");
/**
* Internal render target.
*
* @type {RenderTarget|null}
* @private
*/
__publicField(this, "renderTarget", null);
/**
* Color buffer texture for pick IDs.
*
* @type {Texture|null}
* @private
*/
__publicField(this, "colorBuffer", null);
/**
* Optional depth buffer texture for depth picking.
*
* @type {Texture|null}
* @private
*/
__publicField(this, "depthBuffer", null);
/**
* Internal render target for reading the depth buffer.
*
* @type {RenderTarget|null}
* @private
*/
__publicField(this, "renderTargetDepth", null);
/**
* Mapping table from ids to MeshInstances or GSplatComponents.
*
* @type {Map<number, MeshInstance | GSplatComponent>}
* @private
*/
__publicField(this, "mapping", /* @__PURE__ */ new Map());
/**
* When the device is destroyed, this allows us to ignore async results.
*
* @private
*/
__publicField(this, "deviceValid", true);
Debug.assert(app);
this.device = app.graphicsDevice;
this.renderPass = new RenderPassPicker(this.device, app.renderer);
this.depth = depth;
this.width = 0;
this.height = 0;
this.resize(width, height);
this.allocateRenderTarget();
this.device.on("destroy", () => {
this.deviceValid = false;
});
}
/**
* Frees resources associated with this picker.
*/
destroy() {
this.releaseRenderTarget();
this.renderPass?.destroy();
}
/**
* Return the list of mesh instances selected by the specified rectangle in the previously
* prepared pick buffer. The rectangle using top-left coordinate system.
*
* Note: This function is not supported on WebGPU. Use {@link getSelectionAsync} instead.
* Note: This function is blocks the main thread while reading pixels from GPU memory. It's
* recommended to use {@link getSelectionAsync} instead.
*
* @param {number} x - The left edge of the rectangle.
* @param {number} y - The top edge of the rectangle.
* @param {number} [width] - The width of the rectangle. Defaults to 1.
* @param {number} [height] - The height of the rectangle. Defaults to 1.
* @returns {(MeshInstance | GSplatComponent)[]} An array of mesh instances or gsplat components
* that are in the selection.
* @example
* // Get the selection at the point (10,20)
* const selection = picker.getSelection(10, 20);
* @example
* // Get all models in rectangle with corners at (10,20) and (20,40)
* const selection = picker.getSelection(10, 20, 10, 20);
*/
getSelection(x, y, width = 1, height = 1) {
const device = this.device;
if (device.isWebGPU) {
Debug.errorOnce("pc.Picker#getSelection is not supported on WebGPU, use pc.Picker#getSelectionAsync instead.");
return [];
}
Debug.assert(typeof x !== "object", "Picker.getSelection:param 'rect' is deprecated, use 'x, y, width, height' instead.");
y = this.renderTarget.height - (y + height);
const rect = this.sanitizeRect(x, y, width, height);
device.setRenderTarget(this.renderTarget);
device.updateBegin();
const pixels = new Uint8Array(4 * rect.z * rect.w);
device.readPixels(rect.x, rect.y, rect.z, rect.w, pixels);
device.updateEnd();
return this.decodePixels(pixels, this.mapping);
}
/**
* Return the list of mesh instances selected by the specified rectangle in the previously
* prepared pick buffer. The rectangle uses top-left coordinate system.
*
* This method is asynchronous and does not block the execution.
*
* @param {number} x - The left edge of the rectangle.
* @param {number} y - The top edge of the rectangle.
* @param {number} [width] - The width of the rectangle. Defaults to 1.
* @param {number} [height] - The height of the rectangle. Defaults to 1.
* @returns {Promise<(MeshInstance | GSplatComponent)[]>} - Promise that resolves with an
* array of mesh instances or gsplat components that are in the selection.
* @example
* // Get the mesh instances at the rectangle with start at (10,20) and size of (5,5)
* picker.getSelectionAsync(10, 20, 5, 5).then((meshInstances) => {
* console.log(meshInstances);
* });
*/
getSelectionAsync(x, y, width = 1, height = 1) {
if (!this.renderTarget || !this.renderTarget.colorBuffer) {
return Promise.resolve([]);
}
return this._readTexture(this.renderTarget.colorBuffer, x, y, width, height, this.renderTarget).then((pixels) => {
return this.decodePixels(pixels, this.mapping);
});
}
/**
* Helper method to read pixels from a texture asynchronously.
*
* @param {Texture} texture - The texture to read from.
* @param {number} x - The x coordinate.
* @param {number} y - The y coordinate.
* @param {number} width - The width of the rectangle.
* @param {number} height - The height of the rectangle.
* @param {RenderTarget} renderTarget - The render target to use for reading.
* @returns {Promise<Uint8Array>} Promise resolving to the pixel data.
* @private
*/
_readTexture(texture, x, y, width, height, renderTarget) {
if (this.device?.isWebGL2) {
y = renderTarget.height - (y + height);
}
const rect = this.sanitizeRect(x, y, width, height);
return texture.read(rect.x, rect.y, rect.z, rect.w, {
immediate: true,
renderTarget
});
}
/**
* Return the world position of the mesh instance picked at the specified screen coordinates.
*
* @param {number} x - The x coordinate of the pixel to pick.
* @param {number} y - The y coordinate of the pixel to pick.
* @returns {Promise<Vec3|null>} Promise that resolves with the world position of the picked point,
* or null if no depth is available or nothing was picked.
* @example
* // Get the world position at screen coordinates (100, 50)
* picker.getWorldPointAsync(100, 50).then((worldPoint) => {
* if (worldPoint) {
* console.log('World position:', worldPoint);
* // Use the world position
* } else {
* console.log('No object at this position');
* }
* });
*/
async getWorldPointAsync(x, y) {
const camera = this.renderPass.camera;
if (!camera) {
return null;
}
const viewProjMat = new Mat4().mul2(camera.camera.projectionMatrix, camera.camera.viewMatrix);
const invViewProj = viewProjMat.invert();
const near = camera.nearClip;
const far = camera.farClip;
const isOrtho = camera.projection === PROJECTION_ORTHOGRAPHIC;
const linearDepth = await this.getPointDepthAsync(x, y);
if (linearDepth === null) {
return null;
}
const ndcDepth = isOrtho ? linearDepth : far * linearDepth / (linearDepth * (far - near) + near);
const deviceCoord = new Vec4(
x / this.width * 2 - 1,
(1 - y / this.height) * 2 - 1,
ndcDepth * 2 - 1,
1
);
invViewProj.transformVec4(deviceCoord, deviceCoord);
deviceCoord.mulScalar(1 / deviceCoord.w);
return new Vec3(deviceCoord.x, deviceCoord.y, deviceCoord.z);
}
/**
* Return the depth value of the mesh instance picked at the specified screen coordinates.
*
* @param {number} x - The x coordinate of the pixel to pick.
* @param {number} y - The y coordinate of the pixel to pick.
* @returns {Promise<number|null>} Promise that resolves with the linear normalized depth value
* of the picked point (0 = near plane, 1 = far plane), or null if depth picking is not enabled
* or no object was picked.
* @ignore
*/
async getPointDepthAsync(x, y) {
if (!this.depthBuffer) {
return null;
}
const pixels = await this._readTexture(this.depthBuffer, x, y, 1, 1, this.renderTargetDepth);
const intBits = (pixels[0] << 24 | pixels[1] << 16 | pixels[2] << 8 | pixels[3]) >>> 0;
if (intBits === 4294967295) {
return null;
}
_int32View[0] = intBits;
return _floatView[0];
}
// sanitize the rectangle to make sure it's inside the texture and does not use fractions
sanitizeRect(x, y, width, height) {
const maxWidth = this.renderTarget.width;
const maxHeight = this.renderTarget.height;
x = math.clamp(Math.floor(x), 0, maxWidth - 1);
y = math.clamp(Math.floor(y), 0, maxHeight - 1);
width = Math.floor(Math.max(width, 1));
width = Math.min(width, maxWidth - x);
height = Math.floor(Math.max(height, 1));
height = Math.min(height, maxHeight - y);
return _rect.set(x, y, width, height);
}
decodePixels(pixels, mapping) {
const selection = [];
if (this.deviceValid) {
const count = pixels.length;
for (let i = 0; i < count; i += 4) {
const r = pixels[i + 0];
const g = pixels[i + 1];
const b = pixels[i + 2];
const a = pixels[i + 3];
const index = (a << 24 | r << 16 | g << 8 | b) >>> 0;
if (index !== 4294967295) {
tempSet.add(mapping.get(index));
}
}
tempSet.forEach((meshInstance) => {
if (meshInstance) {
selection.push(meshInstance);
}
});
tempSet.clear();
}
return selection;
}
createTexture(name) {
return Texture.createDataTexture2D(this.device, name, this.width, this.height, PIXELFORMAT_RGBA8);
}
allocateRenderTarget() {
this.colorBuffer = this.createTexture("pick");
const colorBuffers = [this.colorBuffer];
if (this.depth) {
this.depthBuffer = this.createTexture("pick-depth");
colorBuffers.push(this.depthBuffer);
this.renderTargetDepth = new RenderTarget({
colorBuffer: this.depthBuffer,
depth: false
});
}
this.renderTarget = new RenderTarget({
colorBuffers,
depth: true
});
}
releaseRenderTarget() {
this.renderTarget?.destroyTextureBuffers();
this.renderTarget?.destroy();
this.renderTarget = null;
this.renderTargetDepth?.destroy();
this.renderTargetDepth = null;
this.colorBuffer = null;
this.depthBuffer = null;
}
/**
* Primes the pick buffer with a rendering of the specified models from the point of view of
* the supplied camera. Once the pick buffer has been prepared, {@link getSelection} can
* be called multiple times on the same picker object. Therefore, if the models or camera do
* not change in any way, {@link prepare} does not need to be called again.
*
* @param {CameraComponent} camera - The camera component used to render the scene.
* @param {Scene} scene - The scene containing the pickable mesh instances.
* @param {Layer[]} [layers] - Layers from which objects will be picked. If not supplied, all
* layers of the specified camera will be used.
*/
prepare(camera, scene, layers) {
if (layers instanceof Layer) {
layers = [layers];
}
this.renderTarget?.resize(this.width, this.height);
this.renderTargetDepth?.resize(this.width, this.height);
this.mapping.clear();
const renderPass = this.renderPass;
renderPass.init(this.renderTarget);
renderPass.setClearColor(Color.WHITE);
renderPass.depthStencilOps.clearDepth = true;
renderPass.update(camera, scene, layers, this.mapping, this.depth);
renderPass.render();
}
/**
* Sets the resolution of the pick buffer. The pick buffer resolution does not need to match
* the resolution of the corresponding frame buffer use for general rendering of the 3D scene.
* However, the lower the resolution of the pick buffer, the less accurate the selection
* results returned by {@link getSelection}. On the other hand, smaller pick buffers
* will yield greater performance, so there is a trade off.
*
* @param {number} width - The width of the pick buffer in pixels.
* @param {number} height - The height of the pick buffer in pixels.
*/
resize(width, height) {
this.width = Math.floor(width);
this.height = Math.floor(height);
}
}
export {
Picker
};