大纲
- 场景初始化:Renderer/Camera/Controls 的骨架搭建
- 模型点击事件交互链路:命中检测到信息卡弹出
- 信息卡核心实现:高清纹理、屏幕空间缩放、水平跟随
- 动态宽度与竖排布局:按内容测量生成画布
- 踩坑笔记:清晰度、对齐、缩放
背景
在3D场景中点击某个模型时,需要弹出一张高清、可跟随相机的"信息卡"。这个功能看似简单,但尺寸、清晰度、跟随逻辑等细节容易踩坑。本文将从零开始搭建一个完整的 Demo,展示如何用 Three.js 实现这个功能。
1. 场景初始化骨架
首先创建场景管理器,负责初始化渲染器、相机和控制器:
文件:src/scene/SceneManager.ts
import * as THREE from 'three';
import { RendererManager } from './RendererManager';
import { CameraManager } from './CameraManager';
import { ControlsManager } from './ControlsManager';
import { ModelManager } from './ModelManager';
export class SceneManager {
private scene: THREE.Scene;
private rendererManager: RendererManager;
private cameraManager: CameraManager;
private controlsManager: ControlsManager;
private modelManager: ModelManager;
private animationFrameId: number | null = null;
constructor(container: HTMLElement) {
// 初始化核心组件
this.scene = new THREE.Scene();
this.rendererManager = new RendererManager(container);
this.cameraManager = new CameraManager(container);
this.controlsManager = new ControlsManager(
this.cameraManager.instance,
this.rendererManager.instance.domElement
);
this.modelManager = new ModelManager(
this.scene,
this.rendererManager.instance,
this.cameraManager.instance
);
// 启动动画循环
this.animate();
}
private animate = () => {
this.controlsManager.update();
// 每帧传入相机,用于信息卡的 billboard 更新
this.modelManager.update(this.cameraManager.instance);
this.rendererManager.instance.render(
this.scene,
this.cameraManager.instance
);
this.animationFrameId = requestAnimationFrame(this.animate);
};
dispose() {
if (this.animationFrameId) {
cancelAnimationFrame(this.animationFrameId);
}
this.modelManager.dispose();
this.controlsManager.dispose();
this.rendererManager.dispose();
}
}要点:让 ModelManager.update(camera) 每帧拿到相机引用,后续信息卡的屏幕空间缩放和朝向都依赖它。
2. 交互链路:命中 → 显示卡片
在 ModelManager 中集成交互管理器和信息卡管理器:
文件:src/scene/ModelManager.ts
import * as THREE from 'three';
import { WorkerInteractionManager } from './WorkerInteractionManager';
import { WorkerInfoCardManager } from './WorkerInfoCardManager';
export class ModelManager {
private workerInteractionManager: WorkerInteractionManager;
private workerInfoCardManager: WorkerInfoCardManager;
constructor(
scene: THREE.Scene,
renderer: THREE.WebGLRenderer,
camera: THREE.PerspectiveCamera
) {
// 初始化交互和信息卡管理器
this.workerInteractionManager = new WorkerInteractionManager();
this.workerInfoCardManager = new WorkerInfoCardManager(scene);
// 设置上下文
this.workerInteractionManager.setContext(
camera,
renderer,
renderer.domElement
);
this.workerInfoCardManager.setContext(camera, renderer);
// 点击命中后展示信息卡
this.workerInteractionManager.setOnSelect((personId, object, details) => {
if (!personId || !object) {
this.workerInfoCardManager.hide();
return;
}
this.workerInfoCardManager.show(personId, object, {
personId: details?.personId,
personName: details?.personName,
distance: details?.distance ?? null,
voltage: details?.voltage ?? null,
rssi: details?.rssi ?? null,
isSos: details?.isSos ?? false,
});
});
}
update(camera: THREE.Camera) {
// 更新信息卡的 billboard 效果
this.workerInfoCardManager.updateBillboard(camera);
}
dispose() {
this.workerInteractionManager.dispose();
this.workerInfoCardManager.dispose();
}
}WorkerInteractionManager 内部使用 THREE.Raycaster 进行点击检测:
文件:src/scene/WorkerInteractionManager.ts
import * as THREE from 'three';
export class WorkerInteractionManager {
private raycaster = new THREE.Raycaster();
private pointer = new THREE.Vector2();
private camera!: THREE.Camera;
private renderer!: THREE.WebGLRenderer;
private onSelectCallback?: (
personId: string,
object: THREE.Object3D,
details: any
) => void;
setContext(
camera: THREE.Camera,
renderer: THREE.WebGLRenderer,
domElement: HTMLElement
) {
this.camera = camera;
this.renderer = renderer;
domElement.addEventListener('pointerup', this.handlePointerUp);
}
setOnSelect(callback: typeof this.onSelectCallback) {
this.onSelectCallback = callback;
}
private handlePointerUp = (event: PointerEvent) => {
// 更新 pointer 为 NDC 坐标
this.updatePointer(event);
// 射线检测
this.raycaster.setFromCamera(this.pointer, this.camera);
// 实际项目中这里会对工人模型进行检测
// const intersects = this.raycaster.intersectObjects(workerObjects, true);
// 示例:假设检测到了工人
// if (intersects.length > 0) {
// this.onSelectCallback?.('worker-001', intersects[0].object, details);
// }
};
private updatePointer(event: PointerEvent) {
const rect = this.renderer.domElement.getBoundingClientRect();
// 转换为 NDC 坐标 (-1 到 1)
this.pointer.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
this.pointer.y = -(((event.clientY - rect.top) / rect.height) * 2 - 1);
}
dispose() {
// 清理事件监听
}
}核心:将 DOM 坐标转换为归一化设备坐标(NDC),范围为 [-1, 1],用于射线检测。
3. 信息卡核心:高清纹理 + 屏幕空间缩放 + 水平跟随
文件:src/scene/WorkerInfoCardManager.ts
3.1 绘制高清纹理
根据内容测量宽度,按设备像素比放大画布,关闭 mipmap 并开启各向异性,保证清晰:
private createCardTexture(details: PersonDetails): THREE.CanvasTexture {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d')!;
// 测量并计算逻辑尺寸
const { logicalWidth, logicalHeight } = this.measureCardSize(details);
// 按设备像素比放大画布
const textureScale = this.getTextureScale(); // Math.min(devicePixelRatio, 3)
canvas.width = Math.round(logicalWidth * textureScale);
canvas.height = Math.round(logicalHeight * textureScale);
ctx.scale(textureScale, textureScale);
// 绘制内容
this.drawCardContent(ctx, details, logicalWidth, logicalHeight);
// 创建纹理并优化质量
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
texture.generateMipmaps = false; // 禁用 mipmap
texture.minFilter = THREE.LinearFilter; // 线性过滤
texture.magFilter = THREE.LinearFilter;
texture.anisotropy = this.renderer.capabilities.getMaxAnisotropy();
texture.needsUpdate = true;
return texture;
}
private getTextureScale(): number {
const dpr = this.renderer.getPixelRatio();
return Math.min(dpr, 3); // 最高 3 倍
}关键点:
- 画布尺寸要按
devicePixelRatio放大 - 禁用 mipmap 避免模糊
- 使用线性过滤 + 最大各向异性
- 设置 sRGB 色彩空间
3.2 屏幕空间缩放 + 父级缩放抵消
让信息卡在屏幕上保持恒定大小,不受相机距离影响:
private updateCardScale(camera: THREE.PerspectiveCamera, cardGroup: THREE.Group) {
// 计算卡片到相机的距离
const worldPos = new THREE.Vector3();
cardGroup.getWorldPosition(worldPos);
const distance = camera.position.distanceTo(worldPos);
// 计算屏幕空间缩放
const pixelHeight = 200; // 期望的屏幕像素高度
const scale = this.computeScale(distance, pixelHeight, camera);
// 抵消父节点的世界缩放
const parentScale = this.getParentWorldScale(cardGroup);
const adjustedScale = scale / parentScale;
cardGroup.scale.setScalar(adjustedScale);
}
private computeScale(
distance: number,
pixelHeight: number,
camera: THREE.PerspectiveCamera
): number {
// 透视相机:使用视锥公式
const vFov = THREE.MathUtils.degToRad(camera.fov);
const viewportHeight = this.renderer.domElement.clientHeight;
const worldHeight = 2 * Math.tan(vFov / 2) * distance;
const pixelToWorld = worldHeight / viewportHeight;
return pixelHeight * pixelToWorld;
}
private getParentWorldScale(object: THREE.Object3D): number {
const worldScale = new THREE.Vector3();
object.parent?.getWorldScale(worldScale);
// 取 XYZ 平均值
return (worldScale.x + worldScale.y + worldScale.z) / 3;
}核心公式:
- 视口世界高度 =
2 * tan(fov/2) * distance - 像素到世界单位转换 =
worldHeight / viewportHeight - 最终缩放 =
pixelHeight * pixelToWorld / parentScale
3.3 水平跟随相机(不随俯仰)
只让卡片绕垂直轴转向相机,保持横平:
updateBillboard(camera: THREE.Camera) {
if (!this.cardGroup.visible) return;
// 获取卡片世界位置
const worldPos = new THREE.Vector3();
this.cardGroup.getWorldPosition(worldPos);
// 创建目标点:锁定 Y 轴高度
const target = new THREE.Vector3().copy(camera.position);
target.y = worldPos.y;
// 防止 lookAt 异常(当相机正好在卡片上方时)
if (target.distanceToSquared(worldPos) < 1e-6) {
target.copy(camera.position);
}
this.cardGroup.lookAt(target);
// 更新缩放
this.updateCardScale(camera as THREE.PerspectiveCamera, this.cardGroup);
}要点:锁定目标点的 Y 坐标与卡片相同,实现只在水平面旋转。
4. 动态宽度与竖排布局
不写死宽度,而是根据内容动态测量:
private measureCardSize(details: PersonDetails): {
logicalWidth: number;
logicalHeight: number;
} {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d')!;
// 设置字体用于测量
ctx.font = 'bold 18px Arial';
const titleWidth = ctx.measureText(details.personName || '-').width;
ctx.font = '14px Arial';
const infoLines = [
{ label: 'ID', value: this.truncate(details.personId ?? '-', 10) },
{ label: '距离', value: details.distance != null
? `${details.distance.toFixed(2)} m` : '--' },
{ label: '电压', value: details.voltage != null
? `${details.voltage.toFixed(1)} V` : '--' },
{ label: '信号', value: details.rssi != null
? `${details.rssi.toFixed(0)} dBm` : '--' },
];
let maxContentWidth = titleWidth;
// 计算每行信息的宽度
infoLines.forEach(line => {
const labelWidth = ctx.measureText(`${line.label}:`).width;
const valueWidth = ctx.measureText(line.value).width;
const lineWidth = labelWidth + 8 + valueWidth; // 8px 间距
maxContentWidth = Math.max(maxContentWidth, lineWidth);
});
// SOS 徽章宽度
if (details.isSos) {
ctx.font = 'bold 12px Arial';
const badgeWidth = ctx.measureText('SOS').width + 16;
maxContentWidth = Math.max(maxContentWidth, badgeWidth);
}
const padding = 16;
const lineHeight = 20;
const titleHeight = 24;
const logicalWidth = maxContentWidth + padding * 2;
const logicalHeight = titleHeight + infoLines.length * lineHeight
+ (details.isSos ? 24 : 0) + padding * 2;
return { logicalWidth, logicalHeight };
}
private truncate(text: string, maxLength: number): string {
return text.length > maxLength
? text.substring(0, maxLength) + '...'
: text;
}绘制时字段竖排,每行"标签: 值":
private drawCardContent(
ctx: CanvasRenderingContext2D,
details: PersonDetails,
width: number,
height: number
) {
// 背景
ctx.fillStyle = 'rgba(0, 0, 0, 0.85)';
ctx.fillRect(0, 0, width, height);
const padding = 16;
let y = padding;
// 标题
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 18px Arial';
ctx.fillText(details.personName || '-', padding, y + 18);
y += 30;
// 信息行
ctx.font = '14px Arial';
const lines = [
{ label: 'ID', value: this.truncate(details.personId ?? '-', 10) },
{ label: '距离', value: details.distance != null
? `${details.distance.toFixed(2)} m` : '--' },
{ label: '电压', value: details.voltage != null
? `${details.voltage.toFixed(1)} V` : '--' },
{ label: '信号', value: details.rssi != null
? `${details.rssi.toFixed(0)} dBm` : '--' },
];
lines.forEach(line => {
ctx.fillStyle = '#999';
ctx.fillText(`${line.label}:`, padding, y + 14);
ctx.fillStyle = '#fff';
const labelWidth = ctx.measureText(`${line.label}:`).width;
ctx.fillText(line.value, padding + labelWidth + 8, y + 14);
y += 20;
});
// SOS 徽章
if (details.isSos) {
y += 8;
ctx.fillStyle = '#ff4444';
ctx.fillRect(padding, y, 50, 20);
ctx.fillStyle = '#fff';
ctx.font = 'bold 12px Arial';
ctx.fillText('SOS', padding + 8, y + 14);
}
}5. 踩坑与对照
模糊问题
- ❌ 错误写法:直接用逻辑尺寸创建画布,默认 mipmap,放大后模糊
- ✅ 正确写法:按
devicePixelRatio放大画布,禁用 mipmap,使用线性过滤 + 各向异性
卡片大小不对
- ❌ 错误写法:忽略父节点缩放,导致卡片随工人模型一起被缩小
- ✅ 正确写法:
adjustedScale = scale / parentWorldScale抵消父级缩放
跟随方向不对
- ❌ 错误写法:
lookAt(camera.position)直接朝向,俯仰时卡片歪斜 - ✅ 正确写法:锁定 Y 轴,只绕水平面转向
宽度不自适应(后端字段内容长度不确定时可以考虑使用)
- ❌ 错误写法:写死宽度
width = 200 - ✅ 正确写法:用
measureText测量内容后动态计算
6. 完整使用示例
// main.ts
import { SceneManager } from './scene/SceneManager';
const container = document.getElementById('canvas-container')!;
const sceneManager = new SceneManager(container);
// 获取数据代码
// ...
// 清理
window.addEventListener('beforeunload', () => {
sceneManager.dispose();
});至此,一个高清、随相机水平跟随、宽度自适应的信息卡 Demo 就完成了。核心要点:
- 场景架构:
SceneManager统一管理渲染循环,每帧传递 camera 给信息卡 - 交互检测:
Raycaster+ NDC 坐标转换实现精准点击 - 高清渲染:画布按 DPR 放大 + 禁用 mipmap + 各向异性过滤
- 屏幕缩放:视锥公式计算 + 抵消父节点缩放
- 水平跟随:锁定 Y 轴的
lookAt - 动态布局:
measureText测量内容宽度
希望这个完整的从零实现能帮助你快速搭建类似的 Three.js 交互功能。下周见。***
加载中...