2D/3D视口

This commit is contained in:
YHH
2025-10-15 18:08:55 +08:00
parent e880925e3f
commit 956ccf9195
2 changed files with 469 additions and 42 deletions

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Play, Pause, RotateCcw, Maximize2, Grid3x3, Eye, EyeOff, Activity } from 'lucide-react'; import { Play, Pause, RotateCcw, Maximize2, Grid3x3, Eye, EyeOff, Activity, Box, Square } from 'lucide-react';
import '../styles/Viewport.css'; import '../styles/Viewport.css';
interface ViewportProps { interface ViewportProps {
@@ -13,8 +13,23 @@ export function Viewport({ locale = 'en' }: ViewportProps) {
const [showGrid, setShowGrid] = useState(true); const [showGrid, setShowGrid] = useState(true);
const [showGizmos, setShowGizmos] = useState(true); const [showGizmos, setShowGizmos] = useState(true);
const [showStats, setShowStats] = useState(false); const [showStats, setShowStats] = useState(false);
const [is3D, setIs3D] = useState(true);
const animationFrameRef = useRef<number>(); const animationFrameRef = useRef<number>();
const glRef = useRef<WebGLRenderingContext | null>(null); const glRef = useRef<WebGLRenderingContext | null>(null);
const gridProgramRef = useRef<WebGLProgram | null>(null);
const gridBufferRef = useRef<WebGLBuffer | null>(null);
const dynamicGridBufferRef = useRef<WebGLBuffer | null>(null);
const axisBufferRef = useRef<WebGLBuffer | null>(null);
const [cameraRotation, setCameraRotation] = useState({ yaw: -Math.PI / 4, pitch: Math.PI / 6 });
const [cameraDistance, setCameraDistance] = useState(20);
const [camera2DOffset, setCamera2DOffset] = useState({ x: 0, y: 0 });
const [camera2DZoom, setCamera2DZoom] = useState(20);
const isDraggingRef = useRef(false);
const lastMousePosRef = useRef({ x: 0, y: 0 });
const [fps, setFps] = useState(0);
const [drawCalls, setDrawCalls] = useState(0);
const fpsFrameCountRef = useRef(0);
const fpsLastTimeRef = useRef(performance.now());
useEffect(() => { useEffect(() => {
const canvas = canvasRef.current; const canvas = canvasRef.current;
@@ -45,60 +60,101 @@ export function Viewport({ locale = 'en' }: ViewportProps) {
resizeCanvas(); resizeCanvas();
window.addEventListener('resize', resizeCanvas); window.addEventListener('resize', resizeCanvas);
const resizeObserver = new ResizeObserver(() => {
resizeCanvas();
});
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}
initWebGL(gl); initWebGL(gl);
const handleMouseDown = (e: MouseEvent) => {
if (e.button === 0) {
isDraggingRef.current = true;
lastMousePosRef.current = { x: e.clientX, y: e.clientY };
canvas.style.cursor = 'grabbing';
}
};
const handleMouseMove = (e: MouseEvent) => {
if (!isDraggingRef.current) return;
const deltaX = e.clientX - lastMousePosRef.current.x;
const deltaY = e.clientY - lastMousePosRef.current.y;
if (is3D) {
setCameraRotation(prev => ({
yaw: prev.yaw - deltaX * 0.005,
pitch: Math.max(-Math.PI / 2 + 0.1, Math.min(Math.PI / 2 - 0.1, prev.pitch + deltaY * 0.005))
}));
} else {
setCamera2DOffset(prev => ({
x: prev.x - deltaX * 0.05,
y: prev.y - deltaY * 0.05
}));
}
lastMousePosRef.current = { x: e.clientX, y: e.clientY };
};
const handleMouseUp = () => {
isDraggingRef.current = false;
if (canvas) {
canvas.style.cursor = 'grab';
}
};
const handleWheel = (e: WheelEvent) => {
e.preventDefault();
if (is3D) {
setCameraDistance(prev => Math.max(5, Math.min(50, prev + e.deltaY * 0.01)));
} else {
setCamera2DZoom(prev => Math.max(5, Math.min(100, prev + e.deltaY * 0.01)));
}
};
canvas.addEventListener('mousedown', handleMouseDown);
canvas.addEventListener('mousemove', handleMouseMove);
canvas.addEventListener('mouseup', handleMouseUp);
canvas.addEventListener('mouseleave', handleMouseUp);
canvas.addEventListener('wheel', handleWheel, { passive: false });
return () => { return () => {
window.removeEventListener('resize', resizeCanvas); window.removeEventListener('resize', resizeCanvas);
resizeObserver.disconnect();
canvas.removeEventListener('mousedown', handleMouseDown);
canvas.removeEventListener('mousemove', handleMouseMove);
canvas.removeEventListener('mouseup', handleMouseUp);
canvas.removeEventListener('mouseleave', handleMouseUp);
canvas.removeEventListener('wheel', handleWheel);
if (animationFrameRef.current) { if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current); cancelAnimationFrame(animationFrameRef.current);
} }
}; };
}, []); }, [is3D]);
useEffect(() => { useEffect(() => {
if (isPlaying) { startRenderLoop();
startRenderLoop(); return () => {
} else {
if (animationFrameRef.current) { if (animationFrameRef.current) {
cancelAnimationFrame(animationFrameRef.current); cancelAnimationFrame(animationFrameRef.current);
} }
} };
}, [isPlaying]); }, [isPlaying, showGrid, cameraRotation, cameraDistance, camera2DOffset, camera2DZoom, is3D]);
const initWebGL = (gl: WebGLRenderingContext) => { const initWebGL = (gl: WebGLRenderingContext) => {
gl.clearColor(0.1, 0.1, 0.12, 1.0); gl.clearColor(0.1, 0.1, 0.12, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
gl.enable(gl.DEPTH_TEST); gl.enable(gl.DEPTH_TEST);
gl.enable(gl.BLEND);
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
initGridProgram(gl);
renderFrame(gl, 0); renderFrame(gl, 0);
}; };
const startRenderLoop = () => { const initGridProgram = (gl: WebGLRenderingContext) => {
let startTime = performance.now();
const render = (currentTime: number) => {
const elapsed = (currentTime - startTime) / 1000;
if (glRef.current) {
renderFrame(glRef.current, elapsed);
}
animationFrameRef.current = requestAnimationFrame(render);
};
animationFrameRef.current = requestAnimationFrame(render);
};
const renderFrame = (gl: WebGLRenderingContext, time: number) => {
gl.clearColor(0.1, 0.1, 0.12, 1.0);
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
if (showGrid) {
drawGrid(gl);
}
};
const drawGrid = (gl: WebGLRenderingContext) => {
const vertexShaderSource = ` const vertexShaderSource = `
attribute vec3 position; attribute vec3 position;
uniform mat4 projection; uniform mat4 projection;
@@ -120,17 +176,33 @@ export function Viewport({ locale = 'en' }: ViewportProps) {
gl.shaderSource(vertexShader, vertexShaderSource); gl.shaderSource(vertexShader, vertexShaderSource);
gl.compileShader(vertexShader); gl.compileShader(vertexShader);
if (!gl.getShaderParameter(vertexShader, gl.COMPILE_STATUS)) {
console.error('Vertex shader compilation error:', gl.getShaderInfoLog(vertexShader));
return;
}
const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER)!; const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER)!;
gl.shaderSource(fragmentShader, fragmentShaderSource); gl.shaderSource(fragmentShader, fragmentShaderSource);
gl.compileShader(fragmentShader); gl.compileShader(fragmentShader);
if (!gl.getShaderParameter(fragmentShader, gl.COMPILE_STATUS)) {
console.error('Fragment shader compilation error:', gl.getShaderInfoLog(fragmentShader));
return;
}
const program = gl.createProgram()!; const program = gl.createProgram()!;
gl.attachShader(program, vertexShader); gl.attachShader(program, vertexShader);
gl.attachShader(program, fragmentShader); gl.attachShader(program, fragmentShader);
gl.linkProgram(program); gl.linkProgram(program);
gl.useProgram(program);
const gridSize = 10; if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
console.error('Program linking error:', gl.getProgramInfoLog(program));
return;
}
gridProgramRef.current = program;
const gridSize = 100;
const gridStep = 1; const gridStep = 1;
const vertices: number[] = []; const vertices: number[] = [];
@@ -143,14 +215,356 @@ export function Viewport({ locale = 'en' }: ViewportProps) {
gl.bindBuffer(gl.ARRAY_BUFFER, buffer); gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.STATIC_DRAW); gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.STATIC_DRAW);
gridBufferRef.current = buffer;
const axisLength = 5;
const axisVertices = [
0, 0, 0, axisLength, 0, 0,
0, 0, 0, 0, axisLength, 0,
0, 0, 0, 0, 0, axisLength
];
const axisBuffer = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, axisBuffer);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(axisVertices), gl.STATIC_DRAW);
axisBufferRef.current = axisBuffer;
};
const startRenderLoop = () => {
let startTime = performance.now();
const render = (currentTime: number) => {
const elapsed = (currentTime - startTime) / 1000;
if (glRef.current) {
renderFrame(glRef.current, elapsed);
}
animationFrameRef.current = requestAnimationFrame(render);
};
animationFrameRef.current = requestAnimationFrame(render);
};
const renderFrame = (gl: WebGLRenderingContext, time: number) => {
gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT);
let currentDrawCalls = 0;
if (showGrid && gridProgramRef.current && gridBufferRef.current) {
drawGrid(gl);
currentDrawCalls += is3D ? 1 : 2;
}
gl.disable(gl.DEPTH_TEST);
if (gridProgramRef.current && axisBufferRef.current) {
drawAxis(gl);
currentDrawCalls += is3D ? 3 : 2;
}
gl.enable(gl.DEPTH_TEST);
setDrawCalls(currentDrawCalls);
fpsFrameCountRef.current++;
const currentTime = performance.now();
const deltaTime = currentTime - fpsLastTimeRef.current;
if (deltaTime >= 1000) {
const currentFps = Math.round((fpsFrameCountRef.current * 1000) / deltaTime);
setFps(currentFps);
fpsFrameCountRef.current = 0;
fpsLastTimeRef.current = currentTime;
}
};
const createPerspectiveMatrix = (fov: number, aspect: number, near: number, far: number): Float32Array => {
const f = 1.0 / Math.tan(fov / 2);
const rangeInv = 1.0 / (near - far);
return new Float32Array([
f / aspect, 0, 0, 0,
0, f, 0, 0,
0, 0, (near + far) * rangeInv, -1,
0, 0, near * far * rangeInv * 2, 0
]);
};
const createOrthographicMatrix = (left: number, right: number, bottom: number, top: number, near: number, far: number): Float32Array => {
const lr = 1 / (left - right);
const bt = 1 / (bottom - top);
const nf = 1 / (near - far);
return new Float32Array([
-2 * lr, 0, 0, 0,
0, -2 * bt, 0, 0,
0, 0, 2 * nf, 0,
(left + right) * lr, (top + bottom) * bt, (near + far) * nf, 1
]);
};
const createLookAtMatrix = (
eyeX: number, eyeY: number, eyeZ: number,
centerX: number, centerY: number, centerZ: number,
upX: number, upY: number, upZ: number
): Float32Array => {
let zx = eyeX - centerX;
let zy = eyeY - centerY;
let zz = eyeZ - centerZ;
const zlen = Math.sqrt(zx * zx + zy * zy + zz * zz);
zx /= zlen;
zy /= zlen;
zz /= zlen;
let xx = upY * zz - upZ * zy;
let xy = upZ * zx - upX * zz;
let xz = upX * zy - upY * zx;
const xlen = Math.sqrt(xx * xx + xy * xy + xz * xz);
xx /= xlen;
xy /= xlen;
xz /= xlen;
const yx = zy * xz - zz * xy;
const yy = zz * xx - zx * xz;
const yz = zx * xy - zy * xx;
return new Float32Array([
xx, yx, zx, 0,
xy, yy, zy, 0,
xz, yz, zz, 0,
-(xx * eyeX + xy * eyeY + xz * eyeZ),
-(yx * eyeX + yy * eyeY + yz * eyeZ),
-(zx * eyeX + zy * eyeY + zz * eyeZ),
1
]);
};
const updateDynamicGrid = (gl: WebGLRenderingContext, zoom: number, aspect: number) => {
const viewWidth = zoom * aspect * 2;
const viewHeight = zoom * 2;
const maxViewSize = Math.max(viewWidth, viewHeight);
let baseGridStep = 1;
if (maxViewSize > 200) {
baseGridStep = 100;
} else if (maxViewSize > 100) {
baseGridStep = 10;
} else if (maxViewSize > 50) {
baseGridStep = 10;
} else if (maxViewSize > 20) {
baseGridStep = 1;
} else if (maxViewSize > 10) {
baseGridStep = 1;
} else if (maxViewSize > 5) {
baseGridStep = 0.1;
} else {
baseGridStep = 0.01;
}
const fineGridStep = baseGridStep;
const coarseGridStep = baseGridStep * 10;
const gridRange = Math.ceil(maxViewSize * 0.75);
const vertices: number[] = [];
const coarseVertices: number[] = [];
const startX = Math.floor((-viewWidth / 2 - gridRange) / fineGridStep) * fineGridStep;
const endX = Math.ceil((viewWidth / 2 + gridRange) / fineGridStep) * fineGridStep;
const startZ = Math.floor((-viewHeight / 2 - gridRange) / fineGridStep) * fineGridStep;
const endZ = Math.ceil((viewHeight / 2 + gridRange) / fineGridStep) * fineGridStep;
for (let x = startX; x <= endX; x += fineGridStep) {
const roundedX = Math.round(x / fineGridStep) * fineGridStep;
if (Math.abs(roundedX % coarseGridStep) < 0.001) {
coarseVertices.push(roundedX, 0, startZ, roundedX, 0, endZ);
} else {
vertices.push(roundedX, 0, startZ, roundedX, 0, endZ);
}
}
for (let z = startZ; z <= endZ; z += fineGridStep) {
const roundedZ = Math.round(z / fineGridStep) * fineGridStep;
if (Math.abs(roundedZ % coarseGridStep) < 0.001) {
coarseVertices.push(startX, 0, roundedZ, endX, 0, roundedZ);
} else {
vertices.push(startX, 0, roundedZ, endX, 0, roundedZ);
}
}
if (!dynamicGridBufferRef.current) {
dynamicGridBufferRef.current = gl.createBuffer();
}
gl.bindBuffer(gl.ARRAY_BUFFER, dynamicGridBufferRef.current);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([...vertices, ...coarseVertices]), gl.DYNAMIC_DRAW);
return {
fineLineCount: vertices.length / 6,
coarseLineCount: coarseVertices.length / 6,
totalLineCount: (vertices.length + coarseVertices.length) / 6
};
};
const drawGrid = (gl: WebGLRenderingContext) => {
const program = gridProgramRef.current;
if (!program) return;
gl.useProgram(program);
const canvas = canvasRef.current;
if (!canvas) return;
const aspect = canvas.width / canvas.height;
let projectionMatrix: Float32Array;
let viewMatrix: Float32Array;
const projectionLocation = gl.getUniformLocation(program, 'projection');
const viewLocation = gl.getUniformLocation(program, 'view');
const colorLocation = gl.getUniformLocation(program, 'color');
const positionLocation = gl.getAttribLocation(program, 'position');
if (is3D) {
const buffer = gridBufferRef.current;
if (!buffer) return;
projectionMatrix = createPerspectiveMatrix(Math.PI / 4, aspect, 0.1, 100);
const eyeX = Math.cos(cameraRotation.pitch) * Math.sin(cameraRotation.yaw) * cameraDistance;
const eyeY = Math.sin(cameraRotation.pitch) * cameraDistance;
const eyeZ = Math.cos(cameraRotation.pitch) * Math.cos(cameraRotation.yaw) * cameraDistance;
viewMatrix = createLookAtMatrix(
eyeX, eyeY, eyeZ,
0, 0, 0,
0, 1, 0
);
gl.uniformMatrix4fv(projectionLocation, false, projectionMatrix);
gl.uniformMatrix4fv(viewLocation, false, viewMatrix);
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.enableVertexAttribArray(positionLocation);
gl.vertexAttribPointer(positionLocation, 3, gl.FLOAT, false, 0, 0);
gl.uniform4f(colorLocation, 0.3, 0.3, 0.35, 1.0);
const gridSize = 100;
const gridStep = 1;
const lineCount = ((gridSize * 2) / gridStep + 1) * 2;
gl.drawArrays(gl.LINES, 0, lineCount * 2);
} else {
const zoom = camera2DZoom;
const gridInfo = updateDynamicGrid(gl, zoom, aspect);
const buffer = dynamicGridBufferRef.current;
if (!buffer) return;
const halfWidth = zoom * aspect;
const halfHeight = zoom;
projectionMatrix = createOrthographicMatrix(
-halfWidth, halfWidth,
-halfHeight, halfHeight,
-100, 100
);
viewMatrix = createLookAtMatrix(
camera2DOffset.x, 50, camera2DOffset.y,
camera2DOffset.x, 0, camera2DOffset.y,
0, 0, -1
);
gl.uniformMatrix4fv(projectionLocation, false, projectionMatrix);
gl.uniformMatrix4fv(viewLocation, false, viewMatrix);
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.enableVertexAttribArray(positionLocation);
gl.vertexAttribPointer(positionLocation, 3, gl.FLOAT, false, 0, 0);
gl.uniform4f(colorLocation, 0.25, 0.25, 0.28, 1.0);
gl.drawArrays(gl.LINES, 0, gridInfo.fineLineCount * 2);
gl.uniform4f(colorLocation, 0.35, 0.35, 0.4, 1.0);
gl.drawArrays(gl.LINES, gridInfo.fineLineCount * 2, gridInfo.coarseLineCount * 2);
}
};
const drawAxis = (gl: WebGLRenderingContext) => {
const program = gridProgramRef.current;
const buffer = axisBufferRef.current;
if (!program || !buffer) return;
gl.useProgram(program);
const canvas = canvasRef.current;
if (!canvas) return;
const aspect = canvas.width / canvas.height;
let projectionMatrix: Float32Array;
let viewMatrix: Float32Array;
if (is3D) {
projectionMatrix = createPerspectiveMatrix(Math.PI / 4, aspect, 0.1, 100);
const eyeX = Math.cos(cameraRotation.pitch) * Math.sin(cameraRotation.yaw) * cameraDistance;
const eyeY = Math.sin(cameraRotation.pitch) * cameraDistance;
const eyeZ = Math.cos(cameraRotation.pitch) * Math.cos(cameraRotation.yaw) * cameraDistance;
viewMatrix = createLookAtMatrix(
eyeX, eyeY, eyeZ,
0, 0, 0,
0, 1, 0
);
} else {
const zoom = camera2DZoom;
const halfWidth = zoom * aspect;
const halfHeight = zoom;
projectionMatrix = createOrthographicMatrix(
-halfWidth, halfWidth,
-halfHeight, halfHeight,
-100, 100
);
viewMatrix = createLookAtMatrix(
camera2DOffset.x, 50, camera2DOffset.y,
camera2DOffset.x, 0, camera2DOffset.y,
0, 0, -1
);
}
const projectionLocation = gl.getUniformLocation(program, 'projection');
const viewLocation = gl.getUniformLocation(program, 'view');
const colorLocation = gl.getUniformLocation(program, 'color');
gl.uniformMatrix4fv(projectionLocation, false, projectionMatrix);
gl.uniformMatrix4fv(viewLocation, false, viewMatrix);
gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
const positionLocation = gl.getAttribLocation(program, 'position'); const positionLocation = gl.getAttribLocation(program, 'position');
gl.enableVertexAttribArray(positionLocation); gl.enableVertexAttribArray(positionLocation);
gl.vertexAttribPointer(positionLocation, 3, gl.FLOAT, false, 0, 0); gl.vertexAttribPointer(positionLocation, 3, gl.FLOAT, false, 0, 0);
const colorLocation = gl.getUniformLocation(program, 'color'); gl.lineWidth(3);
gl.uniform4f(colorLocation, 0.3, 0.3, 0.35, 1.0);
gl.drawArrays(gl.LINES, 0, vertices.length / 3); if (is3D) {
gl.uniform4f(colorLocation, 1.0, 0.0, 0.0, 1.0);
gl.drawArrays(gl.LINES, 0, 2);
gl.uniform4f(colorLocation, 0.0, 1.0, 0.0, 1.0);
gl.drawArrays(gl.LINES, 2, 2);
gl.uniform4f(colorLocation, 0.0, 0.0, 1.0, 1.0);
gl.drawArrays(gl.LINES, 4, 2);
} else {
gl.uniform4f(colorLocation, 1.0, 0.0, 0.0, 1.0);
gl.drawArrays(gl.LINES, 0, 2);
gl.uniform4f(colorLocation, 0.0, 0.0, 1.0, 1.0);
gl.drawArrays(gl.LINES, 4, 2);
}
gl.lineWidth(1);
}; };
const handlePlayPause = () => { const handlePlayPause = () => {
@@ -207,6 +621,14 @@ export function Viewport({ locale = 'en' }: ViewportProps) {
> >
{showGizmos ? <Eye size={16} /> : <EyeOff size={16} />} {showGizmos ? <Eye size={16} /> : <EyeOff size={16} />}
</button> </button>
<div className="viewport-divider" />
<button
className={`viewport-btn ${is3D ? 'active' : ''}`}
onClick={() => setIs3D(!is3D)}
title={is3D ? (locale === 'zh' ? '切换到2D' : 'Switch to 2D') : (locale === 'zh' ? '切换到3D' : 'Switch to 3D')}
>
{is3D ? <Box size={16} /> : <Square size={16} />}
</button>
</div> </div>
<div className="viewport-toolbar-right"> <div className="viewport-toolbar-right">
<button <button
@@ -230,11 +652,11 @@ export function Viewport({ locale = 'en' }: ViewportProps) {
<div className="viewport-stats"> <div className="viewport-stats">
<div className="viewport-stat"> <div className="viewport-stat">
<span className="viewport-stat-label">FPS:</span> <span className="viewport-stat-label">FPS:</span>
<span className="viewport-stat-value">60</span> <span className="viewport-stat-value">{fps}</span>
</div> </div>
<div className="viewport-stat"> <div className="viewport-stat">
<span className="viewport-stat-label">Draw Calls:</span> <span className="viewport-stat-label">Draw Calls:</span>
<span className="viewport-stat-value">0</span> <span className="viewport-stat-value">{drawCalls}</span>
</div> </div>
</div> </div>
)} )}

View File

@@ -75,8 +75,13 @@
width: 100%; width: 100%;
height: 100%; height: 100%;
display: block; display: block;
cursor: crosshair; cursor: grab;
background: #1a1a1f; background: #1a1a1f;
user-select: none;
}
.viewport-canvas:active {
cursor: grabbing;
} }
.viewport-stats { .viewport-stats {