diff --git a/packages/editor-app/src/components/Viewport.tsx b/packages/editor-app/src/components/Viewport.tsx index 20657849..023c0300 100644 --- a/packages/editor-app/src/components/Viewport.tsx +++ b/packages/editor-app/src/components/Viewport.tsx @@ -1,5 +1,5 @@ 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'; interface ViewportProps { @@ -13,8 +13,23 @@ export function Viewport({ locale = 'en' }: ViewportProps) { const [showGrid, setShowGrid] = useState(true); const [showGizmos, setShowGizmos] = useState(true); const [showStats, setShowStats] = useState(false); + const [is3D, setIs3D] = useState(true); const animationFrameRef = useRef(); const glRef = useRef(null); + const gridProgramRef = useRef(null); + const gridBufferRef = useRef(null); + const dynamicGridBufferRef = useRef(null); + const axisBufferRef = useRef(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(() => { const canvas = canvasRef.current; @@ -45,60 +60,101 @@ export function Viewport({ locale = 'en' }: ViewportProps) { resizeCanvas(); window.addEventListener('resize', resizeCanvas); + const resizeObserver = new ResizeObserver(() => { + resizeCanvas(); + }); + + if (containerRef.current) { + resizeObserver.observe(containerRef.current); + } + 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 () => { 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) { cancelAnimationFrame(animationFrameRef.current); } }; - }, []); + }, [is3D]); useEffect(() => { - if (isPlaying) { - startRenderLoop(); - } else { + startRenderLoop(); + return () => { if (animationFrameRef.current) { cancelAnimationFrame(animationFrameRef.current); } - } - }, [isPlaying]); + }; + }, [isPlaying, showGrid, cameraRotation, cameraDistance, camera2DOffset, camera2DZoom, is3D]); const initWebGL = (gl: WebGLRenderingContext) => { 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.BLEND); + gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA); + initGridProgram(gl); renderFrame(gl, 0); }; - 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.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 initGridProgram = (gl: WebGLRenderingContext) => { const vertexShaderSource = ` attribute vec3 position; uniform mat4 projection; @@ -120,17 +176,33 @@ export function Viewport({ locale = 'en' }: ViewportProps) { gl.shaderSource(vertexShader, vertexShaderSource); 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)!; gl.shaderSource(fragmentShader, fragmentShaderSource); gl.compileShader(fragmentShader); + if (!gl.getShaderParameter(fragmentShader, gl.COMPILE_STATUS)) { + console.error('Fragment shader compilation error:', gl.getShaderInfoLog(fragmentShader)); + return; + } + const program = gl.createProgram()!; gl.attachShader(program, vertexShader); gl.attachShader(program, fragmentShader); 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 vertices: number[] = []; @@ -143,14 +215,356 @@ export function Viewport({ locale = 'en' }: ViewportProps) { gl.bindBuffer(gl.ARRAY_BUFFER, buffer); 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'); gl.enableVertexAttribArray(positionLocation); gl.vertexAttribPointer(positionLocation, 3, gl.FLOAT, false, 0, 0); - const colorLocation = gl.getUniformLocation(program, 'color'); - gl.uniform4f(colorLocation, 0.3, 0.3, 0.35, 1.0); + gl.lineWidth(3); - 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 = () => { @@ -207,6 +621,14 @@ export function Viewport({ locale = 'en' }: ViewportProps) { > {showGizmos ? : } +
+
)} diff --git a/packages/editor-app/src/styles/Viewport.css b/packages/editor-app/src/styles/Viewport.css index a49ad962..6b0e6b2b 100644 --- a/packages/editor-app/src/styles/Viewport.css +++ b/packages/editor-app/src/styles/Viewport.css @@ -75,8 +75,13 @@ width: 100%; height: 100%; display: block; - cursor: crosshair; + cursor: grab; background: #1a1a1f; + user-select: none; +} + +.viewport-canvas:active { + cursor: grabbing; } .viewport-stats {