<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>GPU/CPU Transform 切换 - 150万线段示例</title>
<style>
body {
margin: 0;
padding: 0;
overflow: hidden;
font-family: Arial, sans-serif;
background: #000;
}
canvas {
display: block;
cursor: crosshair;
}
canvas.dragging {
cursor: grabbing !important;
}
canvas.rotating {
cursor: grab !important;
}
.info {
position: absolute;
top: 10px;
left: 10px;
color: white;
background: rgba(0, 0, 0, 0.8);
padding: 15px;
border-radius: 8px;
z-index: 100;
font-size: 14px;
line-height: 1.4;
}
.controls {
position: absolute;
top: 10px;
right: 10px;
z-index: 100;
}
.mode-button {
background: linear-gradient(45deg, #4CAF50, #2196F3);
color: white;
border: none;
padding: 12px 24px;
border-radius: 8px;
cursor: pointer;
font-size: 16px;
font-weight: bold;
transition: all 0.3s ease;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
}
.mode-button:hover {
transform: translateY(-2px);
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.4);
}
.mode-button.cpu-mode {
background: linear-gradient(45deg, #FF5722, #FF9800);
}
.mode-button.gpu-mode {
background: linear-gradient(45deg, #4CAF50, #2196F3);
}
.selection-box {
position: absolute;
border: 2px dashed #fff;
background: rgba(255, 255, 255, 0.1);
pointer-events: none;
display: none;
}
.performance-indicator {
position: absolute;
bottom: 10px;
left: 10px;
color: white;
background: rgba(0, 0, 0, 0.7);
padding: 10px;
border-radius: 5px;
z-index: 100;
font-family: monospace;
}
</style>
</head>
<body>
<div class="info">
<div >
<div><strong>🖱️ 操作说明:</strong></div>
<div style="margin-left: 10px; margin-top: 4px;">
<div>• <kbd>Shift+左键拖拽</kbd> - 框选线段</div>
<div>• <kbd>左键拖拽</kbd> - 拖动选中线段 / 旋转相机</div>
<div>• <kbd>中键拖拽</kbd> - 平移视角</div>
<div>• <kbd>Ctrl+A</kbd> - 全选所有线段</div>
</div>
</div>
<div style="margin-top: 8px; padding-top: 8px; border-top: 1px solid #444; font-size: 12px;">
<div id="stats">线段数量: 0</div>
<div id="transform-info">变换组数: 0</div>
<div id="selected-info">已选中: 0 组</div>
</div>
</div>
<div class="controls">
<button id="modeToggle" class="mode-button gpu-mode">
🚀 GPU Mode
</button>
<button id="selectAll" class="mode-button cpu-mode" style="margin-top: 10px;">
全部选中
</button>
</div>
<div class="performance-indicator">
<div id="fps">FPS: 0</div>
<div id="transform-time">变换耗时: 0ms</div>
</div>
<div class="selection-box" id="selectionBox"></div>
<script src="https://unpkg.com/[email protected]/build/three.min.js"></script>
<script>
// ========================
// 🔧 配置参数 (方便修改)
// ========================
const CONFIG = {
TOTAL_LINES: 1500000, // 总线段数:150万条 (可手动修改)
LINES_PER_GROUP: 100, // 每组线段数:100条
get MAX_TRANSFORMS() {
return Math.ceil(this.TOTAL_LINES / this.LINES_PER_GROUP);
},
get GRID_SIZE() {
const totalGroups = this.MAX_TRANSFORMS;
const cubeRoot = Math.ceil(Math.pow(totalGroups, 1 / 3));
let x = cubeRoot;
let y = cubeRoot;
let z = Math.ceil(totalGroups / (x * y));
while (x * y * z < totalGroups) {
if (x <= y) x++;
else y++;
z = Math.ceil(totalGroups / (x * y));
}
return { X: x, Y: y, Z: z };
},
LINE_LENGTH_MIN: 3,
LINE_LENGTH_MAX: 12,
GROUP_SPREAD: 30,
GROUP_SPACING: 40,
INITIAL_CAMERA_DISTANCE: 1200,
ZOOM_SPEED: 0.1,
ROTATE_SPEED: 0.01,
PAN_SPEED: 1.5,
RAYCASTER_THRESHOLD: 15,
DRAG_SENSITIVITY: 0.002,
get TRANSFORM_TEXTURE_SIZE() {
const size = Math.ceil(Math.sqrt(this.MAX_TRANSFORMS));
return Math.pow(2, Math.ceil(Math.log2(size)));
},
};
class HybridLineRenderer {
constructor() {
this.scene = new THREE.Scene();
this.camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 20000);
this.renderer = new THREE.WebGLRenderer({ antialias: false });
this.raycaster = new THREE.Raycaster();
this.isGPUMode = true;
this.maxTransforms = CONFIG.MAX_TRANSFORMS;
this.transforms = new Array(this.maxTransforms).fill(null).map(() => new THREE.Matrix4());
this.selectedTransforms = new Set();
this.isSelecting = false;
this.isDragging = false;
this.isRotating = false;
this.selectionStart = new THREE.Vector2();
this.selectionEnd = new THREE.Vector2();
this.lastMousePos = new THREE.Vector2();
this.mouse = new THREE.Vector2();
this.orbitTarget = new THREE.Vector3(0, 0, 0);
this.orbitRadius = CONFIG.INITIAL_CAMERA_DISTANCE;
this.orbitTheta = 0;
this.orbitPhi = Math.PI / 4;
this.lineMesh = null;
this.transformTexture = null;
this.transformStartTime = 0;
this.init();
window.lineRenderer = this;
}
init() {
this.renderer.setSize(window.innerWidth, window.innerHeight);
this.renderer.setClearColor(0x1a1a1a);
document.body.appendChild(this.renderer.domElement);
this.camera.position.set(300, 300, CONFIG.INITIAL_CAMERA_DISTANCE);
this.camera.lookAt(0, 0, 0);
this.setupCameraControls();
const ambientLight = new THREE.AmbientLight(0x404040, 0.8);
this.scene.add(ambientLight);
this.raycaster.params.Line.threshold = CONFIG.RAYCASTER_THRESHOLD;
this.createLines();
this.setupEventListeners();
this.updateCameraPosition();
this.animate();
}
updateSelectedInfo() {
const selectedInfo = document.getElementById('selected-info');
const selectedCount = this.selectedTransforms.size;
const affectedLines = selectedCount * CONFIG.LINES_PER_GROUP;
selectedInfo.textContent = `已选中: ${selectedCount.toLocaleString()} 组 (${affectedLines.toLocaleString()} 条线段)`;
}
checkSelectedLinesHit(mouseX, mouseY) {
if (this.selectedTransforms.size === 0) return false;
this.mouse.x = (mouseX / window.innerWidth) * 2 - 1;
this.mouse.y = -(mouseY / window.innerHeight) * 2 + 1;
this.raycaster.setFromCamera(this.mouse, this.camera);
const geometry = this.lineMesh.geometry;
const localPositions = geometry.attributes.localPosition.array;
const groupIds = geometry.attributes.groupId.array;
const selected = geometry.attributes.selectedFlag.array;
const lineCount = localPositions.length / 6;
const sampleStep = Math.max(1, Math.floor(lineCount / 5000));
for (let i = 0; i < lineCount; i += sampleStep) {
if (selected[i * 2] > 0) {
const groupId = Math.floor(groupIds[i * 2]);
const transform = this.transforms[groupId];
const startIdx = i * 6;
const localStart = new THREE.Vector3(
localPositions[startIdx],
localPositions[startIdx + 1],
localPositions[startIdx + 2]
);
const localEnd = new THREE.Vector3(
localPositions[startIdx + 3],
localPositions[startIdx + 4],
localPositions[startIdx + 5]
);
const worldStart = localStart.clone().applyMatrix4(transform);
const worldEnd = localEnd.clone().applyMatrix4(transform);
const lineDirection = worldEnd.clone().sub(worldStart).normalize();
const lineLength = worldStart.distanceTo(worldEnd);
const rayToLineStart = worldStart.clone().sub(this.raycaster.ray.origin);
const projection = rayToLineStart.dot(this.raycaster.ray.direction);
const closestPointOnRay = this.raycaster.ray.origin.clone()
.add(this.raycaster.ray.direction.clone().multiplyScalar(projection));
const lineToRayStart = this.raycaster.ray.origin.clone().sub(worldStart);
const t = Math.max(0, Math.min(lineLength, lineToRayStart.dot(lineDirection)));
const closestPointOnLine = worldStart.clone().add(lineDirection.clone().multiplyScalar(t));
const distance = closestPointOnRay.distanceTo(closestPointOnLine);
if (distance < CONFIG.RAYCASTER_THRESHOLD) {
return true;
}
}
}
return false;
}
toggleMode() {
this.isGPUMode = !this.isGPUMode;
const modeButton = document.getElementById('modeToggle');
const modeInfo = document.getElementById('mode-info');
if (this.isGPUMode) {
modeButton.textContent = '🚀 GPU Mode';
modeButton.className = 'mode-button gpu-mode';
} else {
modeButton.textContent = '🔧 CPU Mode';
modeButton.className = 'mode-button cpu-mode';
}
this.recreateLines();
}
recreateLines() {
if (this.lineMesh) {
this.scene.remove(this.lineMesh);
this.lineMesh.geometry.dispose();
this.lineMesh.material.dispose();
}
this.createLines();
// 恢复选择状态
if (this.selectedTransforms.size > 0) {
const geometry = this.lineMesh.geometry;
const groupIds = geometry.attributes.groupId.array;
const selected = geometry.attributes.selectedFlag.array;
for (let i = 0; i < selected.length / 2; i++) {
const groupId = Math.floor(groupIds[i * 2]);
if (this.selectedTransforms.has(groupId)) {
selected[i * 2] = 1.0;
selected[i * 2 + 1] = 1.0;
}
}
geometry.attributes.selectedFlag.needsUpdate = true;
}
}
createTransformTexture() {
const size = CONFIG.TRANSFORM_TEXTURE_SIZE;
const data = new Float32Array(size * size * 4 * 4);
for (let i = 0; i < this.maxTransforms; i++) {
const baseIndex = i * 16;
const matrix = this.transforms[i];
for (let j = 0; j < 16; j++) {
data[baseIndex + j] = matrix.elements[j];
}
}
const texture = new THREE.DataTexture(
data,
size * 4,
size,
THREE.RGBAFormat,
THREE.FloatType
);
texture.needsUpdate = true;
texture.magFilter = THREE.NearestFilter;
texture.minFilter = THREE.NearestFilter;
texture.flipY = false;
return texture;
}
updateTransformTexture() {
if (!this.transformTexture) return;
const data = this.transformTexture.image.data;
for (let i = 0; i < this.maxTransforms; i++) {
const baseIndex = i * 16;
const matrix = this.transforms[i];
for (let j = 0; j < 16; j++) {
data[baseIndex + j] = matrix.elements[j];
}
}
this.transformTexture.needsUpdate = true;
}
createLines() {
const geometry = new THREE.BufferGeometry();
const positions = new Float32Array(CONFIG.TOTAL_LINES * 6);
const colors = new Float32Array(CONFIG.TOTAL_LINES * 6);
const groupIds = new Float32Array(CONFIG.TOTAL_LINES * 2);
const localPositions = new Float32Array(CONFIG.TOTAL_LINES * 6);
const selectionFlags = new Float32Array(CONFIG.TOTAL_LINES * 2);
let lineIndex = 0;
for (let groupIndex = 0; groupIndex < CONFIG.MAX_TRANSFORMS; groupIndex++) {
const actualLinesInGroup = Math.min(CONFIG.LINES_PER_GROUP, CONFIG.TOTAL_LINES - lineIndex);
if (actualLinesInGroup <= 0) break;
for (let i = 0; i < actualLinesInGroup; i++) {
const localStartX = (Math.random() - 0.5) * CONFIG.GROUP_SPREAD;
const localStartY = (Math.random() - 0.5) * CONFIG.GROUP_SPREAD;
const localStartZ = (Math.random() - 0.5) * CONFIG.GROUP_SPREAD;
const length = CONFIG.LINE_LENGTH_MIN + Math.random() * (CONFIG.LINE_LENGTH_MAX - CONFIG.LINE_LENGTH_MIN);
const direction = new THREE.Vector3(
Math.random() - 0.5,
Math.random() - 0.5,
Math.random() - 0.5
).normalize();
const localEndX = localStartX + direction.x * length;
const localEndY = localStartY + direction.y * length;
const localEndZ = localStartZ + direction.z * length;
const vertexIndex = lineIndex * 6;
localPositions[vertexIndex] = localStartX;
localPositions[vertexIndex + 1] = localStartY;
localPositions[vertexIndex + 2] = localStartZ;
localPositions[vertexIndex + 3] = localEndX;
localPositions[vertexIndex + 4] = localEndY;
localPositions[vertexIndex + 5] = localEndZ;
positions[vertexIndex] = localStartX;
positions[vertexIndex + 1] = localStartY;
positions[vertexIndex + 2] = localStartZ;
positions[vertexIndex + 3] = localEndX;
positions[vertexIndex + 4] = localEndY;
positions[vertexIndex + 5] = localEndZ;
const colorSeed = groupIndex * 137;
const r = ((colorSeed % 256) / 255) * 0.8 + 0.2;
const g = (((colorSeed * 17) % 256) / 255) * 0.8 + 0.2;
const b = (((colorSeed * 31) % 256) / 255) * 0.8 + 0.2;
colors[vertexIndex] = r;
colors[vertexIndex + 1] = g;
colors[vertexIndex + 2] = b;
colors[vertexIndex + 3] = r;
colors[vertexIndex + 4] = g;
colors[vertexIndex + 5] = b;
groupIds[lineIndex * 2] = groupIndex;
groupIds[lineIndex * 2 + 1] = groupIndex;
selectionFlags[lineIndex * 2] = 0.0;
selectionFlags[lineIndex * 2 + 1] = 0.0;
lineIndex++;
}
}
// 初始化变换矩阵
const gridSize = CONFIG.GRID_SIZE;
for (let i = 0; i < CONFIG.MAX_TRANSFORMS; i++) {
const x = (i % gridSize.X - gridSize.X / 2) * CONFIG.GROUP_SPACING;
const y = (Math.floor(i / gridSize.X) % gridSize.Y - gridSize.Y / 2) * CONFIG.GROUP_SPACING;
const z = (Math.floor(i / (gridSize.X * gridSize.Y)) - gridSize.Z / 2) * CONFIG.GROUP_SPACING;
this.transforms[i].makeTranslation(x, y, z);
}
geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
geometry.setAttribute('localPosition', new THREE.BufferAttribute(localPositions, 3));
geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
geometry.setAttribute('groupId', new THREE.BufferAttribute(groupIds, 1));
geometry.setAttribute('selectedFlag', new THREE.BufferAttribute(selectionFlags, 1));
let material;
if (this.isGPUMode) {
this.transformTexture = this.createTransformTexture();
material = new THREE.ShaderMaterial({
uniforms: {
transformTexture: { value: this.transformTexture },
transformTextureSize: { value: CONFIG.TRANSFORM_TEXTURE_SIZE }
},
vertexShader: `
attribute vec3 localPosition;
attribute vec3 color;
attribute float groupId;
attribute float selectedFlag;
uniform sampler2D transformTexture;
uniform float transformTextureSize;
varying vec3 vColor;
mat4 getTransformMatrix(float id) {
float size = transformTextureSize * 4.0;
float matrixId = floor(id);
float row = floor(matrixId / transformTextureSize);
float col = mod(matrixId, transformTextureSize) * 4.0;
mat4 matrix;
for(int i = 0; i < 4; i++) {
vec4 rowData = texture2D(transformTexture,
vec2((col + float(i) + 0.5) / size, (row + 0.5) / transformTextureSize));
matrix[i] = rowData;
}
return matrix;
}
void main() {
mat4 transformMatrix = getTransformMatrix(groupId);
vec4 worldPosition = transformMatrix * vec4(localPosition, 1.0);
vColor = mix(color, vec3(1.0, 1.0, 0.0), selectedFlag * 0.8);
gl_Position = projectionMatrix * modelViewMatrix * worldPosition;
}
`,
fragmentShader: `
varying vec3 vColor;
void main() {
gl_FragColor = vec4(vColor, 1.0);
}
`
});
} else {
material = new THREE.ShaderMaterial({
vertexShader: `
attribute vec3 color;
attribute float selectedFlag;
varying vec3 vColor;
void main() {
vColor = mix(color, vec3(1.0, 1.0, 0.0), selectedFlag * 0.8);
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
varying vec3 vColor;
void main() {
gl_FragColor = vec4(vColor, 1.0);
}
`
});
}
this.lineMesh = new THREE.LineSegments(geometry, material);
this.scene.add(this.lineMesh);
// 如果是CPU模式,需要初始计算世界坐标
if (!this.isGPUMode) {
this.updateCPUPositions();
}
document.getElementById('stats').textContent = `线段数量: ${CONFIG.TOTAL_LINES.toLocaleString()}`;
document.getElementById('transform-info').textContent = `变换组数: ${CONFIG.MAX_TRANSFORMS.toLocaleString()}`;
this.updateSelectedInfo();
}
// CPU模式:更新所有顶点的世界坐标
updateCPUPositions() {
this.transformStartTime = performance.now();
const geometry = this.lineMesh.geometry;
const positions = geometry.attributes.position.array;
const localPositions = geometry.attributes.localPosition.array;
const groupIds = geometry.attributes.groupId.array;
const lineCount = localPositions.length / 6;
for (let i = 0; i < lineCount; i++) {
const groupId = Math.floor(groupIds[i * 2]);
const transform = this.transforms[groupId];
const startIdx = i * 6;
const localStart = new THREE.Vector3(
localPositions[startIdx],
localPositions[startIdx + 1],
localPositions[startIdx + 2]
);
const worldStart = localStart.applyMatrix4(transform);
positions[startIdx] = worldStart.x;
positions[startIdx + 1] = worldStart.y;
positions[startIdx + 2] = worldStart.z;
const localEnd = new THREE.Vector3(
localPositions[startIdx + 3],
localPositions[startIdx + 4],
localPositions[startIdx + 5]
);
const worldEnd = localEnd.applyMatrix4(transform);
positions[startIdx + 3] = worldEnd.x;
positions[startIdx + 4] = worldEnd.y;
positions[startIdx + 5] = worldEnd.z;
}
geometry.attributes.position.needsUpdate = true;
const transformTime = performance.now() - this.transformStartTime;
document.getElementById('transform-time').textContent = `变换耗时: ${transformTime.toFixed(2)}ms`;
}
setupCameraControls() {
let isPanning = false;
let lastX = 0, lastY = 0;
this.renderer.domElement.addEventListener('wheel', (event) => {
event.preventDefault();
if (event.shiftKey) {
const panSpeed = CONFIG.PAN_SPEED * 2.0;
const right = new THREE.Vector3();
const up = new THREE.Vector3(0, 1, 0);
right.crossVectors(this.camera.getWorldDirection(new THREE.Vector3()), up).normalize();
up.crossVectors(right, this.camera.getWorldDirection(new THREE.Vector3())).normalize();
const deltaX = event.deltaX * panSpeed * 0.01;
const deltaY = event.deltaY * panSpeed * 0.01;
const panVector = right.clone().multiplyScalar(-deltaX).add(up.clone().multiplyScalar(deltaY));
this.orbitTarget.add(panVector);
this.camera.position.add(panVector);
} else {
const delta = event.deltaY * CONFIG.ZOOM_SPEED;
this.orbitRadius += delta;
this.orbitRadius = Math.max(100, Math.min(10000, this.orbitRadius));
this.updateCameraPosition();
}
});
this.renderer.domElement.addEventListener('mousedown', (event) => {
if (event.button === 1) {
isPanning = true;
lastX = event.clientX;
lastY = event.clientY;
event.preventDefault();
this.renderer.domElement.style.cursor = 'grabbing';
}
});
this.renderer.domElement.addEventListener('mousemove', (event) => {
if (isPanning) {
const deltaX = event.clientX - lastX;
const deltaY = event.clientY - lastY;
const right = new THREE.Vector3();
const up = new THREE.Vector3(0, 1, 0);
right.crossVectors(this.camera.getWorldDirection(new THREE.Vector3()), up).normalize();
up.crossVectors(right, this.camera.getWorldDirection(new THREE.Vector3())).normalize();
const panVector = right.clone().multiplyScalar(-deltaX * CONFIG.PAN_SPEED)
.add(up.clone().multiplyScalar(deltaY * CONFIG.PAN_SPEED));
this.orbitTarget.add(panVector);
this.updateCameraPosition();
lastX = event.clientX;
lastY = event.clientY;
}
});
this.renderer.domElement.addEventListener('mouseup', (event) => {
if (event.button === 1) {
isPanning = false;
this.renderer.domElement.style.cursor = 'crosshair';
}
});
this.renderer.domElement.addEventListener('contextmenu', (e) => e.preventDefault());
}
updateCameraPosition() {
const x = this.orbitTarget.x + this.orbitRadius * Math.sin(this.orbitPhi) * Math.cos(this.orbitTheta);
const y = this.orbitTarget.y + this.orbitRadius * Math.cos(this.orbitPhi);
const z = this.orbitTarget.z + this.orbitRadius * Math.sin(this.orbitPhi) * Math.sin(this.orbitTheta);
this.camera.position.set(x, y, z);
this.camera.lookAt(this.orbitTarget);
}
setupEventListeners() {
const canvas = this.renderer.domElement;
const selectionBox = document.getElementById('selectionBox');
document.getElementById('modeToggle').addEventListener('click', () => {
this.toggleMode();
});
document.getElementById('selectAll').addEventListener('click', () => {
this.selectAll();
});
canvas.addEventListener('mousedown', (event) => {
if (event.button === 0) {
if (event.shiftKey) {
this.isSelecting = true;
this.selectionStart.set(event.clientX, event.clientY);
this.selectionEnd.copy(this.selectionStart);
selectionBox.style.display = 'block';
canvas.style.cursor = 'crosshair';
} else {
if (this.checkSelectedLinesHit(event.clientX, event.clientY)) {
this.isDragging = true;
this.lastMousePos.set(event.clientX, event.clientY);
canvas.classList.add('dragging');
} else {
this.isRotating = true;
this.lastMousePos.set(event.clientX, event.clientY);
canvas.classList.add('rotating');
}
}
event.preventDefault();
}
});
canvas.addEventListener('mousemove', (event) => {
if (this.isSelecting) {
this.selectionEnd.set(event.clientX, event.clientY);
this.updateSelectionBox(selectionBox);
} else if (this.isDragging) {
const deltaX = event.clientX - this.lastMousePos.x;
const deltaY = event.clientY - this.lastMousePos.y;
this.moveSelectedTransforms(-deltaX, -deltaY);
this.lastMousePos.set(event.clientX, event.clientY);
} else if (this.isRotating) {
let deltaX = event.clientX - this.lastMousePos.x;
let deltaY = event.clientY - this.lastMousePos.y;
deltaX = -deltaX;
deltaY = -deltaY;
this.orbitTheta -= deltaX * CONFIG.ROTATE_SPEED;
this.orbitPhi += deltaY * CONFIG.ROTATE_SPEED;
this.orbitPhi = Math.max(0.01, Math.min(Math.PI - 0.01, this.orbitPhi));
this.updateCameraPosition();
this.lastMousePos.set(event.clientX, event.clientY);
}
});
canvas.addEventListener('mouseup', (event) => {
if (event.button === 0) {
if (this.isSelecting) {
this.performSelection();
this.isSelecting = false;
selectionBox.style.display = 'none';
canvas.style.cursor = 'crosshair';
} else if (this.isDragging) {
this.isDragging = false;
canvas.classList.remove('dragging');
} else if (this.isRotating) {
this.isRotating = false;
canvas.classList.remove('rotating');
}
}
});
canvas.addEventListener('mousemove', (event) => {
if (!this.isSelecting && !this.isDragging && !this.isRotating) {
if (this.selectedTransforms.size > 0 && this.checkSelectedLinesHit(event.clientX, event.clientY)) {
canvas.style.cursor = 'grab';
} else {
canvas.style.cursor = 'crosshair';
}
}
});
window.addEventListener('resize', () => this.onWindowResize());
}
updateSelectionBox(selectionBox) {
const left = Math.min(this.selectionStart.x, this.selectionEnd.x);
const top = Math.min(this.selectionStart.y, this.selectionEnd.y);
const width = Math.abs(this.selectionEnd.x - this.selectionStart.x);
const height = Math.abs(this.selectionEnd.y - this.selectionStart.y);
selectionBox.style.left = left + 'px';
selectionBox.style.top = top + 'px';
selectionBox.style.width = width + 'px';
selectionBox.style.height = height + 'px';
}
performSelection() {
this.clearSelection();
const left = Math.min(this.selectionStart.x, this.selectionEnd.x);
const right = Math.max(this.selectionStart.x, this.selectionEnd.x);
const top = Math.min(this.selectionStart.y, this.selectionEnd.y);
const bottom = Math.max(this.selectionStart.y, this.selectionEnd.y);
if (right - left < 5 || bottom - top < 5) return;
const leftNDC = (left / window.innerWidth) * 2 - 1;
const rightNDC = (right / window.innerWidth) * 2 - 1;
const topNDC = -((top / window.innerHeight) * 2 - 1);
const bottomNDC = -((bottom / window.innerHeight) * 2 - 1);
const geometry = this.lineMesh.geometry;
const localPositions = geometry.attributes.localPosition.array;
const groupIds = geometry.attributes.groupId.array;
const selected = geometry.attributes.selectedFlag.array;
const lineCount = localPositions.length / 6;
const sampleStep = Math.max(1, Math.floor(lineCount / 50000));
for (let i = 0; i < lineCount; i += sampleStep) {
const groupId = Math.floor(groupIds[i * 2]);
const transform = this.transforms[groupId];
const startIdx = i * 6;
const localMidX = (localPositions[startIdx] + localPositions[startIdx + 3]) / 2;
const localMidY = (localPositions[startIdx + 1] + localPositions[startIdx + 4]) / 2;
const localMidZ = (localPositions[startIdx + 2] + localPositions[startIdx + 5]) / 2;
const localPos = new THREE.Vector3(localMidX, localMidY, localMidZ);
const worldPos = localPos.applyMatrix4(transform);
const screenPos = worldPos.project(this.camera);
if (screenPos.x >= leftNDC && screenPos.x <= rightNDC &&
screenPos.y >= bottomNDC && screenPos.y <= topNDC &&
screenPos.z >= -1 && screenPos.z <= 1) {
this.selectedTransforms.add(groupId);
}
}
// 更新所有选中组的线段标志
for (let i = 0; i < selected.length / 2; i++) {
const groupId = Math.floor(groupIds[i * 2]);
if (this.selectedTransforms.has(groupId)) {
selected[i * 2] = 1.0;
selected[i * 2 + 1] = 1.0;
}
}
geometry.attributes.selectedFlag.needsUpdate = true;
this.updateSelectedInfo();
console.log(`选中了 ${this.selectedTransforms.size} 个组`);
}
// Ctrl+A 全选
selectAll() {
this.clearSelection();
// 添加所有组到选中集合
for (let i = 0; i < CONFIG.MAX_TRANSFORMS; i++) {
this.selectedTransforms.add(i);
}
const geometry = this.lineMesh.geometry;
const selected = geometry.attributes.selectedFlag.array;
selected.fill(1.0);
geometry.attributes.selectedFlag.needsUpdate = true;
this.updateSelectedInfo();
const totalLines = this.selectedTransforms.size * CONFIG.LINES_PER_GROUP;
}
clearSelection() {
this.selectedTransforms.clear();
const selected = this.lineMesh.geometry.attributes.selectedFlag.array;
selected.fill(0.0);
this.lineMesh.geometry.attributes.selectedFlag.needsUpdate = true;
this.updateSelectedInfo();
}
// 移动选中的变换组
moveSelectedTransforms(deltaX, deltaY) {
if (this.selectedTransforms.size === 0) return;
this.transformStartTime = performance.now();
const distance = this.camera.position.distanceTo(this.orbitTarget);
const moveFactor = distance * CONFIG.DRAG_SENSITIVITY;
const cameraDirection = new THREE.Vector3();
this.camera.getWorldDirection(cameraDirection);
const right = new THREE.Vector3();
const up = new THREE.Vector3(0, 1, 0);
right.crossVectors(cameraDirection, up).normalize();
up.crossVectors(right, cameraDirection).normalize();
const moveVector = right.clone().multiplyScalar(-deltaX * moveFactor)
.add(up.clone().multiplyScalar(deltaY * moveFactor));
// 更新变换矩阵
this.selectedTransforms.forEach(transformId => {
const currentTransform = this.transforms[transformId];
const translateMatrix = new THREE.Matrix4().makeTranslation(
moveVector.x, moveVector.y, moveVector.z
);
this.transforms[transformId].multiplyMatrices(translateMatrix, currentTransform);
});
if (this.isGPUMode) {
// GPU模式:只需更新纹理
this.updateTransformTexture();
} else {
// CPU模式:重新计算所有顶点位置
this.updateCPUPositions();
}
const transformTime = performance.now() - this.transformStartTime;
document.getElementById('transform-time').textContent = `变换耗时: ${transformTime.toFixed(2)}ms`;
}
onWindowResize() {
this.camera.aspect = window.innerWidth / window.innerHeight;
this.camera.updateProjectionMatrix();
this.renderer.setSize(window.innerWidth, window.innerHeight);
}
animate() {
requestAnimationFrame(() => this.animate());
this.renderer.render(this.scene, this.camera);
}
}
// 性能监控
class PerformanceMonitor {
constructor() {
this.lastTime = performance.now();
this.frames = 0;
this.fps = 0;
this.frameTimeHistory = [];
this.update();
}
update() {
this.frames++;
const now = performance.now();
if (now >= this.lastTime + 1000) {
this.fps = Math.round((this.frames * 1000) / (now - this.lastTime));
this.frames = 0;
this.lastTime = now;
const fpsElement = document.getElementById('fps');
if (fpsElement) {
fpsElement.textContent = `FPS: ${this.fps}`;
}
}
requestAnimationFrame(() => this.update());
}
}
// 启动应用
document.addEventListener('DOMContentLoaded', () => {
try {
const app = new HybridLineRenderer();
const monitor = new PerformanceMonitor();
} catch (error) {
console.error('❌ 启动失败:', error);
}
});
// 键盘快捷键
document.addEventListener('keydown', (event) => {
if (!window.lineRenderer) return;
switch (event.key.toLowerCase()) {
case 'escape':
window.lineRenderer.clearSelection();
console.log('已清除选择');
break;
case 'r':
window.lineRenderer.orbitTarget.set(0, 0, 0);
window.lineRenderer.orbitRadius = CONFIG.INITIAL_CAMERA_DISTANCE;
window.lineRenderer.orbitTheta = 0;
window.lineRenderer.orbitPhi = Math.PI / 4;
window.lineRenderer.updateCameraPosition();
console.log('相机已重置');
break;
case 'm':
window.lineRenderer.toggleMode();
break;
case 'a':
if (event.ctrlKey) {
window.lineRenderer.selectAll();
event.preventDefault();
}
break;
case 'c':
const selectedCount = window.lineRenderer.selectedTransforms.size;
const mode = window.lineRenderer.isGPUMode ? 'GPU' : 'CPU';
const affectedLines = selectedCount * CONFIG.LINES_PER_GROUP;
console.log(`${mode} 模式,选中 ${selectedCount} 个组 (${affectedLines.toLocaleString()} 条线段)`);
break;
case 'h':
console.log('快捷键:');
console.log('Shift+左键拖拽 - 框选');
console.log('左键拖拽 - 移动选中/旋转相机');
console.log('中键拖拽 - 平移');
console.log('滚轮 - 缩放');
console.log('Ctrl+A - 全选');
console.log('M - 切换GPU/CPU模式');
console.log('R - 重置相机');
console.log('ESC - 清除选择');
break;
}
});
// 配置修改工具
window.updateConfig = function (newConfig) {
Object.assign(CONFIG, newConfig);
console.log('配置已更新, 重新加载以生效');
if (confirm('是否重新加载?')) {
location.reload();
}
};
window.CONFIG = CONFIG;
// 检测WebGL支持
function checkWebGLSupport() {
try {
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
return !!gl;
} catch (e) {
return false;
}
}
if (!checkWebGLSupport()) {
console.error('WebGL 不支持');
const warningDiv = document.createElement('div');
warningDiv.style.cssText = `
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(255, 165, 0, 0.9);
color: white;
padding: 20px;
border-radius: 10px;
text-align: center;
z-index: 1000;
max-width: 400px;
`;
warningDiv.innerHTML = `
<h3>WebGL 不支持</h3>
<p>您的浏览器不支持 WebGL,无法运行此应用</p>
<p>请使用支持 WebGL 的现代浏览器</p>
`;
document.body.appendChild(warningDiv);
}
</script>
</body>
</html>
index.html
index.html