diff --git a/packages/editor-app/src/App.tsx b/packages/editor-app/src/App.tsx index bd771323..2369afaa 100644 --- a/packages/editor-app/src/App.tsx +++ b/packages/editor-app/src/App.tsx @@ -7,6 +7,7 @@ import { SceneHierarchy } from './components/SceneHierarchy'; import { EntityInspector } from './components/EntityInspector'; import { AssetBrowser } from './components/AssetBrowser'; import { ConsolePanel } from './components/ConsolePanel'; +import { Viewport } from './components/Viewport'; import { DockContainer, DockablePanel } from './components/DockContainer'; import { TauriAPI } from './api/tauri'; import { useLocale } from './hooks/useLocale'; @@ -189,12 +190,7 @@ function App() { id: 'viewport', title: locale === 'zh' ? '视口' : 'Viewport', position: 'center', - content: ( -
-

{t('viewport.title')}

-

{t('viewport.placeholder')}

-
- ), + content: , closable: false }, { diff --git a/packages/editor-app/src/components/DockContainer.tsx b/packages/editor-app/src/components/DockContainer.tsx index 6efe627f..d1ec97cf 100644 --- a/packages/editor-app/src/components/DockContainer.tsx +++ b/packages/editor-app/src/components/DockContainer.tsx @@ -68,8 +68,8 @@ export function DockContainer({ panels, onPanelClose }: DockContainerProps) { @@ -85,8 +85,8 @@ export function DockContainer({ panels, onPanelClose }: DockContainerProps) { {renderPanelGroup('top')} diff --git a/packages/editor-app/src/components/Viewport.tsx b/packages/editor-app/src/components/Viewport.tsx new file mode 100644 index 00000000..20657849 --- /dev/null +++ b/packages/editor-app/src/components/Viewport.tsx @@ -0,0 +1,243 @@ +import { useEffect, useRef, useState } from 'react'; +import { Play, Pause, RotateCcw, Maximize2, Grid3x3, Eye, EyeOff, Activity } from 'lucide-react'; +import '../styles/Viewport.css'; + +interface ViewportProps { + locale?: string; +} + +export function Viewport({ locale = 'en' }: ViewportProps) { + const canvasRef = useRef(null); + const containerRef = useRef(null); + const [isPlaying, setIsPlaying] = useState(false); + const [showGrid, setShowGrid] = useState(true); + const [showGizmos, setShowGizmos] = useState(true); + const [showStats, setShowStats] = useState(false); + const animationFrameRef = useRef(); + const glRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const gl = canvas.getContext('webgl2') || canvas.getContext('webgl'); + if (!gl) { + console.error('WebGL not supported'); + return; + } + + glRef.current = gl; + + const resizeCanvas = () => { + if (!canvas || !containerRef.current) return; + const container = containerRef.current; + const rect = container.getBoundingClientRect(); + + const dpr = window.devicePixelRatio || 1; + canvas.width = rect.width * dpr; + canvas.height = rect.height * dpr; + canvas.style.width = `${rect.width}px`; + canvas.style.height = `${rect.height}px`; + + gl.viewport(0, 0, canvas.width, canvas.height); + }; + + resizeCanvas(); + window.addEventListener('resize', resizeCanvas); + + initWebGL(gl); + + return () => { + window.removeEventListener('resize', resizeCanvas); + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + } + }; + }, []); + + useEffect(() => { + if (isPlaying) { + startRenderLoop(); + } else { + if (animationFrameRef.current) { + cancelAnimationFrame(animationFrameRef.current); + } + } + }, [isPlaying]); + + 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); + + 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 vertexShaderSource = ` + attribute vec3 position; + uniform mat4 projection; + uniform mat4 view; + void main() { + gl_Position = projection * view * vec4(position, 1.0); + } + `; + + const fragmentShaderSource = ` + precision mediump float; + uniform vec4 color; + void main() { + gl_FragColor = color; + } + `; + + const vertexShader = gl.createShader(gl.VERTEX_SHADER)!; + gl.shaderSource(vertexShader, vertexShaderSource); + gl.compileShader(vertexShader); + + const fragmentShader = gl.createShader(gl.FRAGMENT_SHADER)!; + gl.shaderSource(fragmentShader, fragmentShaderSource); + gl.compileShader(fragmentShader); + + const program = gl.createProgram()!; + gl.attachShader(program, vertexShader); + gl.attachShader(program, fragmentShader); + gl.linkProgram(program); + gl.useProgram(program); + + const gridSize = 10; + const gridStep = 1; + const vertices: number[] = []; + + for (let i = -gridSize; i <= gridSize; i += gridStep) { + vertices.push(i, 0, -gridSize, i, 0, gridSize); + vertices.push(-gridSize, 0, i, gridSize, 0, i); + } + + const buffer = gl.createBuffer(); + gl.bindBuffer(gl.ARRAY_BUFFER, buffer); + gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(vertices), gl.STATIC_DRAW); + + 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.drawArrays(gl.LINES, 0, vertices.length / 3); + }; + + const handlePlayPause = () => { + setIsPlaying(!isPlaying); + }; + + const handleReset = () => { + setIsPlaying(false); + if (glRef.current) { + renderFrame(glRef.current, 0); + } + }; + + const handleFullscreen = () => { + if (containerRef.current) { + if (document.fullscreenElement) { + document.exitFullscreen(); + } else { + containerRef.current.requestFullscreen(); + } + } + }; + + return ( +
+
+
+ + +
+ + +
+
+ + +
+
+ + {showStats && ( +
+
+ FPS: + 60 +
+
+ Draw Calls: + 0 +
+
+ )} +
+ ); +} diff --git a/packages/editor-app/src/styles/Viewport.css b/packages/editor-app/src/styles/Viewport.css new file mode 100644 index 00000000..a49ad962 --- /dev/null +++ b/packages/editor-app/src/styles/Viewport.css @@ -0,0 +1,146 @@ +.viewport { + position: relative; + width: 100%; + height: 100%; + background: #1a1a1f; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.viewport-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 6px 8px; + background: var(--color-bg-elevated); + border-bottom: 1px solid var(--color-border-default); + flex-shrink: 0; + gap: 8px; + z-index: 10; +} + +.viewport-toolbar-left { + display: flex; + align-items: center; + gap: 4px; +} + +.viewport-toolbar-right { + display: flex; + align-items: center; + gap: 4px; +} + +.viewport-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + background: transparent; + border: 1px solid transparent; + border-radius: var(--radius-sm); + color: var(--color-text-secondary); + cursor: pointer; + transition: all var(--transition-fast); + padding: 0; +} + +.viewport-btn:hover { + background: var(--color-bg-hover); + color: var(--color-text-primary); + border-color: var(--color-border-hover); +} + +.viewport-btn.active { + background: var(--color-primary); + color: var(--color-text-inverse); + border-color: var(--color-primary); +} + +.viewport-btn:active { + transform: scale(0.95); +} + +.viewport-divider { + width: 1px; + height: 24px; + background: var(--color-border-default); + margin: 0 4px; +} + +.viewport-canvas { + flex: 1; + width: 100%; + height: 100%; + display: block; + cursor: crosshair; + background: #1a1a1f; +} + +.viewport-stats { + position: absolute; + bottom: 12px; + right: 12px; + display: flex; + flex-direction: column; + gap: 4px; + background: rgba(0, 0, 0, 0.8); + backdrop-filter: blur(8px); + border: 1px solid var(--color-border-subtle); + border-radius: var(--radius-sm); + padding: 8px 12px; + font-family: var(--font-family-mono); + font-size: 11px; + pointer-events: none; + z-index: 5; + animation: fadeIn 0.2s ease-out; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.viewport-stat { + display: flex; + align-items: center; + gap: 8px; + white-space: nowrap; +} + +.viewport-stat-label { + color: var(--color-text-secondary); + font-weight: 500; +} + +.viewport-stat-value { + color: var(--color-primary); + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.viewport:fullscreen { + background: #000; +} + +.viewport:fullscreen .viewport-canvas { + background: #000; +} + +.viewport-canvas:focus { + outline: none; +} + +@media (prefers-reduced-motion: reduce) { + .viewport-btn { + transition: none; + } +}