From 41e9cfc9efed24767c1f271d2bc9d2369b0498c3 Mon Sep 17 00:00:00 2001 From: Marco Gomez Date: Wed, 16 Aug 2023 17:13:32 +0100 Subject: [PATCH] first implementation of a identity and chat labels #15 (#28) * first implementation of a character's id tooltip * adds a fontScale property to increase text definition --- .../src/character/CanvasText.ts | 174 ++++++++++++++++++ .../src/character/Character.ts | 9 + .../src/character/CharacterManager.ts | 2 + .../src/character/CharacterTooltip.ts | 150 +++++++++++++++ 4 files changed, 335 insertions(+) create mode 100644 packages/3d-web-client-core/src/character/CanvasText.ts create mode 100644 packages/3d-web-client-core/src/character/CharacterTooltip.ts diff --git a/packages/3d-web-client-core/src/character/CanvasText.ts b/packages/3d-web-client-core/src/character/CanvasText.ts new file mode 100644 index 00000000..b3f8b1a8 --- /dev/null +++ b/packages/3d-web-client-core/src/character/CanvasText.ts @@ -0,0 +1,174 @@ +import * as THREE from "three"; + +type RGBA = { + r: number; + g: number; + b: number; + a: number; +}; + +type CanvasTextOptions = { + fontSize: number; + textColorRGB255A1: RGBA; + backgroundColorRGB255A1?: RGBA; + font?: string; + bold?: boolean; + paddingPx?: number; + alignment?: string; + dimensions?: { + width: number; + height: number; + }; +}; + +function getTextAlignOffset(textAlign: CanvasTextAlign, width: number) { + switch (textAlign) { + case "center": + return width / 2; + case "right": + return width; + default: + return 0; + } +} + +function printAtWordWrap( + context: CanvasRenderingContext2D, + fullText: string, + x: number, + y: number, + lineHeight: number, + fitWidth: number, + padding: number, + alignment: string, +) { + const lines = fullText.split("\n"); + let currentLine = 0; + for (const text of lines) { + fitWidth = fitWidth || 0; + + if (fitWidth <= 0) { + context.fillText(text, x, y + lineHeight * currentLine); + currentLine++; + continue; + } + let words = text.split(" "); + let lastWordIndex = 1; + while (words.length > 0 && lastWordIndex <= words.length) { + const str = words.slice(0, lastWordIndex).join(" "); + const textWidth = context.measureText(str).width; + if (textWidth + padding * 2 > fitWidth) { + if (lastWordIndex === 1) { + lastWordIndex = 2; + } + context.fillText( + words.slice(0, lastWordIndex - 1).join(" "), + x + padding, + y + lineHeight * currentLine + padding, + ); + currentLine++; + words = words.splice(lastWordIndex - 1); + lastWordIndex = 1; + } else { + lastWordIndex++; + } + } + if (lastWordIndex > 0 && words.length > 0) { + const xOffset = alignment === "center" ? 0 : padding; + context.fillText(words.join(" "), x + xOffset, y + lineHeight * currentLine + padding); + currentLine++; + } + } +} + +export function CanvasText(message: string, options: CanvasTextOptions): HTMLCanvasElement { + const fontsize = options.fontSize; + const textColor = options.textColorRGB255A1; + const backgroundColor = options.backgroundColorRGB255A1 || { r: 255, g: 255, b: 255, a: 1 }; + const padding = options.paddingPx || 0; + const font = options.font || "Arial"; + const fontString = (options.bold ? "bold " : "") + fontsize + "px " + font; + + const canvas = document.createElement("canvas"); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const ct = canvas.getContext("2d")!; + + // calculate text alignment offset + const textAlign = (options.alignment as CanvasTextAlign) ?? "left"; + + if (options.dimensions) { + // NOTE: setting the canvas dimensions resets the context properties, so + // we always do it first + canvas.width = options.dimensions.width; + canvas.height = options.dimensions.height; + ct.clearRect(0, 0, canvas.width, canvas.height); + ct.font = fontString; + ct.textAlign = textAlign; + ct.fillStyle = `rgba(${backgroundColor.r}, ${backgroundColor.g}, ${backgroundColor.b}, ${backgroundColor.a})`; + ct.lineWidth = 0; + ct.fillRect(0, 0, canvas.width, canvas.height); + ct.fillStyle = `rgba(${textColor.r}, ${textColor.g}, ${textColor.b}, ${textColor.a})`; + ct.font = fontString; + printAtWordWrap( + ct, + message, + getTextAlignOffset(textAlign, canvas.width), + fontsize, + fontsize, + canvas.width, + padding, + textAlign, + ); + } else { + // NOTE: setting the canvas dimensions resets the context properties, so + // we always do it first. However, we also need to take into account the + // font size to measure the text in the first place. + ct.font = fontString; + const metrics = ct.measureText(message); + const textWidth = metrics.width; + const textHeight = metrics.fontBoundingBoxAscent + metrics.fontBoundingBoxDescent; + canvas.width = textWidth + padding * 2; + canvas.height = textHeight + padding; + ct.clearRect(0, 0, canvas.width, canvas.height); + ct.font = fontString; + ct.textAlign = textAlign; + ct.fillStyle = `rgba(${backgroundColor.r}, ${backgroundColor.g}, ${backgroundColor.b}, ${backgroundColor.a})`; + ct.lineWidth = 0; + ct.fillRect(0, 0, canvas.width, canvas.height); + ct.fillStyle = `rgba(${textColor.r}, ${textColor.g}, ${textColor.b}, ${textColor.a})`; + ct.font = fontString; + ct.fillText(message, padding + getTextAlignOffset(textAlign, textWidth), textHeight); + } + + return canvas; +} + +export function THREECanvasTextTexture( + text: string, + options: CanvasTextOptions, +): { texture: THREE.Texture; width: number; height: number } { + const canvas = CanvasText(text, options); + + const texture = new THREE.Texture(canvas); + texture.minFilter = THREE.LinearFilter; + texture.magFilter = THREE.LinearFilter; + texture.format = THREE.RGBAFormat; + texture.needsUpdate = true; + + return { texture, width: canvas.width, height: canvas.height }; +} + +export function THREECanvasTextMaterial( + text: string, + options: CanvasTextOptions, +): { material: THREE.MeshBasicMaterial; width: number; height: number } { + const { texture, width, height } = THREECanvasTextTexture(text, options); + + const material = new THREE.MeshBasicMaterial(); + material.map = texture; + material.transparent = true; + material.depthWrite = false; + material.needsUpdate = true; + + return { material, width, height }; +} diff --git a/packages/3d-web-client-core/src/character/Character.ts b/packages/3d-web-client-core/src/character/Character.ts index f377652f..e7d7ad71 100644 --- a/packages/3d-web-client-core/src/character/Character.ts +++ b/packages/3d-web-client-core/src/character/Character.ts @@ -6,6 +6,7 @@ import { KeyInputManager } from "../input/KeyInputManager"; import { TimeManager } from "../time/TimeManager"; import { CharacterModel } from "./CharacterModel"; +import { CharacterTooltip } from "./CharacterTooltip"; import { LocalController } from "./LocalController"; export type CharacterDescription = { @@ -26,6 +27,8 @@ export class Character { public position: Vector3 = new Vector3(); + public tooltip: CharacterTooltip | null = null; + constructor( private readonly characterDescription: CharacterDescription, private readonly id: number, @@ -42,6 +45,9 @@ export class Character { private async load(): Promise { this.model = new CharacterModel(this.characterDescription); await this.model.init(); + if (this.tooltip === null) { + this.tooltip = new CharacterTooltip(this.model.mesh!); + } this.color = this.model.material.colorsCube216[this.id]; if (this.isLocal) { this.controller = new LocalController( @@ -58,6 +64,9 @@ export class Character { public update(time: number) { if (!this.model) return; + if (this.tooltip) { + this.tooltip.update(this.cameraManager.camera); + } this.model.mesh!.getWorldPosition(this.position); if (typeof this.model.material.uniforms.time !== "undefined") { this.model.material.uniforms.time.value = time; diff --git a/packages/3d-web-client-core/src/character/CharacterManager.ts b/packages/3d-web-client-core/src/character/CharacterManager.ts index d9a7147f..df840a2b 100644 --- a/packages/3d-web-client-core/src/character/CharacterManager.ts +++ b/packages/3d-web-client-core/src/character/CharacterManager.ts @@ -111,6 +111,7 @@ export class CharacterManager { if (isLocal) { this.character = character; + this.character.tooltip?.setText(`${id}`); } else { this.remoteCharacters.set(id, character); const remoteController = new RemoteController(character, id); @@ -136,6 +137,7 @@ export class CharacterManager { spawnPosition.z, ); this.remoteCharacterControllers.set(id, remoteController); + character.tooltip?.setText(`${id}`); } resolve(character); }, diff --git a/packages/3d-web-client-core/src/character/CharacterTooltip.ts b/packages/3d-web-client-core/src/character/CharacterTooltip.ts new file mode 100644 index 00000000..40be403a --- /dev/null +++ b/packages/3d-web-client-core/src/character/CharacterTooltip.ts @@ -0,0 +1,150 @@ +import { + Camera, + CanvasTexture, + Color, + FrontSide, + LinearFilter, + Mesh, + MeshBasicMaterial, + Object3D, + PlaneGeometry, +} from "three"; + +import { THREECanvasTextTexture } from "./CanvasText"; + +enum LabelAlignment { + left = "left", + center = "center", + right = "right", +} + +const fontScale = 5; +const defaultLabelColor = new Color(0x000000); +const defaultFontColor = new Color(0xffffff); +const defaultLabelAlignment = LabelAlignment.center; +const defaultLabelFontSize = 9; +const defaultLabelPadding = 0; +const defaultLabelWidth = 0.25; +const defaultLabelHeight = 0.125; +const defaultLabelCastShadows = true; + +export class CharacterTooltip { + private texture: CanvasTexture; + + private geometry: PlaneGeometry; + private material: MeshBasicMaterial; + private mesh: Mesh; + + private visibleOpacity: number = 0.85; + private targetOpacity: number = 0; + private fadingSpeed: number = 0.02; + private secondsToFadeOut: number = 15; + + private props = { + content: "", + alignment: defaultLabelAlignment, + width: defaultLabelWidth, + height: defaultLabelHeight, + fontSize: defaultLabelFontSize, + padding: defaultLabelPadding, + color: defaultLabelColor, + fontColor: defaultFontColor, + castShadows: defaultLabelCastShadows, + }; + + constructor(parentModel: Object3D) { + this.setText = this.setText.bind(this); + this.material = new MeshBasicMaterial({ + map: this.texture, + transparent: true, + opacity: 0, + }); + this.material.side = FrontSide; + this.geometry = new PlaneGeometry(1, 1, 1, 1); + this.mesh = new Mesh(this.geometry, this.material); + this.mesh.position.set(0, 1.6, 0); + this.mesh.visible = false; + parentModel.add(this.mesh); + } + + private redrawText(content: string) { + if (!this.material) { + return; + } + if (this.material.map) { + this.material.map.dispose(); + } + const { texture, width, height } = THREECanvasTextTexture(content, { + bold: true, + fontSize: this.props.fontSize * fontScale, + paddingPx: this.props.padding, + textColorRGB255A1: { + r: this.props.fontColor.r * 255, + g: this.props.fontColor.g * 255, + b: this.props.fontColor.b * 255, + a: 1.0, + }, + backgroundColorRGB255A1: { + r: this.props.color.r * 255, + g: this.props.color.g * 255, + b: this.props.color.b * 255, + a: 1.0, + }, + dimensions: { + width: this.props.width * (100 * fontScale), + height: this.props.height * (100 * fontScale), + }, + alignment: this.props.alignment, + }); + + this.material.map = texture; + this.material.map.magFilter = LinearFilter; + this.material.map.minFilter = LinearFilter; + this.material.needsUpdate = true; + + this.mesh.scale.x = width / (100 * fontScale); + this.mesh.scale.y = height / (100 * fontScale); + this.mesh.position.y = 1.6; + } + + setText(text: string, temporary: boolean = false) { + this.redrawText(text); + this.mesh.visible = true; + this.targetOpacity = this.visibleOpacity; + if (temporary) { + setTimeout(() => { + this.hide(); + }, this.secondsToFadeOut * 1000); + } + } + + hide() { + this.targetOpacity = 0; + } + + update(camera: Camera) { + this.mesh.lookAt(camera.position); + const opacity = this.mesh.material.opacity; + if (opacity < this.targetOpacity) { + this.mesh.material.opacity = Math.min( + this.mesh.material.opacity + this.fadingSpeed, + this.targetOpacity, + ); + } else if (opacity > this.targetOpacity) { + this.mesh.material.opacity = Math.max( + this.mesh.material.opacity - this.fadingSpeed, + this.targetOpacity, + ); + if (opacity >= 1 && this.mesh.material.transparent === true) { + this.mesh.material.transparent = false; + this.mesh.material.needsUpdate = true; + } else if (opacity > 0 && opacity < 1 && this.mesh.material.transparent === false) { + this.mesh.material.transparent = true; + this.mesh.material.needsUpdate = true; + } + if (this.mesh.material.opacity <= 0) { + this.mesh.visible = false; + } + } + } +}