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;
+ }
+}