UNPKG

playcanvas

Version:

Open-source WebGL/WebGPU 3D engine for the web

1,052 lines (906 loc) 32.3 kB
import { CULLFACE_NONE, FILTER_LINEAR, PIXELFORMAT_RGBA8, BlendState, Color, Entity, Layer, Mesh, MeshInstance, PlaneGeometry, Script, StandardMaterial, Texture, Vec3, BLENDEQUATION_ADD, BLENDMODE_ONE, BLENDMODE_ONE_MINUS_SRC_ALPHA, BLENDMODE_SRC_ALPHA } from 'playcanvas'; /** @import { EventHandle } from 'playcanvas' */ // clamp the vertices of the hotspot so it is never clipped by the near or far plane const depthClamp = ` float f = gl_Position.z / gl_Position.w; if (f > 1.0) { gl_Position.z = gl_Position.w; } else if (f < -1.0) { gl_Position.z = -gl_Position.w; } `; const depthClampWGSL = ` let f: f32 = output.position.z / output.position.w; if (f > 1.0) { output.position.z = output.position.w; } else if (f < -1.0) { output.position.z = -output.position.w; } `; const vec = new Vec3(); /** * Resources managed by the AnnotationManager for each Annotation instance. * @typedef {object} AnnotationResources * @property {Entity} baseEntity - The entity for the main hotspot mesh * @property {Entity} overlayEntity - The entity for the overlay mesh (behind geometry) * @property {HTMLDivElement} hotspotDom - The clickable DOM element for the hotspot * @property {Texture} texture - The hotspot texture with the label * @property {StandardMaterial[]} materials - The materials for base and overlay * @property {EventHandle[]} eventHandles - Event listener handles for cleanup * @property {Function} domCleanup - Function to remove DOM event listeners */ /** * A manager script that handles global configuration and shared resources for all annotations * in a scene. Add this script to a single entity to configure annotation appearance. * * The manager listens for app-level events to automatically register annotations: * - `annotation:add` - fired when an Annotation script initializes * - `annotation:remove` - fired when an Annotation script is destroyed * * The manager handles: * - Global hotspot size, colors, and opacity settings (configurable in Editor) * - Shared rendering resources (layers, mesh) * - Per-annotation rendering resources (entities, materials, DOM elements) * - Tooltip DOM elements * - Hover and click interactions */ export class AnnotationManager extends Script { static scriptName = 'annotationManager'; /** @private */ _hotspotSize = 25; /** @private */ _hotspotColor = new Color(0.8, 0.8, 0.8); /** @private */ _hoverColor = new Color(1.0, 0.4, 0.0); /** @private */ _opacity = 1.0; /** @private */ _behindOpacity = 0.25; /** * The size of hotspots in screen pixels. * * @attribute * @title Hotspot Size * @type {number} * @default 25 */ set hotspotSize(value) { if (this._hotspotSize === value) return; this._hotspotSize = value; this._updateStyleSheet(); } get hotspotSize() { return this._hotspotSize; } /** * The default color of hotspots. * * @attribute * @title Hotspot Color * @type {Color} * @default [0.8, 0.8, 0.8, 1] */ set hotspotColor(value) { if (this._hotspotColor.equals(value)) return; this._hotspotColor.copy(value); this._updateAllAnnotationColors(); } get hotspotColor() { return this._hotspotColor; } /** * The color of hotspots when hovered. * * @attribute * @title Hotspot Hover Color * @type {Color} * @default [1, 0.4, 0, 1] */ set hoverColor(value) { if (this._hoverColor.equals(value)) return; this._hoverColor.copy(value); // Update currently hovered annotation if any if (this._hoverAnnotation) { this._setAnnotationHover(this._hoverAnnotation, true); } } get hoverColor() { return this._hoverColor; } /** * The opacity of hotspots when visible (not occluded). * * @attribute * @title Hotspot Opacity * @type {number} * @range [0, 1] * @default 1 */ set opacity(value) { this._opacity = value; } get opacity() { return this._opacity; } /** * The opacity of hotspots when behind geometry. * * @attribute * @title Hotspot Behind Opacity * @type {number} * @range [0, 1] * @default 0.25 */ set behindOpacity(value) { this._behindOpacity = value; } get behindOpacity() { return this._behindOpacity; } // Shared resources /** * @type {HTMLStyleElement | null} * @private */ _styleSheet = null; /** * @type {HTMLElement | null} * @private */ _parentDom = null; /** * @type {Entity | null} * @private */ _camera = null; /** * @type {HTMLDivElement | null} * @private */ _tooltipDom = null; /** * @type {HTMLDivElement | null} * @private */ _titleDom = null; /** * @type {HTMLDivElement | null} * @private */ _textDom = null; /** * @type {Layer[]} * @private */ _layers = []; /** * @type {Mesh | null} * @private */ _mesh = null; // Per-annotation resources /** * Map of Annotation instances to their rendering resources. * @type {Map<Annotation, AnnotationResources>} * @private */ _annotationResources = new Map(); /** * @type {Annotation | null} * @private */ _activeAnnotation = null; /** * @type {Annotation | null} * @private */ _hoverAnnotation = null; /** * Injects required CSS styles into the document. * @private */ _injectStyles() { const size = this._hotspotSize; const css = ` .pc-annotation { display: block; position: absolute; background-color: rgba(0, 0, 0, 0.8); color: white; padding: 8px; border-radius: 4px; font-size: 14px; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji"; pointer-events: none; max-width: 200px; word-wrap: break-word; overflow-x: visible; white-space: normal; width: fit-content; opacity: 0; transition: opacity 0.2s ease-in-out; visibility: hidden; transform: translate(25px, -50%); } .pc-annotation-title { font-weight: bold; margin-bottom: 4px; } /* Add a little triangular arrow on the left edge of the tooltip */ .pc-annotation::before { content: ""; position: absolute; left: -8px; top: 50%; transform: translateY(-50%); width: 0; height: 0; border-top: 8px solid transparent; border-bottom: 8px solid transparent; border-right: 8px solid rgba(0, 0, 0, 0.8); } .pc-annotation-hotspot { display: none; position: absolute; width: ${size + 5}px; height: ${size + 5}px; opacity: 0; cursor: pointer; transform: translate(-50%, -50%); } `; const style = document.createElement('style'); style.textContent = css; document.head.appendChild(style); this._styleSheet = style; } /** * Updates the stylesheet when hotspot size changes. * @private */ _updateStyleSheet() { if (this._styleSheet) { this._styleSheet.remove(); this._styleSheet = null; } this._injectStyles(); } /** * Updates all annotation materials when hotspot color changes. * @private */ _updateAllAnnotationColors() { for (const [annotation, resources] of this._annotationResources) { // Only update non-hovered annotations if (annotation !== this._hoverAnnotation) { resources.materials.forEach((material) => { material.emissive.copy(this._hotspotColor); material.update(); }); } } } /** * Creates a circular hotspot texture. * @param {string} label - Label text to draw on the hotspot * @param {number} [size] - The texture size (should be power of 2) * @param {number} [borderWidth] - The border width in pixels * @returns {Texture} The hotspot texture * @private */ _createHotspotTexture(label, size = 64, borderWidth = 6) { const canvas = document.createElement('canvas'); canvas.width = size; canvas.height = size; const ctx = canvas.getContext('2d'); // First clear with stroke color at zero alpha ctx.fillStyle = 'white'; ctx.globalAlpha = 0; ctx.fillRect(0, 0, size, size); ctx.globalAlpha = 1.0; // Draw dark circle with light border const centerX = size / 2; const centerY = size / 2; const radius = (size / 2) - 4; // Draw main circle ctx.beginPath(); ctx.arc(centerX, centerY, radius, 0, Math.PI * 2); ctx.fillStyle = 'black'; ctx.fill(); // Draw border ctx.beginPath(); ctx.arc(centerX, centerY, radius, 0, Math.PI * 2); ctx.lineWidth = borderWidth; ctx.strokeStyle = 'white'; ctx.stroke(); // Draw text ctx.font = 'bold 32px Arial'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillStyle = 'white'; ctx.fillText(label, Math.floor(canvas.width / 2), Math.floor(canvas.height / 2) + 1); // get pixel data const imageData = ctx.getImageData(0, 0, size, size); const data = imageData.data; // set the color channel of semitransparent pixels to white so the blending at // the edges is correct for (let i = 0; i < data.length; i += 4) { const a = data[i + 3]; if (a < 255) { data[i] = 255; data[i + 1] = 255; data[i + 2] = 255; } } return new Texture(this.app.graphicsDevice, { width: size, height: size, format: PIXELFORMAT_RGBA8, magFilter: FILTER_LINEAR, minFilter: FILTER_LINEAR, mipmaps: false, levels: [new Uint8Array(data.buffer)] }); } /** * Creates a material for hotspot rendering. * @param {Texture} texture - The texture to use for emissive and opacity * @param {object} [options] - Material options * @param {number} [options.opacity] - Base opacity multiplier * @param {boolean} [options.depthTest] - Whether to perform depth testing * @param {boolean} [options.depthWrite] - Whether to write to depth buffer * @returns {StandardMaterial} The configured material * @private */ _createHotspotMaterial(texture, { opacity = 1, depthTest = true, depthWrite = true } = {}) { const material = new StandardMaterial(); material.diffuse = Color.BLACK; material.emissive.copy(this._hotspotColor); material.emissiveMap = texture; material.opacityMap = texture; material.opacity = opacity; material.alphaTest = 0.01; material.blendState = new BlendState( true, BLENDEQUATION_ADD, BLENDMODE_SRC_ALPHA, BLENDMODE_ONE_MINUS_SRC_ALPHA, BLENDEQUATION_ADD, BLENDMODE_ONE, BLENDMODE_ONE ); material.depthTest = depthTest; material.depthWrite = depthWrite; material.cull = CULLFACE_NONE; material.useLighting = false; material.shaderChunks.glsl.add({ 'litUserMainEndVS': depthClamp }); material.shaderChunks.wgsl.add({ 'litUserMainEndVS': depthClampWGSL }); material.update(); return material; } /** * Sets the hover state visual for an annotation. * @param {Annotation} annotation - The annotation * @param {boolean} hover - Whether hovered * @private */ _setAnnotationHover(annotation, hover) { const resources = this._annotationResources.get(annotation); if (!resources) return; resources.materials.forEach((material) => { material.emissive.copy(hover ? this._hoverColor : this._hotspotColor); material.update(); }); annotation.fire('hover', hover); } /** * Shows the tooltip for an annotation. * @param {Annotation} annotation - The annotation to show tooltip for * @private */ _showTooltip(annotation) { this._activeAnnotation = annotation; this._tooltipDom.style.visibility = 'visible'; this._tooltipDom.style.opacity = '1'; this._titleDom.textContent = annotation.title; this._textDom.textContent = annotation.text; annotation.fire('show', annotation); } /** * Hides the tooltip. * @param {Annotation} annotation - The annotation that was active * @private */ _hideTooltip(annotation) { this._activeAnnotation = null; this._tooltipDom.style.opacity = '0'; // Wait for fade out before hiding setTimeout(() => { if (this._tooltipDom && this._activeAnnotation === null) { this._tooltipDom.style.visibility = 'hidden'; } annotation.fire('hide'); }, 200); } /** * Hides all elements when annotation is behind camera. * @param {Annotation} annotation - The annotation * @param {AnnotationResources} resources - The annotation's resources * @private */ _hideAnnotationElements(annotation, resources) { resources.hotspotDom.style.display = 'none'; if (this._activeAnnotation === annotation) { if (this._tooltipDom.style.visibility !== 'hidden') { this._hideTooltip(annotation); } } } /** * Updates screen-space positions of HTML elements. * @param {Annotation} annotation - The annotation * @param {AnnotationResources} resources - The annotation's resources * @param {Vec3} screenPos - Screen coordinate * @private */ _updateAnnotationPositions(annotation, resources, screenPos) { resources.hotspotDom.style.display = 'block'; resources.hotspotDom.style.left = `${screenPos.x}px`; resources.hotspotDom.style.top = `${screenPos.y}px`; if (this._activeAnnotation === annotation) { this._tooltipDom.style.left = `${screenPos.x}px`; this._tooltipDom.style.top = `${screenPos.y}px`; } } /** * Updates 3D rotation and scale of hotspot planes. * @param {Annotation} annotation - The annotation * @param {number} viewDepth - The view-space depth (positive distance along the camera's forward direction) * @private */ _updateAnnotationRotationAndScale(annotation, viewDepth) { const cameraRotation = this._camera.getRotation(); annotation.entity.setRotation(cameraRotation); annotation.entity.rotateLocal(90, 0, 0); // Use view-space depth (not Euclidean distance) to match the projection matrix const canvas = this.app.graphicsDevice.canvas; const screenHeight = canvas.clientHeight; const projMatrix = this._camera.camera.projectionMatrix; const worldSize = (this._hotspotSize / screenHeight) * (2 * viewDepth / projMatrix.data[5]); annotation.entity.setLocalScale(worldSize, worldSize, worldSize); } /** * Handles label change for an annotation. * @param {Annotation} annotation - The annotation * @param {string} label - The new label * @private */ _onLabelChange(annotation, label) { const resources = this._annotationResources.get(annotation); if (!resources) return; // Destroy old texture resources.texture.destroy(); // Create new texture resources.texture = this._createHotspotTexture(label); // Update materials resources.materials.forEach((material) => { material.emissiveMap = resources.texture; material.opacityMap = resources.texture; material.update(); }); } /** * Handles title change for an annotation. * @param {Annotation} annotation - The annotation * @param {string} title - The new title * @private */ _onTitleChange(annotation, title) { if (this._activeAnnotation === annotation) { this._titleDom.textContent = title; } } /** * Handles text change for an annotation. * @param {Annotation} annotation - The annotation * @param {string} text - The new text * @private */ _onTextChange(annotation, text) { if (this._activeAnnotation === annotation) { this._textDom.textContent = text; } } /** * Handles enable event for an annotation. * @param {Annotation} annotation - The annotation * @private */ _onAnnotationEnable(annotation) { const resources = this._annotationResources.get(annotation); if (!resources) return; resources.baseEntity.enabled = true; resources.overlayEntity.enabled = true; resources.hotspotDom.style.display = ''; } /** * Handles disable event for an annotation. * @param {Annotation} annotation - The annotation * @private */ _onAnnotationDisable(annotation) { const resources = this._annotationResources.get(annotation); if (!resources) return; resources.baseEntity.enabled = false; resources.overlayEntity.enabled = false; resources.hotspotDom.style.display = 'none'; if (this._activeAnnotation === annotation) { this._hideTooltip(annotation); } if (this._hoverAnnotation === annotation) { this._hoverAnnotation = null; this._setAnnotationHover(annotation, false); } } /** * Registers an annotation and creates its rendering resources. * Called automatically when an `annotation:add` event is received. * @param {Annotation} annotation - The annotation to register * @private */ _registerAnnotation(annotation) { if (this._annotationResources.has(annotation)) { return; } const eventHandles = []; // Create texture const texture = this._createHotspotTexture(annotation.label); // Create materials const materials = [ this._createHotspotMaterial(texture, { opacity: 1, depthTest: true, depthWrite: true }), this._createHotspotMaterial(texture, { opacity: this._behindOpacity, depthTest: false, depthWrite: false }) ]; // Create base entity const baseEntity = new Entity('base'); const baseMi = new MeshInstance(this._mesh, materials[0]); baseMi.cull = false; baseEntity.addComponent('render', { layers: [this._layers[0].id], meshInstances: [baseMi] }); // Create overlay entity const overlayEntity = new Entity('overlay'); const overlayMi = new MeshInstance(this._mesh, materials[1]); overlayMi.cull = false; overlayEntity.addComponent('render', { layers: [this._layers[1].id], meshInstances: [overlayMi] }); annotation.entity.addChild(baseEntity); annotation.entity.addChild(overlayEntity); // Create hotspot DOM const hotspotDom = document.createElement('div'); hotspotDom.className = 'pc-annotation-hotspot'; // Click handler const onPointerDown = (e) => { e.stopPropagation(); if (this._activeAnnotation === annotation) { this._hideTooltip(annotation); } else { this._showTooltip(annotation); } }; hotspotDom.addEventListener('pointerdown', onPointerDown); // Hover handlers const onPointerEnter = () => { if (this._hoverAnnotation !== null) { this._setAnnotationHover(this._hoverAnnotation, false); } this._hoverAnnotation = annotation; this._setAnnotationHover(annotation, true); }; const onPointerLeave = () => { if (this._hoverAnnotation === annotation) { this._hoverAnnotation = null; this._setAnnotationHover(annotation, false); } }; hotspotDom.addEventListener('pointerenter', onPointerEnter); hotspotDom.addEventListener('pointerleave', onPointerLeave); // Wheel passthrough const onWheel = (e) => { const canvas = this.app.graphicsDevice.canvas; canvas.dispatchEvent(new WheelEvent(e.type, e)); }; hotspotDom.addEventListener('wheel', onWheel); this._parentDom.appendChild(hotspotDom); // Listen for annotation attribute changes eventHandles.push(annotation.on('label:set', label => this._onLabelChange(annotation, label))); eventHandles.push(annotation.on('title:set', title => this._onTitleChange(annotation, title))); eventHandles.push(annotation.on('text:set', text => this._onTextChange(annotation, text))); eventHandles.push(annotation.on('enable', () => this._onAnnotationEnable(annotation))); eventHandles.push(annotation.on('disable', () => this._onAnnotationDisable(annotation))); // Store cleanup function for DOM listeners const domCleanup = () => { hotspotDom.removeEventListener('pointerdown', onPointerDown); hotspotDom.removeEventListener('pointerenter', onPointerEnter); hotspotDom.removeEventListener('pointerleave', onPointerLeave); hotspotDom.removeEventListener('wheel', onWheel); }; // Store resources /** @type {AnnotationResources} */ const resources = { baseEntity, overlayEntity, hotspotDom, texture, materials, eventHandles, domCleanup }; this._annotationResources.set(annotation, resources); } /** * Unregisters an annotation and destroys its rendering resources. * Called automatically when an `annotation:remove` event is received. * @param {Annotation} annotation - The annotation to unregister * @private */ _unregisterAnnotation(annotation) { const resources = this._annotationResources.get(annotation); if (!resources) return; // Clear active/hover state if (this._activeAnnotation === annotation) { this._activeAnnotation = null; this._tooltipDom.style.visibility = 'hidden'; } if (this._hoverAnnotation === annotation) { this._hoverAnnotation = null; } // Unbind event handles resources.eventHandles.forEach(handle => handle.off()); resources.eventHandles.length = 0; // Remove DOM listeners resources.domCleanup(); // Remove DOM element resources.hotspotDom.remove(); // Destroy entities resources.baseEntity.destroy(); resources.overlayEntity.destroy(); // Destroy materials resources.materials.forEach(mat => mat.destroy()); resources.materials.length = 0; // Destroy texture resources.texture.destroy(); this._annotationResources.delete(annotation); } initialize() { // Set parent DOM if (this._parentDom === null) { this._parentDom = document.body; } // Inject styles this._injectStyles(); // Create layers const { layers } = this.app.scene; const worldLayer = layers.getLayerByName('World'); const createLayer = (name, semitrans) => { const layer = new Layer({ name }); const idx = semitrans ? layers.getTransparentIndex(worldLayer) : layers.getOpaqueIndex(worldLayer); layers.insert(layer, idx + 1); return layer; }; this._layers = [ createLayer('HotspotBase', false), createLayer('HotspotOverlay', true) ]; // Find camera if not set if (this._camera === null) { const cameraComponent = this.app.root.findComponent('camera'); if (cameraComponent) { this._camera = cameraComponent.entity; } } // Add layers to camera if (this._camera) { this._camera.camera.layers = [ ...this._camera.camera.layers, ...this._layers.map(layer => layer.id) ]; } // Create shared mesh this._mesh = Mesh.fromGeometry(this.app.graphicsDevice, new PlaneGeometry({ widthSegments: 1, lengthSegments: 1 })); // Initialize tooltip DOM this._tooltipDom = document.createElement('div'); this._tooltipDom.className = 'pc-annotation'; this._titleDom = document.createElement('div'); this._titleDom.className = 'pc-annotation-title'; this._tooltipDom.appendChild(this._titleDom); this._textDom = document.createElement('div'); this._textDom.className = 'pc-annotation-text'; this._tooltipDom.appendChild(this._textDom); this._parentDom.appendChild(this._tooltipDom); // Single document-level listener to dismiss active tooltip const onDocumentPointerDown = () => { if (this._activeAnnotation) { this._hideTooltip(this._activeAnnotation); } }; document.addEventListener('pointerdown', onDocumentPointerDown); // Listen for annotation add/remove events on the app const onAnnotationAdd = annotation => this._registerAnnotation(annotation); const onAnnotationRemove = annotation => this._unregisterAnnotation(annotation); this.app.on('annotation:add', onAnnotationAdd); this.app.on('annotation:remove', onAnnotationRemove); // Prerender handler - update all annotations const prerenderHandler = () => { if (!this._camera) return; for (const [annotation, resources] of this._annotationResources) { if (!annotation.enabled) continue; const position = annotation.entity.getPosition(); const screenPos = this._camera.camera.worldToScreen(position); const { viewMatrix } = this._camera.camera; viewMatrix.transformPoint(position, vec); if (vec.z >= 0) { this._hideAnnotationElements(annotation, resources); continue; } this._updateAnnotationPositions(annotation, resources, screenPos); this._updateAnnotationRotationAndScale(annotation, -vec.z); // Update material opacity resources.materials[0].opacity = this._opacity; resources.materials[1].opacity = this._behindOpacity * this._opacity; resources.materials[0].setParameter('material_opacity', this._opacity); resources.materials[1].setParameter('material_opacity', this._behindOpacity * this._opacity); } }; this.app.on('prerender', prerenderHandler); // Clean up on destroy this.once('destroy', () => { // Unregister all annotations for (const annotation of this._annotationResources.keys()) { this._unregisterAnnotation(annotation); } // Remove event listeners this.app.off('annotation:add', onAnnotationAdd); this.app.off('annotation:remove', onAnnotationRemove); this.app.off('prerender', prerenderHandler); document.removeEventListener('pointerdown', onDocumentPointerDown); // Remove DOM elements if (this._tooltipDom) { this._tooltipDom.remove(); this._tooltipDom = null; } if (this._styleSheet) { this._styleSheet.remove(); this._styleSheet = null; } // Remove layers from camera if (this._camera && this._camera.camera) { const layerIds = this._layers.map(layer => layer.id); this._camera.camera.layers = this._camera.camera.layers.filter( id => !layerIds.includes(id) ); } // Remove layers from scene const { layers } = this.app.scene; this._layers.forEach(layer => layers.remove(layer)); this._layers = []; // Destroy mesh if (this._mesh) { this._mesh.destroy(); this._mesh = null; } }); } } /** * A lightweight data script for creating interactive 3D annotations in a scene. * This script only holds the annotation data (label, title, text) - all rendering * and interaction is handled by an AnnotationManager listening for app events. * * Fires the following app-level events: * - `annotation:add` - when the annotation initializes * - `annotation:remove` - when the annotation is destroyed * * Fires the following script-level events (listened to by AnnotationManager): * - `label:set` - when label changes * - `title:set` - when title changes * - `text:set` - when text changes * - `hover` - when hover state changes * - `show` - when tooltip is shown * - `hide` - when tooltip is hidden */ export class Annotation extends Script { static scriptName = 'annotation'; /** @private */ _label = ''; /** @private */ _title = ''; /** @private */ _text = ''; /** * The short text displayed on the hotspot circle (e.g. "1", "A"). * * @attribute * @title Label * @type {string} */ set label(value) { this._label = value; this.fire('label:set', value); } get label() { return this._label; } /** * The title shown in the tooltip when the hotspot is clicked. * * @attribute * @title Title * @type {string} */ set title(value) { this._title = value; this.fire('title:set', value); } get title() { return this._title; } /** * The description text shown in the tooltip when the hotspot is clicked. * * @attribute * @title Text * @type {string} */ set text(value) { this._text = value; this.fire('text:set', value); } get text() { return this._text; } /** * Called after all scripts have initialized, ensuring the AnnotationManager * is ready to receive the annotation:add event. */ postInitialize() { // Notify any listeners that this annotation has been created this.app.fire('annotation:add', this); // Clean up on destroy this.once('destroy', () => { this.app.fire('annotation:remove', this); }); } }