Skip to content

Commit

Permalink
first implementation of a identity and chat labels #15 (#28)
Browse files Browse the repository at this point in the history
* first implementation of a character's id tooltip

* adds a fontScale property to increase text definition
  • Loading branch information
TheCodeTherapy authored Aug 16, 2023
1 parent 74874a9 commit 41e9cfc
Show file tree
Hide file tree
Showing 4 changed files with 335 additions and 0 deletions.
174 changes: 174 additions & 0 deletions packages/3d-web-client-core/src/character/CanvasText.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
9 changes: 9 additions & 0 deletions packages/3d-web-client-core/src/character/Character.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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,
Expand All @@ -42,6 +45,9 @@ export class Character {
private async load(): Promise<void> {
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(
Expand All @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions packages/3d-web-client-core/src/character/CharacterManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -136,6 +137,7 @@ export class CharacterManager {
spawnPosition.z,
);
this.remoteCharacterControllers.set(id, remoteController);
character.tooltip?.setText(`${id}`);
}
resolve(character);
},
Expand Down
150 changes: 150 additions & 0 deletions packages/3d-web-client-core/src/character/CharacterTooltip.ts
Original file line number Diff line number Diff line change
@@ -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<PlaneGeometry, MeshBasicMaterial>;

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;
}
}
}
}

0 comments on commit 41e9cfc

Please sign in to comment.