playcanvas
Version:
PlayCanvas WebGL game engine
617 lines (519 loc) • 19 kB
JavaScript
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 { Application, CameraComponent, Quat } 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();
/**
* A script for creating interactive 3D annotations in a scene. Each annotation consists of:
*
* - A 3D hotspot that maintains constant screen-space size. The hotspot is rendered with muted
* appearance when obstructed by geometry but is still clickable. The hotspot relies on an
* invisible DOM element that matches the hotspot's size and position to detect clicks.
* - An annotation panel that shows title and description text.
*/
export class Annotation extends Script {
static scriptName = 'annotation';
/** @type {number} */
static hotspotSize = 25;
/** @type {Color} */
static hotspotColor = new Color(0.8, 0.8, 0.8);
/** @type {Color} */
static hoverColor = new Color(1.0, 0.4, 0.0);
/** @type {HTMLElement | null} */
static parentDom = null;
/** @type {HTMLStyleElement | null} */
static styleSheet = null;
/** @type {Entity | null} */
static camera = null;
/** @type {HTMLDivElement | null} */
static tooltipDom = null;
/** @type {HTMLDivElement | null} */
static titleDom = null;
/** @type {HTMLDivElement | null} */
static textDom = null;
/** @type {Layer[]} */
static layers = [];
/** @type {Mesh | null} */
static mesh = null;
/** @type {Annotation | null} */
static activeAnnotation = null;
/** @type {Annotation | null} */
static hoverAnnotation = null;
/** @type {number} */
static opacity = 1.0;
/**
* @type {string}
* @attribute
*/
label;
/**
* @type {string}
* @attribute
*/
title;
/**
* @type {string}
* @attribute
*/
text;
/**
* @type {HTMLDivElement | null}
* @private
*/
hotspotDom = null;
/**
* @type {Texture | null}
* @private
*/
texture = null;
/**
* @type {StandardMaterial[]}
* @private
*/
materials = [];
/**
* Injects required CSS styles into the document.
* @param {number} size - The size of the hotspot in screen pixels.
* @private
*/
static _injectStyles(size) {
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);
Annotation.styleSheet = style;
}
/**
* Initialize static resources.
* @param {Application} app - The application instance
* @private
*/
static _initializeStatic(app) {
if (Annotation.styleSheet) {
return;
}
Annotation._injectStyles(Annotation.hotspotSize);
if (Annotation.parentDom === null) {
Annotation.parentDom = document.body;
}
const { layers } = app.scene;
const worldLayer = layers.getLayerByName('World');
const createLayer = (name, semitrans) => {
const layer = new Layer({ name: name });
const idx = semitrans ? layers.getTransparentIndex(worldLayer) : layers.getOpaqueIndex(worldLayer);
layers.insert(layer, idx + 1);
return layer;
};
Annotation.layers = [
createLayer('HotspotBase', false),
createLayer('HotspotOverlay', true)
];
if (Annotation.camera === null) {
Annotation.camera = app.root.findComponent('camera').entity;
}
Annotation.camera.camera.layers = [
...Annotation.camera.camera.layers,
...Annotation.layers.map(layer => layer.id)
];
Annotation.mesh = Mesh.fromGeometry(app.graphicsDevice, new PlaneGeometry({
widthSegments: 1,
lengthSegments: 1
}));
// Initialize tooltip dom
Annotation.tooltipDom = document.createElement('div');
Annotation.tooltipDom.className = 'pc-annotation';
Annotation.titleDom = document.createElement('div');
Annotation.titleDom.className = 'pc-annotation-title';
Annotation.tooltipDom.appendChild(Annotation.titleDom);
Annotation.textDom = document.createElement('div');
Annotation.textDom.className = 'pc-annotation-text';
Annotation.tooltipDom.appendChild(Annotation.textDom);
Annotation.parentDom.appendChild(Annotation.tooltipDom);
}
/**
* Creates a circular hotspot texture.
* @param {Application} app - The PlayCanvas application
* @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
*/
static _createHotspotTexture(app, label, size = 64, borderWidth = 6) {
// Create canvas for hotspot texture
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; // Leave space for border
// 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;
}
}
const texture = new Texture(app.graphicsDevice, {
width: size,
height: size,
format: PIXELFORMAT_RGBA8,
magFilter: FILTER_LINEAR,
minFilter: FILTER_LINEAR,
mipmaps: false,
levels: [new Uint8Array(data.buffer)]
});
return texture;
}
/**
* 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
*/
static _createHotspotMaterial(texture, { opacity = 1, depthTest = true, depthWrite = true } = {}) {
const material = new StandardMaterial();
// Base properties
material.diffuse = Color.BLACK;
material.emissive.copy(Annotation.hotspotColor);
material.emissiveMap = texture;
material.opacityMap = texture;
// Alpha properties
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
);
// Depth properties
material.depthTest = depthTest;
material.depthWrite = depthWrite;
// Rendering properties
material.cull = CULLFACE_NONE;
material.useLighting = false;
material.shaderChunks.glsl.add({
'litUserMainEndVS': depthClamp
});
material.shaderChunks.wgsl.add({
'litUserMainEndVS': depthClampWGSL
});
material.update();
return material;
}
initialize() {
// Ensure static resources are initialized
Annotation._initializeStatic(this.app);
// Create texture
this.texture = Annotation._createHotspotTexture(this.app, this.label);
// Create the base and overlay material
this.materials = [
Annotation._createHotspotMaterial(this.texture, {
opacity: 1,
depthTest: true,
depthWrite: true
}),
Annotation._createHotspotMaterial(this.texture, {
opacity: 0.25,
depthTest: false,
depthWrite: false
})
];
const base = new Entity('base');
const baseMi = new MeshInstance(Annotation.mesh, this.materials[0]);
baseMi.cull = false;
base.addComponent('render', {
layers: [Annotation.layers[0].id],
meshInstances: [baseMi]
});
const overlay = new Entity('overlay');
const overlayMi = new MeshInstance(Annotation.mesh, this.materials[1]);
overlayMi.cull = false;
overlay.addComponent('render', {
layers: [Annotation.layers[1].id],
meshInstances: [overlayMi]
});
this.entity.addChild(base);
this.entity.addChild(overlay);
// Create hotspot dom
this.hotspotDom = document.createElement('div');
this.hotspotDom.className = 'pc-annotation-hotspot';
// Add click handlers
this.hotspotDom.addEventListener('click', (e) => {
e.stopPropagation();
this.showTooltip();
});
const leave = () => {
if (Annotation.hoverAnnotation === this) {
Annotation.hoverAnnotation = null;
this.setHover(false);
}
};
const enter = () => {
if (Annotation.hoverAnnotation !== null) {
Annotation.hoverAnnotation.setHover(false);
}
Annotation.hoverAnnotation = this;
this.setHover(true);
};
this.hotspotDom.addEventListener('pointerenter', enter);
this.hotspotDom.addEventListener('pointerleave', leave);
document.addEventListener('click', () => {
this.hideTooltip();
});
Annotation.parentDom.appendChild(this.hotspotDom);
// Store prerender handler for cleanup
const prerenderHandler = () => {
if (!Annotation.camera) return;
const position = this.entity.getPosition();
const screenPos = Annotation.camera.camera.worldToScreen(position);
const { viewMatrix } = Annotation.camera.camera;
viewMatrix.transformPoint(position, vec);
if (vec.z >= 0) {
this._hideElements();
return;
}
this._updatePositions(screenPos);
this._updateRotationAndScale();
// update material opacity and also directly on the uniform so we
// can avoid a full material update
this.materials[0].opacity = Annotation.opacity;
this.materials[1].opacity = 0.25 * Annotation.opacity;
this.materials[0].setParameter('material_opacity', Annotation.opacity);
this.materials[1].setParameter('material_opacity', 0.25 * Annotation.opacity);
};
this.app.on('prerender', prerenderHandler);
// Clean up on entity destruction
this.on('destroy', () => {
this.hotspotDom.remove();
if (Annotation.activeAnnotation === this) {
this.hideTooltip();
}
this.materials.forEach(mat => mat.destroy());
this.materials = [];
this.texture.destroy();
this.texture = null;
// Remove prerender event listener
this.app.off('prerender', prerenderHandler);
});
}
/**
* Set the hover state of the annotation.
* @param {boolean} hover - Whether the annotation is hovered
* @private
*/
setHover(hover) {
this.materials.forEach((material) => {
material.emissive.copy(hover ? Annotation.hoverColor : Annotation.hotspotColor);
material.update();
});
this.fire('hover', hover);
}
/**
* @private
*/
showTooltip() {
Annotation.activeAnnotation = this;
Annotation.tooltipDom.style.visibility = 'visible';
Annotation.tooltipDom.style.opacity = '1';
Annotation.titleDom.textContent = this.title;
Annotation.textDom.textContent = this.text;
this.fire('show', this);
}
/**
* @private
*/
hideTooltip() {
Annotation.activeAnnotation = null;
Annotation.tooltipDom.style.opacity = '0';
// Wait for fade out before hiding
setTimeout(() => {
if (Annotation.tooltipDom.style.opacity === '0') {
Annotation.tooltipDom.style.visibility = 'hidden';
}
this.fire('hide');
}, 200); // Match the transition duration
}
/**
* Hide all elements when annotation is behind camera.
* @private
*/
_hideElements() {
this.hotspotDom.style.display = 'none';
if (Annotation.activeAnnotation === this) {
if (Annotation.tooltipDom.style.visibility !== 'hidden') {
this.hideTooltip();
}
}
}
/**
* Update screen-space positions of HTML elements.
* @param {Vec3} screenPos - Screen coordinate
* @private
*/
_updatePositions(screenPos) {
// Show and position hotspot
this.hotspotDom.style.display = 'block';
this.hotspotDom.style.left = `${screenPos.x}px`;
this.hotspotDom.style.top = `${screenPos.y}px`;
// Position tooltip
if (Annotation.activeAnnotation === this) {
Annotation.tooltipDom.style.left = `${screenPos.x}px`;
Annotation.tooltipDom.style.top = `${screenPos.y}px`;
}
}
/**
* Update 3D rotation and scale of hotspot planes.
* @private
*/
_updateRotationAndScale() {
// Copy camera rotation to align with view plane
const cameraRotation = Annotation.camera.getRotation();
this._updateHotspotTransform(this.entity, cameraRotation);
// Calculate scale based on distance to maintain constant screen size
const scale = this._calculateScreenSpaceScale();
this.entity.setLocalScale(scale, scale, scale);
}
/**
* Update rotation of a single hotspot entity.
* @param {Entity} hotspot - The hotspot entity to update
* @param {Quat} cameraRotation - The camera's current rotation
* @private
*/
_updateHotspotTransform(hotspot, cameraRotation) {
hotspot.setRotation(cameraRotation);
hotspot.rotateLocal(90, 0, 0);
}
/**
* Calculate scale factor to maintain constant screen-space size.
* @returns {number} The scale to apply to hotspot entities
* @private
*/
_calculateScreenSpaceScale() {
const cameraPos = Annotation.camera.getPosition();
const toAnnotation = this.entity.getPosition().clone().sub(cameraPos);
const distance = toAnnotation.length();
// Use the canvas's CSS/client height instead of graphics device height
const canvas = this.app.graphicsDevice.canvas;
const screenHeight = canvas.clientHeight;
// Get the camera's projection matrix vertical scale factor
const projMatrix = Annotation.camera.camera.projectionMatrix;
const worldSize = (Annotation.hotspotSize / screenHeight) * (2 * distance / projMatrix.data[5]);
return worldSize;
}
}