From 3c7cb3f5d3e13213efa80a6f6c8207b2bd1fdcd7 Mon Sep 17 00:00:00 2001 From: AlexCat315 <99124972+AlexCat315@users.noreply.github.com> Date: Fri, 12 Jun 2026 12:56:56 +0800 Subject: [PATCH 1/4] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E8=AE=B8=E5=8F=AF?= =?UTF-8?q?=E8=AF=81=E5=9B=BE=E6=A0=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index aab7fc2a..479bda1f 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,5 @@ +[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) +# FlowNoteMauiApp # Guava Engine Guava Engine 是基于 Swift 构建的 AI-Native 游戏引擎与影视创作编辑器。 From 3f4bc9071fead3ba6b10e5403eb76e7b06a300d9 Mon Sep 17 00:00:00 2001 From: AlexCat315 <99124972+AlexCat315@users.noreply.github.com> Date: Fri, 12 Jun 2026 12:59:22 +0800 Subject: [PATCH 2/4] =?UTF-8?q?=E6=9B=B4=E6=96=B0=20README=20=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E6=8F=8F=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 479bda1f..85a8aa49 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ # FlowNoteMauiApp # Guava Engine -Guava Engine 是基于 Swift 构建的 AI-Native 游戏引擎与影视创作编辑器。 +Guava Engine 是基于 Swift 构建的 AI-Native 3D 游戏和影视引擎。 ## 构建 From 003b6440d15a3a6e8ea18b91d62015c0ac99ea14 Mon Sep 17 00:00:00 2001 From: AlexCat315 <99124972+AlexCat315@users.noreply.github.com> Date: Fri, 12 Jun 2026 13:05:05 +0800 Subject: [PATCH 3/4] =?UTF-8?q?=E5=88=9B=E5=BB=BA=E4=BA=86=E4=B8=80?= =?UTF-8?q?=E4=BB=BD=E8=8B=B1=E6=96=87=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.en.md | 210 +++++++++++++++++++++++++++++++++++++++++++++++++++ README.md | 49 ++++++++++-- 2 files changed, 254 insertions(+), 5 deletions(-) create mode 100644 README.en.md diff --git a/README.en.md b/README.en.md new file mode 100644 index 00000000..be332391 --- /dev/null +++ b/README.en.md @@ -0,0 +1,210 @@ +[![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) + +# Guava Engine + +An **AI-Native 3D game & film engine** written in Swift, built around a unified semantic world rather than a traditional translation-layer API. + +Guava's core thesis: **creative intent, world state, runtime execution, and AI understanding are not four layers — they are four views of the same thing.** Humans and AI authors share the exact same world-mutation primitives; every interaction is a training signal by default; and the world is a continuously maintained semantic graph, not a serialized snapshot. + +- 🇨🇳 **中文版**: [README.md](README.md) +- 📖 **Architecture design doc** (in Chinese): [`docs/architecture.md`](docs/architecture.md) +- 🗺 **Roadmap** (in Chinese): [`docs/roadmap.md`](docs/roadmap.md) + +--- + +## Overview + +| Layer | Technology | +|-------|-----------| +| Primary language | Swift 6.1+ | +| Render backend | WebGPU (wgpu-native) | +| UI layout | Yoga (Meta) | +| Text shaping / rendering | HarfBuzz + FreeType | +| Physics | Jolt Physics | +| Platform shell | SDL3 / native | +| Target platform (first) | macOS 14+ (Sonoma); Linux & Windows in progress | + +The repository ships three top-level SwiftPM packages plus an MCP server: + +- **`Engine/`** — rendering, scene, simulation, asset pipeline, AI runtime kernels, observation bus, film pipeline (EXR / color / denoise / render farm). +- **`GuavaUI/`** — a declarative, SwiftUI-style UI framework with a wgpu renderer. Also the foundation of the in-game UI, injected into the engine via a protocol (no circular dependency). +- **`Editor/`** — the desktop editor application. Hosts the engine, mounts GuavaUI panels (Hierarchy / Inspector / Console / Asset Browser / Viewport / Sequencer / Farm Dashboard …), and is the primary surface for AI interactions. +- **`guava-mcp/`** — an [MCP](https://modelcontextprotocol.io/) server that exposes Guava capabilities to LLM agents over stdio / WebSocket. + +--- + +## Architecture at a Glance + +``` +Editor (top-level app) + ├─ EditorCore (state machine, panel registry, transaction host, AI UI) + │ ├───► Engine (SceneRuntime, AssetPipeline, ObservationBus, CapabilityGraph) + │ └───► GuavaUI (Compose API + wgpu renderer, shared WGPUDevice with Engine) + │ + GuavaUI (UI framework) + └───► Engine.RHIWGPU (device & surface only; no hard dependency on 3D rendering) + + Engine (core) + ├── RHIWGPU / RenderBackend / SceneRuntime / ScriptRuntime + ├── AIRuntime (Session, WorldView, Signal, Proposal, Edit) + ├── ObservationBus (event bus, cross-process bridge at M11) + └── (film) SequenceRuntime, CinematicRenderer, ColorPipeline, ImageIO, RenderFarm +``` + +### Core AI concepts + +- **World** — the single source of truth. Entities carry three property layers: `authored` (human-set, permanent), `evaluated` (engine-derived, e.g. world-space position), and `inferred` (AI-inferred, confidence-tagged). Relationships are first-class directed edges. +- **Session** — an AI's continuous presence in a project (persists across editor restarts). Maintains a `WorldView` incrementally updated from `WorldEvent`s. +- **Signal** — the unified input representation. Covers natural language, direct manipulation, reference images, code diffs, selection changes, and `UserCorrection` (the most valuable signal: AI proposed X, user edited to Y). +- **Proposal** — any author's intent-to-change. Always validated, optionally staged for ghost-world preview, optionally requires user confirmation. +- **Edit** — a change already applied to the World. Every Edit is a training datum by default; there is no separate "intent training logger." + +Full design (in Chinese): [`docs/architecture.md`](docs/architecture.md). + +### Roadmap + +- **M0 – foundation** ✅ three packages build cleanly with `swift build`. +- **M1 – first 3D frame + UI data layer** ✅ first real wgpu scene; NodeTree + Recomposer. +- **M2 – visible UI frame** ✅ GuavaUI window renders real rectangles + text. +- **M3 – editor UI takes shape** ✅ Hierarchy / Inspector / Console; theme & default styles system complete. +- **M4 – full editing workflow** ✅ asset browser + viewport + gizmo; multi-threaded rendering. +- **M5 – in-game UI + asset pipeline** ✅ GLTF import; `InGameUIProviding` protocol injection. +- **M6 – cross-platform + packaging** ongoing — Windows / Linux compile; standalone `.app` output. +- **M7 – AI skeleton + film foundation** ongoing — `ObservationBus`, `CapabilityRuntime`, `IntentIR`/`TransactionIR`, `MinimalConfirmationUI`; `SequenceRuntime`, `CinematicRenderer`, EXR I/O, OCIO/ACES baseline. +- **M8 – film rendering depth** multi-bounce path tracing, denoise (OIDN), Cryptomatte, deep EXR, lookdev. +- **M9 – semantic pipeline** StructureExtractor → GeometryFingerprinter → SemanticAnalyzer → SemanticMemoryStore; first capability verbs registered. +- **M10 – scene-from-image + first end-to-end workflows** reference image → SceneDocument; LLM agent bridge; film dailies & game playtest loops. +- **M11 – render farm + long-session memory** cross-process ObservationBus bridge; farm orchestrator; cross-session WorldView reconciliation. + +Full roadmap with per-milestone acceptance criteria (in Chinese): [`docs/roadmap.md`](docs/roadmap.md). + +--- + +## Building + +### Prerequisites + +- Swift 6.1+ (official Swift toolchain) +- CMake 3.20+ +- A C/C++ toolchain: + - **macOS**: Xcode Command Line Tools + - **Linux**: GCC or Clang + - **Windows**: Visual Studio 2022 (C++ workload) +- Git + +### First build + +```bash +git clone https://github.com/AlexCat315/Guava-Engine.git +cd Guava-Engine +git submodule update --init --recursive +python bootstrap.py +swift build --package-path Editor +``` + +`bootstrap.py` compiles the native C/C++ dependencies (Yoga, FreeType, HarfBuzz, SDL3, Jolt, OpenEXR/Imath) for each package into its `vendor/` directory and wraps them as `.artifactbundle`s consumable by SwiftPM. It is cross-platform and initializes the MSVC toolchain on Windows. + +After the first bootstrap, day-to-day development only needs: + +```bash +swift build --package-path Editor +``` + +> **Force rebuild of native deps:** `python bootstrap.py --force` + +### Individual packages + +```bash +swift build --package-path Engine # engine + renderers +swift build --package-path GuavaUI # UI framework (demo: swift run GuavaUIDemo) +swift build --package-path Editor # editor (run: swift run GuavaEditor) +swift build --package-path guava-mcp # MCP server (run: swift run GuavaMCP) +``` + +--- + +## Vendored Dependencies + +| Library | Form | Source | +|---------|------|--------| +| Yoga | CMake source build → `.artifactbundle` | submodule under `GuavaUI/third-party/yoga` | +| FreeType | CMake source build → `.artifactbundle` | submodule under `GuavaUI/third-party/freetype` | +| HarfBuzz | CMake source build → `.artifactbundle` | submodule under `GuavaUI/third-party/harfbuzz` | +| SDL3 | CMake source build → `.artifactbundle` | submodule under `Engine/third-party/sdl3` | +| Imath | CMake source build | submodule under `Engine/third-party/imath` | +| OpenEXR | CMake source build | submodule under `Engine/third-party/openexr` | +| JoltPhysics | CMake source build → `.artifactbundle` | submodule under `Engine/third-party/jolt` | +| wgpu-native | prebuilt binary downloaded from gfx-rs releases at configure time | no submodule | + +The pattern is uniform: each SPM package owns its native deps under `/third-party/`, CMake builds them into `/vendor/` (gitignored), and the package consumes them via `.binaryTarget(path:)`. + +--- + +## Project Structure + +``` +Guava-Engine/ +├── Engine/Sources/ +│ ├── AIRuntime/ (Session, WorldView, Signal, Proposal, RetryPolicy) +│ ├── AssetPipeline/ (asset import, registry, loader) +│ ├── CapabilityRuntime/ (CapabilityRegistry, PreconditionChecker, EffectAnalyzer) +│ ├── CinematicRenderer/ (PathTracer, BSDF, SamplingStrategy, AOV) +│ ├── ColorPipeline/ (OCIO bridge, ACES config, view/display transform) +│ ├── EngineCore/ (core types, RingBuffer, EngineFFI) +│ ├── EngineKernel/ (boot → input → simulation → render submit tick loop) +│ ├── ContextMemory/ (cross-session symbolic memory, reducers) +│ ├── DenoiseBridge/ (OIDN / OptiX bridges) +│ ├── ImageIO/ (EXRReader, EXRWriter, DeepEXRWriter, Cryptomatte) +│ ├── IntentRuntime/ (Edit, IntentIR, TransactionIR, TransactionExecutor, AmbiguityScorer) +│ ├── ObservationBus/ (Publisher/Subscriber, Bus bridge) +│ ├── PlatformShell/ (SDL3 + native platform abstraction, Cursor, logging) +│ ├── RenderBackend/ (render packet, multi-threaded renderer scheduling) +│ ├── RenderFarm/ (orchestrator, worker, job scheduler, result collector) +│ ├── RHIWGPU/ (WebGPU RHI abstraction: BindGroup, Pipeline, Texture, etc.) +│ ├── SceneRuntime/ (ECS, entities, components, audio, physics, prefabs, schedules) +│ ├── SceneFromImage/ (reference image → layout inference → scene draft) +│ ├── ScriptRuntime/ (script context, component, lifecycle, input, drawUI) +│ ├── SemanticPipeline/ (structure extraction, fingerprinting, analysis, memory) +│ ├── SequenceRuntime/ (SequenceDocument, ShotEvaluator, ClipScheduler) +│ └── SIMDCompat/ (cross-platform SIMD helpers) +├── GuavaUI/Sources/ +│ ├── Bridge/CYoga/ (C bindings for Yoga) +│ ├── Font/ (bundled fonts, FontAtlas, TextShaper) +│ ├── GuavaUIApp/ (AppConfig, window host, WGPU surface assembly) +│ ├── GuavaUIDemo/ (runnable demo entry point) +│ └── GuavaUIRuntime/ (Node, NodeTree, Layout, Color, State, Theme, DrawList wgpu renderer) +├── Editor/Sources/ +│ ├── EditorApp/ (main.swift, MainWindow, panels) +│ └── EditorCore/ (editor state, project manager, AI/EditLog, GizmoSystem, etc.) +├── guava-mcp/Sources/GuavaMCP/ (MCP server exposing Guava capabilities) +├── docs/ (architecture, roadmap, component & blueprint docs) +└── .github/workflows/ (CI matrices for engine / editor / UI / MCP + release) +``` + +--- + +## Continuous Integration + +GitHub Actions workflows live under `.github/workflows/`: + +- `ci-engine.yml` — Engine package matrix. +- `ci-editor.yml` — Editor package. +- `ci-guava-ui.yml` — GuavaUI package. +- `ci-guava-mcp.yml` — MCP server package. +- `release.yml` — release builds. + +--- + +## Documentation + +- **Architecture (AI-Native design, in Chinese):** [`docs/architecture.md`](docs/architecture.md) +- **Full roadmap & milestones (in Chinese):** [`docs/roadmap.md`](docs/roadmap.md) +- **Project overview (in Chinese):** [`docs/project-overview.md`](docs/project-overview.md) +- **UI design system (in Chinese):** [`docs/components/`](docs/components/) +- **MCP tools (AI tools API):** [`docs/api/ai-tools.md`](docs/api/ai-tools.md) + +--- + +## License + +Guava Engine is open source under the [Apache License 2.0](LICENSE). Third-party dependencies retain their upstream licenses; see [NOTICE](NOTICE) for attribution. diff --git a/README.md b/README.md index 85a8aa49..eaeec039 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,16 @@ [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) -# FlowNoteMauiApp + # Guava Engine -Guava Engine 是基于 Swift 构建的 AI-Native 3D 游戏和影视引擎。 +> 基于 Swift 构建的 AI-Native 3D 游戏与影视引擎。 + +Guava 的核心命题是:**创作意图、世界状态、运行时执行、AI 理解——不是四层,是同一件事的四个视角。** 人类作者与 AI 共用完全相同的世界变更原语;每一次交互本身就是训练信号;世界是持续维护的语义图,而不是按需序列化的快照。 + +- 🇬🇧 **English version**: [README.en.md](README.en.md) +- 📖 **架构设计文档(中文)**:[`docs/architecture.md`](docs/architecture.md) +- 🗺 **路线图(中文)**:[`docs/roadmap.md`](docs/roadmap.md) + +--- ## 构建 @@ -28,11 +36,26 @@ swift build --package-path Editor `bootstrap.py` 跨平台统一:编译 Engine + GuavaUI 的 C/C++ 原生依赖(CMake),Windows 下自动初始化 MSVC 工具链。 -后续日常开发只需要 `swift build --package-path Editor`。 +后续日常开发只需要: + +```bash +swift build --package-path Editor +``` > **强制重编译原生依赖**:`python bootstrap.py --force` -### 第三方依赖 +### 按包独立构建 + +```bash +swift build --package-path Engine # 引擎核心 +swift build --package-path GuavaUI # UI 框架(运行示例: swift run GuavaUIDemo) +swift build --package-path Editor # 编辑器(运行: swift run GuavaEditor) +swift build --package-path guava-mcp # MCP 服务(运行: swift run GuavaMCP) +``` + +--- + +## 第三方依赖 | 库 | 形式 | 来源 | |----|------|------| @@ -45,7 +68,23 @@ swift build --package-path Editor | JoltPhysics | CMake 源码编译 → `.artifactbundle` | submodule `Engine/third-party/jolt` | | wgpu-native | 配置时从 gfx-rs 公开 release 下载 | 无 submodule(Rust 项目,CMake 无法源码编译) | -构建模式:每个 SPM 包的 native 依赖都在自己的 `/third-party/` 下,CMake 编译产物落到 `/vendor/`(gitignored),SPM 通过 `.binaryTarget(path:)` 消费。统一模式,跨平台一致。 +构建模式:每个 SPM 包的 native 依赖都在自己的 `/third-party/` 下,CMake 编译产物落到 `/vendor/`(gitignored),SPM 通过 `.binaryTarget(path:)` 消费。 + +--- + +## 项目结构 + +``` +Guava-Engine/ +├── Engine/Sources/ 渲染 / 场景 / 物理 / 资产 / AI 运行时 / 观察总线 / 影视管线 +├── GuavaUI/Sources/ 声明式 UI 框架(Compose API + wgpu 渲染器) +├── Editor/Sources/ 桌面编辑器(状态机、面板、AI 宿主) +├── guava-mcp/Sources/ MCP 服务器(暴露 Guava 能力给 LLM agent) +├── docs/ 架构、路线图、组件与蓝图文档 +└── .github/workflows/ CI 矩阵与 release +``` + +--- ## 许可证 From b1c19b44af93d3e8258a6845d2d66b6097b73f73 Mon Sep 17 00:00:00 2001 From: AlexCat315 <99124972+AlexCat315@users.noreply.github.com> Date: Fri, 12 Jun 2026 17:35:43 +0800 Subject: [PATCH 4/4] Update viewport resolution handling and post-processing shaders --- .../EditorApp/Panels/ViewportPanel.swift | 72 +++++- Editor/Sources/EditorApp/main.swift | 10 +- .../Editing/EditorGizmoController.swift | 8 +- .../EditorViewportInputController.swift | 10 + .../Editing/EditorViewportResolution.swift | 30 +++ Editor/Sources/EditorCore/EditorCore.swift | 30 ++- .../EditorCore/State/EditorReducer.swift | 8 + .../EditorCore/State/EditorState.swift | 20 ++ .../EditorCore/State/EditorStore.swift | 12 + Editor/Sources/GuavaPlayer/main.swift | 15 +- .../EditorViewportResolutionTests.swift | 123 ++++++++++ .../RenderBackend/Core/RenderBackend.swift | 213 ++++++++++++------ .../RenderBackend/Core/RenderUniforms.swift | 9 + .../Core/ViewportTargetAllocation.swift | 36 +++ .../Core/WGPURenderer+FrameState.swift | 6 +- .../Resources/Shaders/WGSL/bloom.wgsl | 9 +- .../Resources/Shaders/WGSL/fxaa.wgsl | 29 ++- .../Shaders/WGSL/ink_paper_post.wgsl | 13 +- .../Resources/Shaders/WGSL/ssao.wgsl | 24 +- .../Resources/Shaders/WGSL/ssr.wgsl | 31 ++- .../Resources/Shaders/WGSL/taa.wgsl | 13 +- .../Resources/Shaders/WGSL/tonemap.wgsl | 11 +- .../Surface/ViewportSurface.swift | 11 + .../ViewportTargetAllocationTests.swift | 61 +++++ GuavaUI/Sources/GuavaUIApp/InGameUIHost.swift | 6 +- .../InGameViewGraphBridge.swift | 4 +- .../Primitives/ViewportHost.swift | 22 +- .../GuavaUIRuntime/InGameUIRuntime.swift | 6 +- .../ViewportHostScaleTests.swift | 95 ++++++++ 29 files changed, 809 insertions(+), 128 deletions(-) create mode 100644 Editor/Sources/EditorCore/Editing/EditorViewportResolution.swift create mode 100644 Editor/Tests/EditorCoreTests/EditorViewportResolutionTests.swift create mode 100644 Engine/Sources/RenderBackend/Core/ViewportTargetAllocation.swift create mode 100644 Engine/Tests/EngineCoreTests/RenderBackend/ViewportTargetAllocationTests.swift create mode 100644 GuavaUI/Tests/GuavaUIComposeTests/ViewportHostScaleTests.swift diff --git a/Editor/Sources/EditorApp/Panels/ViewportPanel.swift b/Editor/Sources/EditorApp/Panels/ViewportPanel.swift index ec31f80d..e2116c96 100644 --- a/Editor/Sources/EditorApp/Panels/ViewportPanel.swift +++ b/Editor/Sources/EditorApp/Panels/ViewportPanel.swift @@ -27,13 +27,14 @@ struct ViewportPanel: View { let gizmoSpace = store.gizmoSpace let shadingMode = store.viewportShadingMode let shadowsEnabled = store.viewportShadowsEnabled + let renderScalePercent = store.viewportRenderScalePercent + let interactionDownscaleEnabled = store.viewportInteractionDownscaleEnabled let playbackState = store.playbackState // 推送 gizmo 控制器所需的快照(摄像机 / 视口矩形 / 实体世界坐标)。 let _: Void = updateGizmoSnapshot(selectedID: selectedEntityID, gizmoMode: gizmoMode, - gizmoSpace: gizmoSpace, - surface: surface) + gizmoSpace: gizmoSpace) ViewportHost(surface: surface, onInputEvent: { event in @@ -71,6 +72,8 @@ struct ViewportPanel: View { gizmoSpace: gizmoSpace, shadingMode: shadingMode, shadowsEnabled: shadowsEnabled, + renderScalePercent: renderScalePercent, + interactionDownscaleEnabled: interactionDownscaleEnabled, playbackState: playbackState, onSelectGizmoMode: { mode in if gizmoMode != mode { @@ -88,6 +91,12 @@ struct ViewportPanel: View { onToggleShadows: { app.setViewportShadowsEnabled(!shadowsEnabled) }, + onSelectRenderScale: { percent in + app.setViewportRenderScalePercent(percent) + }, + onToggleInteractionDownscale: { + app.setViewportInteractionDownscaleEnabled(!interactionDownscaleEnabled) + }, onPlay: { app.applyPlaybackState(.playing) }, onPause: { app.applyPlaybackState(.paused) }, onStop: { app.applyPlaybackState(.stopped) }) @@ -381,8 +390,7 @@ struct ViewportPanel: View { private func updateGizmoSnapshot(selectedID: UInt64?, gizmoMode: EditorGizmoMode, - gizmoSpace: EditorGizmoSpace, - surface: ViewportSurfaceState) { + gizmoSpace: EditorGizmoSpace) { guard let mode = controllerMode(for: gizmoMode), let id = selectedID, let world = scene.entityWorldPosition(id), @@ -405,8 +413,6 @@ struct ViewportPanel: View { space: gizmoSpace == .local ? .local : .world, camera: camera, frame: frame, - drawableWidth: Float(surface.width), - drawableHeight: Float(surface.height), entityID: id, entityWorldPosition: world, entityWorldMatrix: worldMatrix, @@ -1202,11 +1208,15 @@ private struct ViewportInfoBar: View { let gizmoSpace: EditorGizmoSpace let shadingMode: EditorViewportShadingMode let shadowsEnabled: Bool + let renderScalePercent: Int + let interactionDownscaleEnabled: Bool let playbackState: PlaybackState let onSelectGizmoMode: (EditorGizmoMode) -> Void let onSelectGizmoSpace: (EditorGizmoSpace) -> Void let onSelectShadingMode: (EditorViewportShadingMode) -> Void let onToggleShadows: () -> Void + let onSelectRenderScale: (Int) -> Void + let onToggleInteractionDownscale: () -> Void let onPlay: () -> Void let onPause: () -> Void let onStop: () -> Void @@ -1260,6 +1270,11 @@ private struct ViewportInfoBar: View { } .buttonStyle(.toggle) + RenderScaleSelector(percent: renderScalePercent, + interactionDownscaleEnabled: interactionDownscaleEnabled, + onSelect: onSelectRenderScale, + onToggleInteractionDownscale: onToggleInteractionDownscale) + Divider() .frame(width: 1, height: 16) .foregroundColor(Color(r: 0, g: 0, b: 0, a: 0.4)) @@ -1430,6 +1445,51 @@ private struct GizmoAxisChip: View { } } +/// Unreal-style screen percentage for the 3D viewport: presentation size is +/// fixed, the engine renders `percent` of it and the composite upscales. +private struct RenderScaleSelector: View { + let percent: Int + let interactionDownscaleEnabled: Bool + let onSelect: (Int) -> Void + let onToggleInteractionDownscale: () -> Void + @State private var isPresented: Bool = false + + private static let presets = [50, 75, 100, 150, 200] + + var body: some View { + Popover(isPresented: $isPresented, width: 168) { + Row(alignment: .center, spacing: 5) { + Text("\(percent)%", lineLimit: 1) + .font(.caption) + .foregroundColor(.onSurface) + Icon(UICommonIcons.chevronDown, size: 8, color: .onSurfaceMuted) + } + .padding(horizontal: 8, vertical: 4) + .background(.surfaceSunken) + .cornerRadius(3) + } content: { + Menu(menuEntries, width: 168, maxVisibleRows: 8, onItemActivated: { + isPresented = false + }) + } + } + + private var menuEntries: [MenuEntry] { + var entries: [MenuEntry] = Self.presets.map { preset in + .item(MenuItem(id: "renderscale-\(preset)", + title: "\(preset)%", + isSelected: preset == percent, + action: { onSelect(preset) })) + } + entries.append(.separator(id: "renderscale-sep")) + entries.append(.item(MenuItem(id: "renderscale-interaction", + title: L("Downscale while navigating"), + isSelected: interactionDownscaleEnabled, + action: { onToggleInteractionDownscale() }))) + return entries + } +} + private struct ViewModeSelector: View { let shadingMode: EditorViewportShadingMode let onSelect: (EditorViewportShadingMode) -> Void diff --git a/Editor/Sources/EditorApp/main.swift b/Editor/Sources/EditorApp/main.swift index cdbeef39..e8c62a49 100644 --- a/Editor/Sources/EditorApp/main.swift +++ b/Editor/Sources/EditorApp/main.swift @@ -69,9 +69,13 @@ private func runLegacyEditor(launchOptions: EditorAppLaunchOptions) throws { events: events, onTick: { dt in context.tick(deltaTime: dt) - if let bundle = context.bundle { - let size = bundle.app.viewportDrawableSize - inGameUIHost.tick(width: Int(size.width), height: Int(size.height)) + if context.bundle != nil { + // HUD 布局用视口的逻辑尺寸;光栅化按窗口 content scale。 + let scale = max(1, ContentScaleHolder.current) + let frame = EditorViewportDropTarget.frame + let logicalW = Int((frame?.width ?? 1280).rounded()) + let logicalH = Int((frame?.height ?? 720).rounded()) + inGameUIHost.tick(width: logicalW, height: logicalH, contentScale: scale) } }, onDisplayReady: { display in diff --git a/Editor/Sources/EditorCore/Editing/EditorGizmoController.swift b/Editor/Sources/EditorCore/Editing/EditorGizmoController.swift index 8a62a1bb..b437048c 100644 --- a/Editor/Sources/EditorCore/Editing/EditorGizmoController.swift +++ b/Editor/Sources/EditorCore/Editing/EditorGizmoController.swift @@ -91,13 +91,13 @@ public final class EditorGizmoController: @unchecked Sendable { } } + /// All screen-space math runs in the logical `frame` coordinates; the + /// engine's pixel resolution (render scale, HiDPI) never enters here. public struct Snapshot { public var mode: Mode public var space: GizmoSpace public var camera: RenderCamera public var frame: ViewportScreenFrame - public var drawableWidth: Float - public var drawableHeight: Float public var entityID: UInt64 public var entityWorldPosition: SIMD3 public var entityWorldMatrix: simd_float4x4 @@ -109,8 +109,6 @@ public final class EditorGizmoController: @unchecked Sendable { space: GizmoSpace = .local, camera: RenderCamera, frame: ViewportScreenFrame, - drawableWidth: Float, - drawableHeight: Float, entityID: UInt64, entityWorldPosition: SIMD3, entityWorldMatrix: simd_float4x4, @@ -121,8 +119,6 @@ public final class EditorGizmoController: @unchecked Sendable { self.space = space self.camera = camera self.frame = frame - self.drawableWidth = drawableWidth - self.drawableHeight = drawableHeight self.entityID = entityID self.entityWorldPosition = entityWorldPosition self.entityWorldMatrix = entityWorldMatrix diff --git a/Editor/Sources/EditorCore/Editing/EditorViewportInputController.swift b/Editor/Sources/EditorCore/Editing/EditorViewportInputController.swift index a598f694..b53b7337 100644 --- a/Editor/Sources/EditorCore/Editing/EditorViewportInputController.swift +++ b/Editor/Sources/EditorCore/Editing/EditorViewportInputController.swift @@ -49,6 +49,16 @@ public final class EditorViewportInputController: @unchecked Sendable { activeInteraction != nil } + /// True while the scene re-renders continuously (camera or gizmo drags) — + /// the window where interaction downscale pays off. Clicks and marquee + /// selection leave the camera static, so they stay full-res. + public var isContinuousSceneInteractionActive: Bool { + switch activeInteraction { + case .camera, .gizmo: return true + case .pendingClick, .marquee, nil: return false + } + } + public func begin(_ interaction: ActiveInteraction, at point: (x: Float, y: Float), modifiers: KeyModifiers) { diff --git a/Editor/Sources/EditorCore/Editing/EditorViewportResolution.swift b/Editor/Sources/EditorCore/Editing/EditorViewportResolution.swift new file mode 100644 index 00000000..e7c3748e --- /dev/null +++ b/Editor/Sources/EditorCore/Editing/EditorViewportResolution.swift @@ -0,0 +1,30 @@ +import RenderBackend + +/// Maps the viewport's presentation size (physical pixels of the on-screen +/// quad) to the engine's render resolution: presentation × renderScale × +/// optional interaction downscale. Pure math, kept separate for tests. +public enum EditorViewportResolution { + public static let maxDimension: UInt32 = 16_384 + /// Extra factor applied while a camera / gizmo drag is active and the + /// interaction-downscale toggle is on. + public static let interactionFactor: Float = 0.5 + + public static func effectiveSize(presentation: RenderDrawableSize, + renderScalePercent: Int, + interactionDownscaleActive: Bool) -> RenderDrawableSize { + var scale = Float(EditorState.sanitizedRenderScalePercent(renderScalePercent)) / 100 + if interactionDownscaleActive { + scale *= interactionFactor + } + return RenderDrawableSize( + width: scaled(presentation.width, by: scale), + height: scaled(presentation.height, by: scale) + ) + } + + private static func scaled(_ value: UInt32, by scale: Float) -> UInt32 { + let raw = (Float(value) * scale).rounded() + guard raw >= 1 else { return 1 } + return min(UInt32(raw), maxDimension) + } +} diff --git a/Editor/Sources/EditorCore/EditorCore.swift b/Editor/Sources/EditorCore/EditorCore.swift index 36c2cf39..34d84b8e 100644 --- a/Editor/Sources/EditorCore/EditorCore.swift +++ b/Editor/Sources/EditorCore/EditorCore.swift @@ -180,7 +180,7 @@ public final class EditorApplication: @unchecked Sendable { engine.tick( deltaTime: deltaTime, inputEvents: inputEvents, - drawableSize: _viewportDrawableSize, + drawableSize: effectiveViewportDrawableSize(), shouldRender: store.state.shouldRender, renderSceneOverride: scene.currentRenderScene(), jointPaletteOverride: scene.currentJointPaletteMap() @@ -213,6 +213,9 @@ public final class EditorApplication: @unchecked Sendable { pendingViewportEvents.append(event) } + /// Presentation size of the viewport in physical pixels (reported by + /// `ViewportHost`). The engine renders this scaled by the render-scale + /// settings — see `effectiveViewportDrawableSize()`. public var viewportDrawableSize: RenderDrawableSize { _viewportDrawableSize } public func setViewportDrawableSize(_ size: RenderDrawableSize) { @@ -220,6 +223,31 @@ public final class EditorApplication: @unchecked Sendable { _viewportDrawableSize = size } + private func effectiveViewportDrawableSize() -> RenderDrawableSize { + let state = store.state + let interacting = state.viewportInteractionDownscaleEnabled + && EditorViewportInputController.shared.isContinuousSceneInteractionActive + return EditorViewportResolution.effectiveSize( + presentation: _viewportDrawableSize, + renderScalePercent: state.viewportRenderScalePercent, + interactionDownscaleActive: interacting + ) + } + + public func setViewportRenderScalePercent(_ percent: Int) { + let sanitized = EditorState.sanitizedRenderScalePercent(percent) + guard store.state.viewportRenderScalePercent != sanitized else { return } + store.dispatch(.setViewportRenderScalePercent(sanitized)) + logConsole("Viewport render scale \(sanitized)%") + } + + public func setViewportInteractionDownscaleEnabled(_ enabled: Bool) { + guard store.state.viewportInteractionDownscaleEnabled != enabled else { return } + store.dispatch(.setViewportInteractionDownscale(enabled)) + logConsole(enabled ? "Viewport interaction downscale enabled" + : "Viewport interaction downscale disabled") + } + public func setViewportRenderCompletionHandler(_ handler: (@Sendable (ViewportSurfaceState) -> Void)?) { engine.setRenderCompletionHandler { completion in handler?(completion.viewportSurfaceState) diff --git a/Editor/Sources/EditorCore/State/EditorReducer.swift b/Editor/Sources/EditorCore/State/EditorReducer.swift index 19994a15..71dad934 100644 --- a/Editor/Sources/EditorCore/State/EditorReducer.swift +++ b/Editor/Sources/EditorCore/State/EditorReducer.swift @@ -21,6 +21,8 @@ public enum EditorAction: Sendable { case setGizmoSpace(EditorGizmoSpace) case setViewportShadingMode(EditorViewportShadingMode) case setViewportShadowsEnabled(Bool) + case setViewportRenderScalePercent(Int) + case setViewportInteractionDownscale(Bool) case setTranslateSnapEnabled(Bool) case setRotateSnapEnabled(Bool) case setScaleSnapEnabled(Bool) @@ -118,6 +120,12 @@ public enum EditorReducer { case let .setViewportShadowsEnabled(enabled): state.viewportShadowsEnabled = enabled + case let .setViewportRenderScalePercent(percent): + state.viewportRenderScalePercent = EditorState.sanitizedRenderScalePercent(percent) + + case let .setViewportInteractionDownscale(enabled): + state.viewportInteractionDownscaleEnabled = enabled + case let .setTranslateSnapEnabled(enabled): state.translateSnapEnabled = enabled diff --git a/Editor/Sources/EditorCore/State/EditorState.swift b/Editor/Sources/EditorCore/State/EditorState.swift index 8eff3fd2..b359a28c 100644 --- a/Editor/Sources/EditorCore/State/EditorState.swift +++ b/Editor/Sources/EditorCore/State/EditorState.swift @@ -250,6 +250,12 @@ public struct EditorState: Codable, Sendable { public var viewportDirectionalCascadeCount: Int public var viewportDirectionalCascadeSplitLambda: Float public var viewportShadowDebugMode: EditorViewportShadowDebugMode + /// Screen-percentage style render scale for the 3D viewport (100 = native + /// pixels). The presentation size stays fixed; the engine renders + /// `presentation × percent/100` and the composite quad rescales. + public var viewportRenderScalePercent: Int + /// Temporarily halve the render resolution during camera / gizmo drags. + public var viewportInteractionDownscaleEnabled: Bool public var translateSnapEnabled: Bool public var rotateSnapEnabled: Bool public var scaleSnapEnabled: Bool @@ -293,6 +299,8 @@ public struct EditorState: Codable, Sendable { viewportDirectionalCascadeCount: Int = 1, viewportDirectionalCascadeSplitLambda: Float = 0.55, viewportShadowDebugMode: EditorViewportShadowDebugMode = .off, + viewportRenderScalePercent: Int = 100, + viewportInteractionDownscaleEnabled: Bool = false, translateSnapEnabled: Bool = false, rotateSnapEnabled: Bool = false, scaleSnapEnabled: Bool = false, @@ -336,6 +344,8 @@ public struct EditorState: Codable, Sendable { self.viewportDirectionalCascadeCount = Self.sanitizedDirectionalCascadeCount(viewportDirectionalCascadeCount) self.viewportDirectionalCascadeSplitLambda = Self.sanitizedDirectionalCascadeSplitLambda(viewportDirectionalCascadeSplitLambda) self.viewportShadowDebugMode = viewportShadowDebugMode + self.viewportRenderScalePercent = Self.sanitizedRenderScalePercent(viewportRenderScalePercent) + self.viewportInteractionDownscaleEnabled = viewportInteractionDownscaleEnabled self.translateSnapEnabled = translateSnapEnabled self.rotateSnapEnabled = rotateSnapEnabled self.scaleSnapEnabled = scaleSnapEnabled @@ -397,6 +407,8 @@ public struct EditorState: Codable, Sendable { case viewportDirectionalCascadeCount case viewportDirectionalCascadeSplitLambda case viewportShadowDebugMode + case viewportRenderScalePercent + case viewportInteractionDownscaleEnabled case translateSnapEnabled case rotateSnapEnabled case scaleSnapEnabled @@ -459,6 +471,8 @@ public struct EditorState: Codable, Sendable { viewportDirectionalCascadeCount: try c.decodeIfPresent(Int.self, forKey: .viewportDirectionalCascadeCount) ?? 1, viewportDirectionalCascadeSplitLambda: try c.decodeIfPresent(Float.self, forKey: .viewportDirectionalCascadeSplitLambda) ?? 0.55, viewportShadowDebugMode: try c.decodeIfPresent(EditorViewportShadowDebugMode.self, forKey: .viewportShadowDebugMode) ?? .off, + viewportRenderScalePercent: try c.decodeIfPresent(Int.self, forKey: .viewportRenderScalePercent) ?? 100, + viewportInteractionDownscaleEnabled: try c.decodeIfPresent(Bool.self, forKey: .viewportInteractionDownscaleEnabled) ?? false, translateSnapEnabled: try c.decodeIfPresent(Bool.self, forKey: .translateSnapEnabled) ?? false, rotateSnapEnabled: try c.decodeIfPresent(Bool.self, forKey: .rotateSnapEnabled) ?? false, scaleSnapEnabled: try c.decodeIfPresent(Bool.self, forKey: .scaleSnapEnabled) ?? false, @@ -503,6 +517,8 @@ public struct EditorState: Codable, Sendable { try c.encode(viewportDirectionalCascadeCount, forKey: .viewportDirectionalCascadeCount) try c.encode(viewportDirectionalCascadeSplitLambda, forKey: .viewportDirectionalCascadeSplitLambda) try c.encode(viewportShadowDebugMode, forKey: .viewportShadowDebugMode) + try c.encode(viewportRenderScalePercent, forKey: .viewportRenderScalePercent) + try c.encode(viewportInteractionDownscaleEnabled, forKey: .viewportInteractionDownscaleEnabled) try c.encode(translateSnapEnabled, forKey: .translateSnapEnabled) try c.encode(rotateSnapEnabled, forKey: .rotateSnapEnabled) try c.encode(scaleSnapEnabled, forKey: .scaleSnapEnabled) @@ -537,6 +553,10 @@ public struct EditorState: Codable, Sendable { public static func sanitizedDirectionalCascadeSplitLambda(_ value: Float) -> Float { min(max(value, 0), 1) } + + public static func sanitizedRenderScalePercent(_ value: Int) -> Int { + min(max(value, 25), 200) + } } public struct EditorPendingCloseRequest: Equatable, Sendable { diff --git a/Editor/Sources/EditorCore/State/EditorStore.swift b/Editor/Sources/EditorCore/State/EditorStore.swift index 133535ff..09688aa6 100644 --- a/Editor/Sources/EditorCore/State/EditorStore.swift +++ b/Editor/Sources/EditorCore/State/EditorStore.swift @@ -34,6 +34,8 @@ public final class EditorStore: @unchecked Sendable { case gizmoSpace case viewportShadingMode case viewportShadowsEnabled + case viewportRenderScalePercent + case viewportInteractionDownscaleEnabled case translateSnapEnabled case rotateSnapEnabled case scaleSnapEnabled @@ -157,6 +159,12 @@ public final class EditorStore: @unchecked Sendable { mark(.viewportShadingMode, old.viewportShadingMode, new.viewportShadingMode) case .setViewportShadowsEnabled: mark(.viewportShadowsEnabled, old.viewportShadowsEnabled, new.viewportShadowsEnabled) + case .setViewportRenderScalePercent: + mark(.viewportRenderScalePercent, old.viewportRenderScalePercent, new.viewportRenderScalePercent) + case .setViewportInteractionDownscale: + mark(.viewportInteractionDownscaleEnabled, + old.viewportInteractionDownscaleEnabled, + new.viewportInteractionDownscaleEnabled) case .setTranslateSnapEnabled: mark(.translateSnapEnabled, old.translateSnapEnabled, new.translateSnapEnabled) case .setRotateSnapEnabled: @@ -262,6 +270,10 @@ extension EditorStore { public var gizmoSpace: EditorGizmoSpace { read(.gizmoSpace, storage.gizmoSpace) } public var viewportShadingMode: EditorViewportShadingMode { read(.viewportShadingMode, storage.viewportShadingMode) } public var viewportShadowsEnabled: Bool { read(.viewportShadowsEnabled, storage.viewportShadowsEnabled) } + public var viewportRenderScalePercent: Int { read(.viewportRenderScalePercent, storage.viewportRenderScalePercent) } + public var viewportInteractionDownscaleEnabled: Bool { + read(.viewportInteractionDownscaleEnabled, storage.viewportInteractionDownscaleEnabled) + } public var translateSnapEnabled: Bool { read(.translateSnapEnabled, storage.translateSnapEnabled) } public var rotateSnapEnabled: Bool { read(.rotateSnapEnabled, storage.rotateSnapEnabled) } public var scaleSnapEnabled: Bool { read(.scaleSnapEnabled, storage.scaleSnapEnabled) } diff --git a/Editor/Sources/GuavaPlayer/main.swift b/Editor/Sources/GuavaPlayer/main.swift index b9f10371..a0e93fe4 100644 --- a/Editor/Sources/GuavaPlayer/main.swift +++ b/Editor/Sources/GuavaPlayer/main.swift @@ -16,6 +16,10 @@ private final class GamePlayerState: @unchecked Sendable { private let registrar = ObservableStateRegistrar() private var _viewportSurface: ViewportSurfaceState = .init() + /// Logical (point) size of the viewport, for HUD layout. Written from + /// `onScreenFrameChange`; no recompose dependency needed. + var logicalSize: (width: Float, height: Float) = (1280, 720) + /// Reading this property inside a view body registers a recompose dependency. var viewportSurface: ViewportSurfaceState { registrar.access("viewportSurface") @@ -40,7 +44,10 @@ private struct GamePlayerRootView: View { ViewportHost( surface: state.viewportSurface, onInputEvent: { app.enqueueInput($0) }, - onDrawableSizeChange: { app.setViewportDrawableSize($0) } + onDrawableSizeChange: { app.setViewportDrawableSize($0) }, + onScreenFrameChange: { frame in + state.logicalSize = (frame.width, frame.height) + } ) { EmptyView() } @@ -87,8 +94,10 @@ private func runPlayer() throws { backend: backend, onTick: { dt in app.tick(deltaTime: dt) - let size = app.viewportDrawableSize - inGameUIHost.tick(width: Int(size.width), height: Int(size.height)) + let logical = playerState.logicalSize + inGameUIHost.tick(width: Int(logical.width.rounded()), + height: Int(logical.height.rounded()), + contentScale: max(1, ContentScaleHolder.current)) } ) { GamePlayerRootView(app: app, state: playerState) diff --git a/Editor/Tests/EditorCoreTests/EditorViewportResolutionTests.swift b/Editor/Tests/EditorCoreTests/EditorViewportResolutionTests.swift new file mode 100644 index 00000000..750eb671 --- /dev/null +++ b/Editor/Tests/EditorCoreTests/EditorViewportResolutionTests.swift @@ -0,0 +1,123 @@ +import EditorCore +import Foundation +import RenderBackend +import Testing + +@Suite("EditorViewportResolution") +struct EditorViewportResolutionTests { + @Test("Native scale passes the presentation size through") + func nativeScaleIsIdentity() { + let size = EditorViewportResolution.effectiveSize( + presentation: RenderDrawableSize(width: 2800, height: 1600), + renderScalePercent: 100, + interactionDownscaleActive: false + ) + #expect(size == RenderDrawableSize(width: 2800, height: 1600)) + } + + @Test("Render scale percent shrinks and supersamples with rounding") + func percentScales() { + #expect(EditorViewportResolution.effectiveSize( + presentation: RenderDrawableSize(width: 2801, height: 1601), + renderScalePercent: 50, + interactionDownscaleActive: false + ) == RenderDrawableSize(width: 1401, height: 801)) + + #expect(EditorViewportResolution.effectiveSize( + presentation: RenderDrawableSize(width: 1400, height: 800), + renderScalePercent: 200, + interactionDownscaleActive: false + ) == RenderDrawableSize(width: 2800, height: 1600)) + } + + @Test("Interaction downscale stacks an extra halving on top of the scale") + func interactionFactorStacks() { + let size = EditorViewportResolution.effectiveSize( + presentation: RenderDrawableSize(width: 2800, height: 1600), + renderScalePercent: 100, + interactionDownscaleActive: true + ) + #expect(size == RenderDrawableSize(width: 1400, height: 800)) + + let scaled = EditorViewportResolution.effectiveSize( + presentation: RenderDrawableSize(width: 2800, height: 1600), + renderScalePercent: 50, + interactionDownscaleActive: true + ) + #expect(scaled == RenderDrawableSize(width: 700, height: 400)) + } + + @Test("Effective size never collapses below one pixel and sanitizes the percent") + func clampsAndSanitizes() { + #expect(EditorViewportResolution.effectiveSize( + presentation: RenderDrawableSize(width: 1, height: 1), + renderScalePercent: 25, + interactionDownscaleActive: true + ) == RenderDrawableSize(width: 1, height: 1)) + + // Out-of-range percents clamp to the sanitized bounds (25–200). + #expect(EditorViewportResolution.effectiveSize( + presentation: RenderDrawableSize(width: 1000, height: 1000), + renderScalePercent: 5, + interactionDownscaleActive: false + ) == RenderDrawableSize(width: 250, height: 250)) + #expect(EditorViewportResolution.effectiveSize( + presentation: RenderDrawableSize(width: 1000, height: 1000), + renderScalePercent: 9999, + interactionDownscaleActive: false + ) == RenderDrawableSize(width: 2000, height: 2000)) + } + + @Test("Reducer sanitizes the render scale percent and toggles downscale") + func reducerHandlesRenderScaleActions() { + var state = EditorState() + #expect(state.viewportRenderScalePercent == 100) + #expect(!state.viewportInteractionDownscaleEnabled) + + EditorReducer.reduce(state: &state, action: .setViewportRenderScalePercent(75)) + #expect(state.viewportRenderScalePercent == 75) + + EditorReducer.reduce(state: &state, action: .setViewportRenderScalePercent(9999)) + #expect(state.viewportRenderScalePercent == 200) + + EditorReducer.reduce(state: &state, action: .setViewportRenderScalePercent(1)) + #expect(state.viewportRenderScalePercent == 25) + + EditorReducer.reduce(state: &state, action: .setViewportInteractionDownscale(true)) + #expect(state.viewportInteractionDownscaleEnabled) + } + + @Test("Render scale settings survive a state codable round trip") + func renderScaleSettingsRoundTrip() throws { + var state = EditorState() + EditorReducer.reduce(state: &state, action: .setViewportRenderScalePercent(50)) + EditorReducer.reduce(state: &state, action: .setViewportInteractionDownscale(true)) + + let data = try JSONEncoder().encode(state) + let decoded = try JSONDecoder().decode(EditorState.self, from: data) + + #expect(decoded.viewportRenderScalePercent == 50) + #expect(decoded.viewportInteractionDownscaleEnabled) + } + + @Test("Camera and gizmo drags count as continuous scene interaction, clicks do not") + func continuousInteractionClassification() { + let controller = EditorViewportInputController.shared + defer { controller.reset() } + + controller.begin(.camera(.orbit, button: .left), at: (0, 0), modifiers: []) + #expect(controller.isContinuousSceneInteractionActive) + + controller.begin(.gizmo(button: .left), at: (0, 0), modifiers: []) + #expect(controller.isContinuousSceneInteractionActive) + + controller.begin(.pendingClick(button: .left), at: (0, 0), modifiers: []) + #expect(!controller.isContinuousSceneInteractionActive) + + controller.begin(.marquee(button: .left), at: (0, 0), modifiers: []) + #expect(!controller.isContinuousSceneInteractionActive) + + controller.endPointerSession() + #expect(!controller.isContinuousSceneInteractionActive) + } +} diff --git a/Engine/Sources/RenderBackend/Core/RenderBackend.swift b/Engine/Sources/RenderBackend/Core/RenderBackend.swift index 880201ea..6a9f11d7 100644 --- a/Engine/Sources/RenderBackend/Core/RenderBackend.swift +++ b/Engine/Sources/RenderBackend/Core/RenderBackend.swift @@ -11,7 +11,12 @@ public final class WGPURenderer: RenderPacketConsumer, @unchecked Sendable { let backend: WGPUBackend private let renderSurface: RenderSurfaceDescriptor? var surface: GPUSurface? + /// Rendered (used) extent of the current frame. private var configuredSize: RenderDrawableSize = .init(width: 0, height: 0) + /// Backing extent of frame-graph targets. Offscreen path: grow-only and + /// quantized so panel resizes don't recreate textures every frame. + /// Surface path: equal to the swapchain size. + private var allocatedTargetSize: RenderDrawableSize = .init(width: 0, height: 0) let format: GPUTextureFormat = .bgra8Unorm let hdrFormat: GPUTextureFormat = .rgba16Float let depthFormat: GPUTextureFormat = .depth32Float @@ -81,6 +86,7 @@ public final class WGPURenderer: RenderPacketConsumer, @unchecked Sendable { private var ssrUniformBuffer: GPUBuffer? private var taaUniformBuffer: GPUBuffer? private var ssaoUniformBuffer: GPUBuffer? + private var postFrameUniformBuffer: GPUBuffer? var stylizedCharacterUniformBuffer: GPUBuffer? var fallbackJointPaletteBuffer: GPUBuffer? var jointPaletteBuffers: [EntityID: GPUBuffer] = [:] @@ -156,7 +162,8 @@ public final class WGPURenderer: RenderPacketConsumer, @unchecked Sendable { } if usesHDRFrameGraph { - try ensureFrameGraphResources(size: packet.drawableSize) + try ensureFrameGraphResources(size: allocatedTargetSize, + needsTAAHistory: framePlan.passes.contains(.taa)) } let cameraMatrices = RenderCameraMatrices.make( @@ -218,6 +225,7 @@ public final class WGPURenderer: RenderPacketConsumer, @unchecked Sendable { _ = try ensureOutlinePipeline(hdr: usesHDRFrameGraph) } try ensureFullscreenResources() + writePostFrameUniforms() let prepareDoneNS = DispatchTime.now().uptimeNanoseconds @@ -405,8 +413,8 @@ public final class WGPURenderer: RenderPacketConsumer, @unchecked Sendable { encoder.copyTextureToTexture( source: input.texture, destination: historyTarget.texture, - width: packet.drawableSize.width, - height: packet.drawableSize.height + width: configuredSize.width, + height: configuredSize.height ) historyValid = true } else { @@ -426,7 +434,9 @@ public final class WGPURenderer: RenderPacketConsumer, @unchecked Sendable { passDrawCallCount = 1 case .viewportResolve: - registerViewportSurface(texture: colorTarget.texture, size: packet.drawableSize) + registerViewportSurface(texture: colorTarget.texture, + size: configuredSize, + textureSize: allocatedTargetSize) viewportResolved = true } @@ -467,8 +477,8 @@ public final class WGPURenderer: RenderPacketConsumer, @unchecked Sendable { commandEncoder: encoder, colorView: colorTarget.view, formatHint: formatHint, - width: Int(packet.drawableSize.width), - height: Int(packet.drawableSize.height), + width: Int(configuredSize.width), + height: Int(configuredSize.height), deltaTime: packet.deltaTime ) } @@ -996,62 +1006,79 @@ public final class WGPURenderer: RenderPacketConsumer, @unchecked Sendable { private func ensureConfigured(size: RenderDrawableSize) throws { guard backend.rawDevice != nil else { return } - let width = max(size.width, 1) - let height = max(size.height, 1) - if width == configuredSize.width && height == configuredSize.height - && configuredSize.width > 0 - { - return - } - if let surface, let device = backend.rawDevice { - try surface.configure( - device: device, - format: format, - width: width, - height: height, - presentMode: .fifo - ) - offscreenColorView = nil - offscreenColorTexture = nil - } else { - let color = try backend.createTexture( - width: width, - height: height, - format: format, - usage: [.renderAttachment, .textureBinding, .copySrc] + let used = ViewportTargetAllocation.clampedUsed(size) + // Swapchains can't over-allocate, so the surface path stays exact and + // reallocates on resize as before; only the offscreen path grows. + let capacity = surface == nil + ? ViewportTargetAllocation.grownCapacity(current: allocatedTargetSize, used: used) + : used + let allocationChanged = capacity != allocatedTargetSize + let usedChanged = used != configuredSize + guard allocationChanged || usedChanged else { return } + + if allocationChanged { + if let surface, let device = backend.rawDevice { + try surface.configure( + device: device, + format: format, + width: capacity.width, + height: capacity.height, + presentMode: .fifo + ) + offscreenColorView = nil + offscreenColorTexture = nil + } else { + let color = try backend.createTexture( + width: capacity.width, + height: capacity.height, + format: format, + usage: [.renderAttachment, .textureBinding, .copySrc] + ) + offscreenColorView = try color.createView() + offscreenColorTexture = color + } + allocatedTargetSize = capacity + sceneColorTarget = nil + postProcessTargetA = nil + postProcessTargetB = nil + ldrPostProcessTarget = nil + historyTarget = nil + + depthView = nil + depthTexture = nil + let depth = try backend.createTexture( + width: capacity.width, + height: capacity.height, + format: depthFormat, + usage: [.renderAttachment, .textureBinding] ) - offscreenColorView = try color.createView() - offscreenColorTexture = color + depthView = try depth.createView() + depthTexture = depth } - configuredSize = .init(width: width, height: height) - sceneColorTarget = nil - postProcessTargetA = nil - postProcessTargetB = nil - ldrPostProcessTarget = nil - historyTarget = nil - historyValid = false - depthView = nil - depthTexture = nil - let depth = try backend.createTexture( - width: width, - height: height, - format: depthFormat, - usage: [.renderAttachment, .textureBinding] - ) - depthView = try depth.createView() - depthTexture = depth + configuredSize = used + // The history texture's used sub-region no longer lines up after any + // extent change, so TAA must rebuild from the next frame. + historyValid = false } - private func ensureFrameGraphResources(size: RenderDrawableSize) throws { - guard sceneColorTarget == nil || postProcessTargetA == nil || postProcessTargetB == nil || historyTarget == nil else { - return + private func ensureFrameGraphResources(size: RenderDrawableSize, needsTAAHistory: Bool) throws { + if sceneColorTarget == nil { + sceneColorTarget = try makeRenderTarget(width: size.width, height: size.height, format: hdrFormat) + } + if postProcessTargetA == nil { + postProcessTargetA = try makeRenderTarget(width: size.width, height: size.height, format: hdrFormat) + } + if postProcessTargetB == nil { + postProcessTargetB = try makeRenderTarget(width: size.width, height: size.height, format: hdrFormat) + } + if ldrPostProcessTarget == nil { + ldrPostProcessTarget = try makeRenderTarget(width: size.width, height: size.height, format: format) + } + if needsTAAHistory, historyTarget == nil { + historyTarget = try makeRenderTarget(width: size.width, height: size.height, format: hdrFormat) + historyValid = false } - sceneColorTarget = try makeRenderTarget(width: size.width, height: size.height, format: hdrFormat) - postProcessTargetA = try makeRenderTarget(width: size.width, height: size.height, format: hdrFormat) - postProcessTargetB = try makeRenderTarget(width: size.width, height: size.height, format: hdrFormat) - ldrPostProcessTarget = try makeRenderTarget(width: size.width, height: size.height, format: format) - historyTarget = try makeRenderTarget(width: size.width, height: size.height, format: hdrFormat) } private func ensureFullscreenResources() throws { @@ -1095,6 +1122,36 @@ public final class WGPURenderer: RenderPacketConsumer, @unchecked Sendable { if ssaoUniformBuffer == nil { ssaoUniformBuffer = try backend.createBuffer(size: 512, usage: [.uniform, .copyDst]) } + if postFrameUniformBuffer == nil { + postFrameUniformBuffer = try backend.createBuffer(size: 256, usage: [.uniform, .copyDst]) + } + } + + /// Restrict rasterization to the used sub-region of the (potentially + /// larger) frame-graph targets. Call right after `beginRenderPass` on + /// every frame-sized pass. + private func applyUsedRegion(_ pass: GPURenderPassEncoder) { + pass.setViewport(x: 0, y: 0, + width: Float(configuredSize.width), + height: Float(configuredSize.height)) + pass.setScissorRect(x: 0, y: 0, + width: configuredSize.width, + height: configuredSize.height) + } + + private func writePostFrameUniforms() { + guard let postFrameUniformBuffer else { return } + let allocW = Float(max(allocatedTargetSize.width, 1)) + let allocH = Float(max(allocatedTargetSize.height, 1)) + let usedW = Float(configuredSize.width) + let usedH = Float(configuredSize.height) + var uniforms = PostFrameUniforms(uvScaleMax: SIMD4( + usedW / allocW, + usedH / allocH, + max(usedW - 0.5, 0.5) / allocW, + max(usedH - 0.5, 0.5) / allocH + )) + writeUniform(&uniforms, buffer: postFrameUniformBuffer) } private func encodeSkyboxPass( @@ -1128,6 +1185,7 @@ public final class WGPURenderer: RenderPacketConsumer, @unchecked Sendable { depthStoreOp: .store, depthClearValue: 1.0 ) + applyUsedRegion(pass) pass.setPipeline(pipeline) pass.setBindGroup(bindGroup, index: 0) pass.draw(vertexCount: 3) @@ -1152,6 +1210,7 @@ public final class WGPURenderer: RenderPacketConsumer, @unchecked Sendable { depthStoreOp: .store, depthClearValue: 1.0 ) + applyUsedRegion(pass) pass.setPipeline(pipeline) let drawCallCount = encodeInstanceDraws( pass: pass, @@ -1363,6 +1422,7 @@ public final class WGPURenderer: RenderPacketConsumer, @unchecked Sendable { depthClearValue: 1.0 ) + applyUsedRegion(pass) pass.setPipeline(pipeline) var drawCallCount = 0 if let dyn = dynamicInstanceResources { @@ -1435,6 +1495,7 @@ public final class WGPURenderer: RenderPacketConsumer, @unchecked Sendable { depthStoreOp: .store, depthClearValue: 1.0 ) + applyUsedRegion(pass) pass.setPipeline(pipeline) var drawCallCount = 0 if let dyn = dynamicInstanceResources { @@ -1639,6 +1700,7 @@ public final class WGPURenderer: RenderPacketConsumer, @unchecked Sendable { depthStoreOp: .store, depthClearValue: 1.0 ) + applyUsedRegion(pass) pass.executeBundles(compactBundles) pass.end() @@ -1676,9 +1738,13 @@ public final class WGPURenderer: RenderPacketConsumer, @unchecked Sendable { output: RenderTextureTarget, pipeline: GPURenderPipeline ) throws { - guard let linearSampler, let bloomUniformBuffer else { return } + guard let linearSampler, let bloomUniformBuffer, let postFrameUniformBuffer else { return } + // Texel offsets step in texture space, so they derive from the + // allocated extent, not the used sub-region. var uniforms = BloomUniforms( - params: SIMD4(1.05, 0.75, 1.0 / Float(configuredSize.width), 1.0 / Float(configuredSize.height)) + params: SIMD4(1.05, 0.75, + 1.0 / Float(max(allocatedTargetSize.width, 1)), + 1.0 / Float(max(allocatedTargetSize.height, 1))) ) writeUniform(&uniforms, buffer: bloomUniformBuffer) let bindGroup = try makeBindGroup( @@ -1687,9 +1753,11 @@ public final class WGPURenderer: RenderPacketConsumer, @unchecked Sendable { GPUBindGroupEntry(binding: 0, sampler: linearSampler), GPUBindGroupEntry(binding: 1, textureView: input.view), GPUBindGroupEntry(binding: 2, buffer: bloomUniformBuffer, offset: 0, size: UInt64(MemoryLayout.stride)), + GPUBindGroupEntry(binding: 3, buffer: postFrameUniformBuffer, offset: 0, size: UInt64(MemoryLayout.stride)), ] ) let pass = try encoder.beginRenderPass(colorView: output.view, loadOp: .clear, storeOp: .store, clearColor: .clear) + applyUsedRegion(pass) pass.setPipeline(pipeline) pass.setBindGroup(bindGroup, index: 0) pass.draw(vertexCount: 3) @@ -1702,16 +1770,18 @@ public final class WGPURenderer: RenderPacketConsumer, @unchecked Sendable { output: RenderTextureTarget, pipeline: GPURenderPipeline ) throws { - guard let linearSampler, let stylizedCharacterUniformBuffer else { return } + guard let linearSampler, let stylizedCharacterUniformBuffer, let postFrameUniformBuffer else { return } let bindGroup = try makeBindGroup( pipeline: pipeline, entries: [ GPUBindGroupEntry(binding: 0, sampler: linearSampler), GPUBindGroupEntry(binding: 1, textureView: input.view), GPUBindGroupEntry(binding: 2, buffer: stylizedCharacterUniformBuffer, offset: 0, size: UInt64(MemoryLayout.stride)), + GPUBindGroupEntry(binding: 3, buffer: postFrameUniformBuffer, offset: 0, size: UInt64(MemoryLayout.stride)), ] ) let pass = try encoder.beginRenderPass(colorView: output.view, loadOp: .clear, storeOp: .store, clearColor: .clear) + applyUsedRegion(pass) pass.setPipeline(pipeline) pass.setBindGroup(bindGroup, index: 0) pass.draw(vertexCount: 3) @@ -1726,7 +1796,7 @@ public final class WGPURenderer: RenderPacketConsumer, @unchecked Sendable { pipeline: GPURenderPipeline, projection: simd_float4x4 ) throws { - guard let linearSampler, let ssrUniformBuffer else { return } + guard let linearSampler, let ssrUniformBuffer, let postFrameUniformBuffer else { return } var uniforms = SSRUniforms( projection: projection, invProjection: simd_inverse(projection), @@ -1742,9 +1812,11 @@ public final class WGPURenderer: RenderPacketConsumer, @unchecked Sendable { GPUBindGroupEntry(binding: 1, textureView: input.view), GPUBindGroupEntry(binding: 2, textureView: depthView), GPUBindGroupEntry(binding: 3, buffer: ssrUniformBuffer, offset: 0, size: UInt64(MemoryLayout.stride)), + GPUBindGroupEntry(binding: 4, buffer: postFrameUniformBuffer, offset: 0, size: UInt64(MemoryLayout.stride)), ] ) let pass = try encoder.beginRenderPass(colorView: output.view, loadOp: .clear, storeOp: .store, clearColor: .clear) + applyUsedRegion(pass) pass.setPipeline(pipeline) pass.setBindGroup(bindGroup, index: 0) pass.draw(vertexCount: 3) @@ -1758,9 +1830,12 @@ public final class WGPURenderer: RenderPacketConsumer, @unchecked Sendable { output: RenderTextureTarget, pipeline: GPURenderPipeline ) throws { - guard let linearSampler, let taaUniformBuffer else { return } + guard let linearSampler, let taaUniformBuffer, let postFrameUniformBuffer else { return } var uniforms = TAAUniforms( - params: SIMD4(0.12, 1.0 / Float(configuredSize.width), 1.0 / Float(configuredSize.height), historyValid ? 1.0 : 0.0) + params: SIMD4(0.12, + 1.0 / Float(max(allocatedTargetSize.width, 1)), + 1.0 / Float(max(allocatedTargetSize.height, 1)), + historyValid ? 1.0 : 0.0) ) writeUniform(&uniforms, buffer: taaUniformBuffer) let bindGroup = try makeBindGroup( @@ -1770,9 +1845,11 @@ public final class WGPURenderer: RenderPacketConsumer, @unchecked Sendable { GPUBindGroupEntry(binding: 1, textureView: input.view), GPUBindGroupEntry(binding: 2, textureView: history.view), GPUBindGroupEntry(binding: 3, buffer: taaUniformBuffer, offset: 0, size: UInt64(MemoryLayout.stride)), + GPUBindGroupEntry(binding: 4, buffer: postFrameUniformBuffer, offset: 0, size: UInt64(MemoryLayout.stride)), ] ) let pass = try encoder.beginRenderPass(colorView: output.view, loadOp: .clear, storeOp: .store, clearColor: .clear) + applyUsedRegion(pass) pass.setPipeline(pipeline) pass.setBindGroup(bindGroup, index: 0) pass.draw(vertexCount: 3) @@ -1787,7 +1864,7 @@ public final class WGPURenderer: RenderPacketConsumer, @unchecked Sendable { pipeline: GPURenderPipeline, projection: simd_float4x4 ) throws { - guard let linearSampler, let ssaoUniformBuffer else { return } + guard let linearSampler, let ssaoUniformBuffer, let postFrameUniformBuffer else { return } var uniforms = SSAOUniforms( projection: projection, invProjection: simd_inverse(projection), @@ -1803,9 +1880,11 @@ public final class WGPURenderer: RenderPacketConsumer, @unchecked Sendable { GPUBindGroupEntry(binding: 1, textureView: input.view), GPUBindGroupEntry(binding: 2, textureView: depthView), GPUBindGroupEntry(binding: 3, buffer: ssaoUniformBuffer, offset: 0, size: UInt64(MemoryLayout.stride)), + GPUBindGroupEntry(binding: 4, buffer: postFrameUniformBuffer, offset: 0, size: UInt64(MemoryLayout.stride)), ] ) let pass = try encoder.beginRenderPass(colorView: output.view, loadOp: .clear, storeOp: .store, clearColor: .clear) + applyUsedRegion(pass) pass.setPipeline(pipeline) pass.setBindGroup(bindGroup, index: 0) pass.draw(vertexCount: 3) @@ -1819,7 +1898,7 @@ public final class WGPURenderer: RenderPacketConsumer, @unchecked Sendable { outputView: GPUTextureView, pipeline: GPURenderPipeline ) throws { - guard let linearSampler, let tonemapUniformBuffer else { return } + guard let linearSampler, let tonemapUniformBuffer, let postFrameUniformBuffer else { return } var uniforms = TonemapUniforms( params: SIMD4(1.0, 0.85, activeRenderSettings.enableBloom ? 1.0 : 0.0, 1.0) ) @@ -1831,9 +1910,11 @@ public final class WGPURenderer: RenderPacketConsumer, @unchecked Sendable { GPUBindGroupEntry(binding: 1, textureView: input.view), GPUBindGroupEntry(binding: 2, textureView: bloom.view), GPUBindGroupEntry(binding: 3, buffer: tonemapUniformBuffer, offset: 0, size: UInt64(MemoryLayout.stride)), + GPUBindGroupEntry(binding: 4, buffer: postFrameUniformBuffer, offset: 0, size: UInt64(MemoryLayout.stride)), ] ) let pass = try encoder.beginRenderPass(colorView: outputView, loadOp: .clear, storeOp: .store, clearColor: .black) + applyUsedRegion(pass) pass.setPipeline(pipeline) pass.setBindGroup(bindGroup, index: 0) pass.draw(vertexCount: 3) @@ -1846,15 +1927,17 @@ public final class WGPURenderer: RenderPacketConsumer, @unchecked Sendable { output: FrameColorTarget, pipeline: GPURenderPipeline ) throws { - guard let linearSampler else { return } + guard let linearSampler, let postFrameUniformBuffer else { return } let bindGroup = try makeBindGroup( pipeline: pipeline, entries: [ GPUBindGroupEntry(binding: 0, sampler: linearSampler), GPUBindGroupEntry(binding: 1, textureView: input.view), + GPUBindGroupEntry(binding: 2, buffer: postFrameUniformBuffer, offset: 0, size: UInt64(MemoryLayout.stride)), ] ) let pass = try encoder.beginRenderPass(colorView: output.view, loadOp: .clear, storeOp: .store, clearColor: .black) + applyUsedRegion(pass) pass.setPipeline(pipeline) pass.setBindGroup(bindGroup, index: 0) pass.draw(vertexCount: 3) diff --git a/Engine/Sources/RenderBackend/Core/RenderUniforms.swift b/Engine/Sources/RenderBackend/Core/RenderUniforms.swift index 30cb7ff8..5f42b88c 100644 --- a/Engine/Sources/RenderBackend/Core/RenderUniforms.swift +++ b/Engine/Sources/RenderBackend/Core/RenderUniforms.swift @@ -39,3 +39,12 @@ struct StylizedCharacterUniforms { var inkWashColor: SIMD4 var params: SIMD4 } + +/// Shared per-frame uniforms for fullscreen post passes. Targets are +/// allocated grow-only, so the rendered image occupies the `uv_scale` +/// sub-region of each texture; `uv_max` is the half-texel-inset clamp bound +/// neighbor taps must not exceed. +struct PostFrameUniforms { + /// xy = used / allocated, zw = (used - 0.5) / allocated. + var uvScaleMax: SIMD4 +} diff --git a/Engine/Sources/RenderBackend/Core/ViewportTargetAllocation.swift b/Engine/Sources/RenderBackend/Core/ViewportTargetAllocation.swift new file mode 100644 index 00000000..9ff2a01c --- /dev/null +++ b/Engine/Sources/RenderBackend/Core/ViewportTargetAllocation.swift @@ -0,0 +1,36 @@ +/// Grow-only allocation policy for offscreen frame-graph targets. +/// +/// Targets are allocated at a quantized capacity and the frame renders into +/// the top-left `used` sub-region, so dragging a panel splitter never +/// recreates textures mid-resize. Capacity only grows; shrinking requires an +/// explicit reset (renderer teardown). +public enum ViewportTargetAllocation { + public static let granularity: UInt32 = 256 + public static let maxDimension: UInt32 = 16_384 + + public static func quantized(_ value: UInt32) -> UInt32 { + let clamped = min(max(value, 1), maxDimension) + let rounded = (clamped + granularity - 1) / granularity * granularity + return min(rounded, maxDimension) + } + + /// Capacity that fits `used`, reusing `current` when it is already large + /// enough in both dimensions. + public static func grownCapacity(current: RenderDrawableSize, + used: RenderDrawableSize) -> RenderDrawableSize { + if current.width >= used.width, current.height >= used.height, + current.width > 0, current.height > 0 { + return current + } + return RenderDrawableSize( + width: max(current.width, quantized(used.width)), + height: max(current.height, quantized(used.height)) + ) + } + + /// Rendered extent clamped to hardware-safe bounds. + public static func clampedUsed(_ size: RenderDrawableSize) -> RenderDrawableSize { + RenderDrawableSize(width: min(max(size.width, 1), maxDimension), + height: min(max(size.height, 1), maxDimension)) + } +} diff --git a/Engine/Sources/RenderBackend/Core/WGPURenderer+FrameState.swift b/Engine/Sources/RenderBackend/Core/WGPURenderer+FrameState.swift index 6ac9cb38..741fe6f4 100644 --- a/Engine/Sources/RenderBackend/Core/WGPURenderer+FrameState.swift +++ b/Engine/Sources/RenderBackend/Core/WGPURenderer+FrameState.swift @@ -55,7 +55,7 @@ extension WGPURenderer { frameIndex == 0 || frameIndex % 120 == 0 } - func registerViewportSurface(texture: GPUTexture, size: RenderDrawableSize) { + func registerViewportSurface(texture: GPUTexture, size: RenderDrawableSize, textureSize: RenderDrawableSize) { // Keep old texture retainers briefly because UI snapshots can outlive // the frame that published them. if let publishedTextureRetainer, @@ -65,6 +65,8 @@ extension WGPURenderer { handle: publishedSurfaceHandle, width: size.width, height: size.height, + textureWidth: textureSize.width, + textureHeight: textureSize.height, zeroCopy: true ) return @@ -87,6 +89,8 @@ extension WGPURenderer { handle: publishedSurfaceHandle, width: size.width, height: size.height, + textureWidth: textureSize.width, + textureHeight: textureSize.height, zeroCopy: true ) } diff --git a/Engine/Sources/RenderBackend/Resources/Shaders/WGSL/bloom.wgsl b/Engine/Sources/RenderBackend/Resources/Shaders/WGSL/bloom.wgsl index 1969a0d0..b22e6270 100644 --- a/Engine/Sources/RenderBackend/Resources/Shaders/WGSL/bloom.wgsl +++ b/Engine/Sources/RenderBackend/Resources/Shaders/WGSL/bloom.wgsl @@ -5,9 +5,15 @@ struct BloomUniforms { texel_size_y : f32, }; +struct PostFrame { + uv_scale : vec2, + uv_max : vec2, +}; + @group(0) @binding(0) var bloom_sampler : sampler; @group(0) @binding(1) var hdr_texture : texture_2d; @group(0) @binding(2) var u : BloomUniforms; +@group(0) @binding(3) var frame_u : PostFrame; struct VsOut { @builtin(position) position : vec4, @@ -49,8 +55,9 @@ fn fs_main(in : VsOut) -> @location(0) vec4 { var bloom = vec3(0.0); var total = 0.0; + let base_uv = in.uv * frame_u.uv_scale; for (var i : u32 = 0u; i < 9u; i += 1u) { - let sample_uv = in.uv + offsets[i] * texel * 2.0; + let sample_uv = clamp(base_uv + offsets[i] * texel * 2.0, vec2(0.0), frame_u.uv_max); let sample_color = textureSample(hdr_texture, bloom_sampler, sample_uv).rgb; let bright = max(luminance(sample_color) - u.threshold, 0.0); bloom += sample_color * bright * weights[i]; diff --git a/Engine/Sources/RenderBackend/Resources/Shaders/WGSL/fxaa.wgsl b/Engine/Sources/RenderBackend/Resources/Shaders/WGSL/fxaa.wgsl index 1fbdb489..56ca5921 100644 --- a/Engine/Sources/RenderBackend/Resources/Shaders/WGSL/fxaa.wgsl +++ b/Engine/Sources/RenderBackend/Resources/Shaders/WGSL/fxaa.wgsl @@ -1,5 +1,11 @@ +struct PostFrame { + uv_scale : vec2, + uv_max : vec2, +}; + @group(0) @binding(0) var fxaa_sampler : sampler; @group(0) @binding(1) var color_texture : texture_2d; +@group(0) @binding(2) var frame_u : PostFrame; struct VsOut { @builtin(position) position : vec4, @@ -10,6 +16,10 @@ fn luminance(color : vec3) -> f32 { return dot(color, vec3(0.299, 0.587, 0.114)); } +fn sample_color(uv : vec2) -> vec3 { + return textureSample(color_texture, fxaa_sampler, clamp(uv, vec2(0.0), frame_u.uv_max)).rgb; +} + @vertex fn vs_main(@builtin(vertex_index) vertex_index : u32) -> VsOut { var positions = array, 3>( @@ -32,12 +42,13 @@ fn vs_main(@builtin(vertex_index) vertex_index : u32) -> VsOut { @fragment fn fs_main(in : VsOut) -> @location(0) vec4 { let texel = 1.0 / vec2(vec2(textureDimensions(color_texture))); + let base_uv = in.uv * frame_u.uv_scale; - let rgb_m = textureSample(color_texture, fxaa_sampler, in.uv).rgb; - let rgb_n = textureSample(color_texture, fxaa_sampler, in.uv + vec2(0.0, -texel.y)).rgb; - let rgb_s = textureSample(color_texture, fxaa_sampler, in.uv + vec2(0.0, texel.y)).rgb; - let rgb_w = textureSample(color_texture, fxaa_sampler, in.uv + vec2(-texel.x, 0.0)).rgb; - let rgb_e = textureSample(color_texture, fxaa_sampler, in.uv + vec2(texel.x, 0.0)).rgb; + let rgb_m = sample_color(base_uv); + let rgb_n = sample_color(base_uv + vec2(0.0, -texel.y)); + let rgb_s = sample_color(base_uv + vec2(0.0, texel.y)); + let rgb_w = sample_color(base_uv + vec2(-texel.x, 0.0)); + let rgb_e = sample_color(base_uv + vec2(texel.x, 0.0)); let luma_m = luminance(rgb_m); let luma_n = luminance(rgb_n); @@ -62,12 +73,12 @@ fn fs_main(in : VsOut) -> @location(0) vec4 { dir = clamp(dir * rcp_dir_min, vec2(-8.0), vec2(8.0)) * texel; let rgb_a = 0.5 * ( - textureSample(color_texture, fxaa_sampler, in.uv + dir * (1.0 / 3.0 - 0.5)).rgb + - textureSample(color_texture, fxaa_sampler, in.uv + dir * (2.0 / 3.0 - 0.5)).rgb + sample_color(base_uv + dir * (1.0 / 3.0 - 0.5)) + + sample_color(base_uv + dir * (2.0 / 3.0 - 0.5)) ); let rgb_b = rgb_a * 0.5 + 0.25 * ( - textureSample(color_texture, fxaa_sampler, in.uv - dir * 0.5).rgb + - textureSample(color_texture, fxaa_sampler, in.uv + dir * 0.5).rgb + sample_color(base_uv - dir * 0.5) + + sample_color(base_uv + dir * 0.5) ); let luma_b = luminance(rgb_b); diff --git a/Engine/Sources/RenderBackend/Resources/Shaders/WGSL/ink_paper_post.wgsl b/Engine/Sources/RenderBackend/Resources/Shaders/WGSL/ink_paper_post.wgsl index ee2330a0..4bfa282f 100644 --- a/Engine/Sources/RenderBackend/Resources/Shaders/WGSL/ink_paper_post.wgsl +++ b/Engine/Sources/RenderBackend/Resources/Shaders/WGSL/ink_paper_post.wgsl @@ -5,9 +5,15 @@ struct StylizedStyle { params : vec4, }; +struct PostFrame { + uv_scale : vec2, + uv_max : vec2, +}; + @group(0) @binding(0) var post_sampler : sampler; @group(0) @binding(1) var color_texture : texture_2d; @group(0) @binding(2) var style : StylizedStyle; +@group(0) @binding(3) var frame_u : PostFrame; struct VsOut { @builtin(position) position : vec4, @@ -46,9 +52,10 @@ fn paper_hash(uv : vec2) -> f32 { @fragment fn fs_main(in : VsOut) -> @location(0) vec4 { let texel = 1.0 / vec2(vec2(textureDimensions(color_texture))); - let center = textureSample(color_texture, post_sampler, in.uv).rgb; - let north = textureSample(color_texture, post_sampler, in.uv + vec2(0.0, -texel.y)).rgb; - let east = textureSample(color_texture, post_sampler, in.uv + vec2(texel.x, 0.0)).rgb; + let base_uv = in.uv * frame_u.uv_scale; + let center = textureSample(color_texture, post_sampler, base_uv).rgb; + let north = textureSample(color_texture, post_sampler, clamp(base_uv + vec2(0.0, -texel.y), vec2(0.0), frame_u.uv_max)).rgb; + let east = textureSample(color_texture, post_sampler, clamp(base_uv + vec2(texel.x, 0.0), vec2(0.0), frame_u.uv_max)).rgb; let contrast = abs(luminance(center) - luminance(north)) + abs(luminance(center) - luminance(east)); let ink_edge = clamp(contrast * 1.8, 0.0, 0.22); diff --git a/Engine/Sources/RenderBackend/Resources/Shaders/WGSL/ssao.wgsl b/Engine/Sources/RenderBackend/Resources/Shaders/WGSL/ssao.wgsl index 32f70401..6f373436 100644 --- a/Engine/Sources/RenderBackend/Resources/Shaders/WGSL/ssao.wgsl +++ b/Engine/Sources/RenderBackend/Resources/Shaders/WGSL/ssao.wgsl @@ -5,16 +5,29 @@ struct SSAOUniforms { tuning : vec4, }; +struct PostFrame { + uv_scale : vec2, + uv_max : vec2, +}; + @group(0) @binding(0) var ssao_sampler : sampler; @group(0) @binding(1) var scene_texture : texture_2d; @group(0) @binding(2) var depth_texture : texture_depth_2d; @group(0) @binding(3) var u : SSAOUniforms; +@group(0) @binding(4) var frame_u : PostFrame; struct VsOut { @builtin(position) position : vec4, @location(0) uv : vec2, }; +// `uv` arguments below stay in logical viewport space (0..1 across the +// rendered region); `tex_uv` maps them into the used sub-region of the +// grow-only allocated textures for sampling. +fn tex_uv(uv : vec2) -> vec2 { + return min(uv * frame_u.uv_scale, frame_u.uv_max); +} + fn get_view_pos(uv : vec2, depth : f32) -> vec3 { let clip = vec4(uv * 2.0 - 1.0, depth, 1.0); let view = u.inv_projection * clip; @@ -25,8 +38,8 @@ fn reconstruct_normal(uv : vec2, view_pos : vec3) -> vec3 { let texel = 1.0 / u.resolution_radius.xy; let uv_x = clamp(uv + vec2(texel.x, 0.0), vec2(0.0), vec2(1.0)); let uv_y = clamp(uv + vec2(0.0, texel.y), vec2(0.0), vec2(1.0)); - let view_x = get_view_pos(uv_x, textureSample(depth_texture, ssao_sampler, uv_x)); - let view_y = get_view_pos(uv_y, textureSample(depth_texture, ssao_sampler, uv_y)); + let view_x = get_view_pos(uv_x, textureSample(depth_texture, ssao_sampler, tex_uv(uv_x))); + let view_y = get_view_pos(uv_y, textureSample(depth_texture, ssao_sampler, tex_uv(uv_y))); return normalize(cross(view_x - view_pos, view_y - view_pos)); } @@ -65,15 +78,14 @@ fn vs_main(@builtin(vertex_index) vertex_index : u32) -> VsOut { @fragment fn fs_main(in : VsOut) -> @location(0) vec4 { - let depth = textureSample(depth_texture, ssao_sampler, in.uv); - let scene = textureSample(scene_texture, ssao_sampler, in.uv).rgb; + let depth = textureSample(depth_texture, ssao_sampler, tex_uv(in.uv)); + let scene = textureSample(scene_texture, ssao_sampler, tex_uv(in.uv)).rgb; if (depth >= 0.9999) { return vec4(scene, 1.0); } let view_pos = get_view_pos(in.uv, depth); let normal = reconstruct_normal(in.uv, view_pos); - let texel = 1.0 / u.resolution; var occlusion = 0.0; for (var i : u32 = 0u; i < 8u; i += 1u) { @@ -86,7 +98,7 @@ fn fs_main(in : VsOut) -> @location(0) vec4 { continue; } - let sample_depth = textureSample(depth_texture, ssao_sampler, sample_uv); + let sample_depth = textureSample(depth_texture, ssao_sampler, tex_uv(sample_uv)); let sample_view = get_view_pos(sample_uv, sample_depth); let range_check = smoothstep(0.0, 1.0, u.resolution_radius.z / (abs(view_pos.z - sample_view.z) + 0.001)); if (sample_view.z >= sample_pos.z + u.tuning.x) { diff --git a/Engine/Sources/RenderBackend/Resources/Shaders/WGSL/ssr.wgsl b/Engine/Sources/RenderBackend/Resources/Shaders/WGSL/ssr.wgsl index 6c44a122..f16dfe4c 100644 --- a/Engine/Sources/RenderBackend/Resources/Shaders/WGSL/ssr.wgsl +++ b/Engine/Sources/RenderBackend/Resources/Shaders/WGSL/ssr.wgsl @@ -5,16 +5,29 @@ struct SSRUniforms { tracing : vec4, }; +struct PostFrame { + uv_scale : vec2, + uv_max : vec2, +}; + @group(0) @binding(0) var ssr_sampler : sampler; @group(0) @binding(1) var scene_texture : texture_2d; @group(0) @binding(2) var depth_texture : texture_depth_2d; @group(0) @binding(3) var u : SSRUniforms; +@group(0) @binding(4) var frame_u : PostFrame; struct VsOut { @builtin(position) position : vec4, @location(0) uv : vec2, }; +// `uv` arguments below stay in logical viewport space (0..1 across the +// rendered region); `tex_uv` maps them into the used sub-region of the +// grow-only allocated textures for sampling. +fn tex_uv(uv : vec2) -> vec2 { + return min(uv * frame_u.uv_scale, frame_u.uv_max); +} + fn get_view_pos(uv : vec2, depth : f32) -> vec3 { let clip = vec4(uv * 2.0 - 1.0, depth, 1.0); let view = u.inv_projection * clip; @@ -22,11 +35,11 @@ fn get_view_pos(uv : vec2, depth : f32) -> vec3 { } fn reconstruct_normal(uv : vec2, view_pos : vec3) -> vec3 { - let texel = 1.0 / u.resolution; + let texel = 1.0 / u.resolution_intensity.xy; let uv_x = clamp(uv + vec2(texel.x, 0.0), vec2(0.0), vec2(1.0)); let uv_y = clamp(uv + vec2(0.0, texel.y), vec2(0.0), vec2(1.0)); - let view_x = get_view_pos(uv_x, textureSample(depth_texture, ssr_sampler, uv_x)); - let view_y = get_view_pos(uv_y, textureSample(depth_texture, ssr_sampler, uv_y)); + let view_x = get_view_pos(uv_x, textureSample(depth_texture, ssr_sampler, tex_uv(uv_x))); + let view_y = get_view_pos(uv_y, textureSample(depth_texture, ssr_sampler, tex_uv(uv_y))); return normalize(cross(view_x - view_pos, view_y - view_pos)); } @@ -57,9 +70,9 @@ fn vs_main(@builtin(vertex_index) vertex_index : u32) -> VsOut { @fragment fn fs_main(in : VsOut) -> @location(0) vec4 { - let depth = textureSample(depth_texture, ssr_sampler, in.uv); + let depth = textureSample(depth_texture, ssr_sampler, tex_uv(in.uv)); if (depth >= 0.9999) { - return vec4(textureSample(scene_texture, ssr_sampler, in.uv).rgb, 1.0); + return vec4(textureSample(scene_texture, ssr_sampler, tex_uv(in.uv)).rgb, 1.0); } let view_pos = get_view_pos(in.uv, depth); @@ -67,7 +80,7 @@ fn fs_main(in : VsOut) -> @location(0) vec4 { let view_dir = normalize(-view_pos); let reflect_dir = reflect(-view_dir, normal); if (reflect_dir.z > 0.0) { - return vec4(textureSample(scene_texture, ssr_sampler, in.uv).rgb, 1.0); + return vec4(textureSample(scene_texture, ssr_sampler, tex_uv(in.uv)).rgb, 1.0); } let max_steps = i32(max(u.tracing.y, 8.0)); @@ -82,7 +95,7 @@ fn fs_main(in : VsOut) -> @location(0) vec4 { break; } - let sample_depth = textureSample(depth_texture, ssr_sampler, screen_uv); + let sample_depth = textureSample(depth_texture, ssr_sampler, tex_uv(screen_uv)); let surface_pos = get_view_pos(screen_uv, sample_depth); if (surface_pos.z >= sample_pos.z && surface_pos.z - sample_pos.z < u.tracing.z) { hit_uv = screen_uv; @@ -91,12 +104,12 @@ fn fs_main(in : VsOut) -> @location(0) vec4 { } } - let scene = textureSample(scene_texture, ssr_sampler, in.uv).rgb; + let scene = textureSample(scene_texture, ssr_sampler, tex_uv(in.uv)).rgb; if (!hit) { return vec4(scene, 1.0); } - let reflection = textureSample(scene_texture, ssr_sampler, hit_uv).rgb; + let reflection = textureSample(scene_texture, ssr_sampler, tex_uv(hit_uv)).rgb; let edge_fade = u.tracing.w; let fade_x = smoothstep(0.0, edge_fade, hit_uv.x) * smoothstep(0.0, edge_fade, 1.0 - hit_uv.x); let fade_y = smoothstep(0.0, edge_fade, hit_uv.y) * smoothstep(0.0, edge_fade, 1.0 - hit_uv.y); diff --git a/Engine/Sources/RenderBackend/Resources/Shaders/WGSL/taa.wgsl b/Engine/Sources/RenderBackend/Resources/Shaders/WGSL/taa.wgsl index b865c0db..b7fc46ef 100644 --- a/Engine/Sources/RenderBackend/Resources/Shaders/WGSL/taa.wgsl +++ b/Engine/Sources/RenderBackend/Resources/Shaders/WGSL/taa.wgsl @@ -5,10 +5,16 @@ struct TAAUniforms { history_valid : f32, }; +struct PostFrame { + uv_scale : vec2, + uv_max : vec2, +}; + @group(0) @binding(0) var taa_sampler : sampler; @group(0) @binding(1) var current_texture : texture_2d; @group(0) @binding(2) var history_texture : texture_2d; @group(0) @binding(3) var u : TAAUniforms; +@group(0) @binding(4) var frame_u : PostFrame; struct VsOut { @builtin(position) position : vec4, @@ -45,19 +51,20 @@ fn vs_main(@builtin(vertex_index) vertex_index : u32) -> VsOut { @fragment fn fs_main(in : VsOut) -> @location(0) vec4 { - let current = textureSample(current_texture, taa_sampler, in.uv).rgb; + let base_uv = min(in.uv * frame_u.uv_scale, frame_u.uv_max); + let current = textureSample(current_texture, taa_sampler, base_uv).rgb; if (u.history_valid < 0.5) { return vec4(current, 1.0); } - let history = textureSample(history_texture, taa_sampler, in.uv).rgb; + let history = textureSample(history_texture, taa_sampler, base_uv).rgb; let texel = vec2(u.texel_size_x, u.texel_size_y); var min_color = current; var max_color = current; for (var y : i32 = -1; y <= 1; y += 1) { for (var x : i32 = -1; x <= 1; x += 1) { - let sample_uv = clamp(in.uv + vec2(f32(x), f32(y)) * texel, vec2(0.0), vec2(1.0)); + let sample_uv = clamp(base_uv + vec2(f32(x), f32(y)) * texel, vec2(0.0), frame_u.uv_max); let sample_color = textureSample(current_texture, taa_sampler, sample_uv).rgb; min_color = min(min_color, sample_color); max_color = max(max_color, sample_color); diff --git a/Engine/Sources/RenderBackend/Resources/Shaders/WGSL/tonemap.wgsl b/Engine/Sources/RenderBackend/Resources/Shaders/WGSL/tonemap.wgsl index 161d17ba..799166b8 100644 --- a/Engine/Sources/RenderBackend/Resources/Shaders/WGSL/tonemap.wgsl +++ b/Engine/Sources/RenderBackend/Resources/Shaders/WGSL/tonemap.wgsl @@ -5,10 +5,16 @@ struct TonemapUniforms { output_srgb : f32, }; +struct PostFrame { + uv_scale : vec2, + uv_max : vec2, +}; + @group(0) @binding(0) var tone_sampler : sampler; @group(0) @binding(1) var hdr_texture : texture_2d; @group(0) @binding(2) var bloom_texture : texture_2d; @group(0) @binding(3) var u : TonemapUniforms; +@group(0) @binding(4) var frame_u : PostFrame; struct VsOut { @builtin(position) position : vec4, @@ -51,9 +57,10 @@ fn vs_main(@builtin(vertex_index) vertex_index : u32) -> VsOut { @fragment fn fs_main(in : VsOut) -> @location(0) vec4 { - var hdr = textureSample(hdr_texture, tone_sampler, in.uv).rgb * max(u.exposure, 0.001); + let tex_uv = min(in.uv * frame_u.uv_scale, frame_u.uv_max); + var hdr = textureSample(hdr_texture, tone_sampler, tex_uv).rgb * max(u.exposure, 0.001); if (u.use_bloom > 0.5) { - hdr += textureSample(bloom_texture, tone_sampler, in.uv).rgb * max(u.bloom_mix, 0.0); + hdr += textureSample(bloom_texture, tone_sampler, tex_uv).rgb * max(u.bloom_mix, 0.0); } var ldr = aces_film(hdr); diff --git a/Engine/Sources/RenderBackend/Surface/ViewportSurface.swift b/Engine/Sources/RenderBackend/Surface/ViewportSurface.swift index 66f229cc..843293ad 100644 --- a/Engine/Sources/RenderBackend/Surface/ViewportSurface.swift +++ b/Engine/Sources/RenderBackend/Surface/ViewportSurface.swift @@ -9,8 +9,15 @@ public struct ViewportSurfaceState: Sendable, Equatable { /// long as `surfaceID` is the published one. Consumers reconstruct the /// texture via `Unmanaged.fromOpaque(...)`. public var handle: UInt64 + /// Rendered (used) extent in pixels. May be smaller than the backing + /// texture: targets are allocated grow-only so panel resizes don't + /// recreate the frame graph every frame. public var width: UInt32 public var height: UInt32 + /// Backing texture extent in pixels. Consumers sampling the surface must + /// crop to `width / textureWidth` × `height / textureHeight`. + public var textureWidth: UInt32 + public var textureHeight: UInt32 public var zeroCopy: Bool public init( @@ -18,12 +25,16 @@ public struct ViewportSurfaceState: Sendable, Equatable { handle: UInt64 = 0, width: UInt32 = 0, height: UInt32 = 0, + textureWidth: UInt32 = 0, + textureHeight: UInt32 = 0, zeroCopy: Bool = false ) { self.surfaceID = surfaceID self.handle = handle self.width = width self.height = height + self.textureWidth = textureWidth == 0 ? width : textureWidth + self.textureHeight = textureHeight == 0 ? height : textureHeight self.zeroCopy = zeroCopy } diff --git a/Engine/Tests/EngineCoreTests/RenderBackend/ViewportTargetAllocationTests.swift b/Engine/Tests/EngineCoreTests/RenderBackend/ViewportTargetAllocationTests.swift new file mode 100644 index 00000000..89c241e9 --- /dev/null +++ b/Engine/Tests/EngineCoreTests/RenderBackend/ViewportTargetAllocationTests.swift @@ -0,0 +1,61 @@ +import Testing +@testable import RenderBackend + +@Suite("ViewportTargetAllocation") +struct ViewportTargetAllocationTests { + @Test("quantizes up to the allocation granularity") + func quantizesUp() { + #expect(ViewportTargetAllocation.quantized(1) == 256) + #expect(ViewportTargetAllocation.quantized(255) == 256) + #expect(ViewportTargetAllocation.quantized(256) == 256) + #expect(ViewportTargetAllocation.quantized(257) == 512) + #expect(ViewportTargetAllocation.quantized(0) == 256) + #expect(ViewportTargetAllocation.quantized(UInt32.max) == ViewportTargetAllocation.maxDimension) + } + + @Test("reuses the current capacity while it fits") + func reusesCapacity() { + let current = RenderDrawableSize(width: 1536, height: 1024) + // Shrinks and equal sizes keep the allocation. + #expect(ViewportTargetAllocation.grownCapacity( + current: current, used: RenderDrawableSize(width: 800, height: 600)) == current) + #expect(ViewportTargetAllocation.grownCapacity( + current: current, used: current) == current) + // Growth in either axis reallocates, quantized, never shrinking the other axis. + let grownW = ViewportTargetAllocation.grownCapacity( + current: current, used: RenderDrawableSize(width: 1600, height: 600)) + #expect(grownW == RenderDrawableSize(width: 1792, height: 1024)) + let grownH = ViewportTargetAllocation.grownCapacity( + current: current, used: RenderDrawableSize(width: 100, height: 1100)) + #expect(grownH == RenderDrawableSize(width: 1536, height: 1280)) + } + + @Test("initial allocation starts from the quantized used size") + func initialAllocation() { + let grown = ViewportTargetAllocation.grownCapacity( + current: RenderDrawableSize(width: 0, height: 0), + used: RenderDrawableSize(width: 1400, height: 800)) + #expect(grown == RenderDrawableSize(width: 1536, height: 1024)) + } + + @Test("clamps the used extent to hardware-safe bounds") + func clampsUsed() { + #expect(ViewportTargetAllocation.clampedUsed(RenderDrawableSize(width: 0, height: 0)) + == RenderDrawableSize(width: 1, height: 1)) + #expect(ViewportTargetAllocation.clampedUsed(RenderDrawableSize(width: UInt32.max, height: 4)) + == RenderDrawableSize(width: ViewportTargetAllocation.maxDimension, height: 4)) + } + + @Test("fullscreen post shaders sample through the PostFrame sub-region uniforms") + func postShadersCarrySubRegionUniforms() throws { + let catalog = try ShaderCatalog() + for name in ["tonemap", "fxaa", "bloom", "taa", "ssao", "ssr", "ink_paper_post"] { + let source = try catalog.loadWGSLRenderModule(named: name) + #expect(source.contains("var frame_u : PostFrame"), "\(name) missing PostFrame uniform") + #expect(source.contains("frame_u.uv_scale"), "\(name) not scaling sample UVs") + // Regression: ssao/ssr previously referenced a nonexistent + // `u.resolution` member, which fails WGSL validation. + #expect(!source.contains("u.resolution;"), "\(name) references stale u.resolution") + } + } +} diff --git a/GuavaUI/Sources/GuavaUIApp/InGameUIHost.swift b/GuavaUI/Sources/GuavaUIApp/InGameUIHost.swift index 10ac9f8c..7363a389 100644 --- a/GuavaUI/Sources/GuavaUIApp/InGameUIHost.swift +++ b/GuavaUI/Sources/GuavaUIApp/InGameUIHost.swift @@ -47,8 +47,10 @@ public final class InGameUIHost: InGameUIProviding, @unchecked Sendable { /// Advance the in-game UI one frame. Call on the **main thread** every /// frame — typically inside the `onTick` callback passed to `AppRuntime.run`. - public func tick(width: Int, height: Int) { - bridge.tick(width: width, height: height) + /// `width`/`height` are logical points; pass the window's content scale so + /// HUD text rasterizes at physical-pixel resolution. + public func tick(width: Int, height: Int, contentScale: Float = 1) { + bridge.tick(width: width, height: height, contentScale: contentScale) } // MARK: - InGameUIProviding (render thread) diff --git a/GuavaUI/Sources/GuavaUICompose/InGameViewGraphBridge.swift b/GuavaUI/Sources/GuavaUICompose/InGameViewGraphBridge.swift index ed202283..0d53b4be 100644 --- a/GuavaUI/Sources/GuavaUICompose/InGameViewGraphBridge.swift +++ b/GuavaUI/Sources/GuavaUICompose/InGameViewGraphBridge.swift @@ -51,9 +51,9 @@ public final class InGameViewGraphBridge { /// /// Recomposes dirty scopes, runs Yoga layout, renders the node tree into a /// `DrawList`, snapshots the result, and publishes it to the render thread. - public func tick(width: Int, height: Int) { + public func tick(width: Int, height: Int, contentScale: Float = 1) { guard width > 0, height > 0, didInstallRoot else { return } - ensureTextEnvironment(scale: 1) + ensureTextEnvironment(scale: contentScale) withTextEnvInstalled { _ = recomposer.commitAll() diff --git a/GuavaUI/Sources/GuavaUICompose/Primitives/ViewportHost.swift b/GuavaUI/Sources/GuavaUICompose/Primitives/ViewportHost.swift index 55658719..09d522ec 100644 --- a/GuavaUI/Sources/GuavaUICompose/Primitives/ViewportHost.swift +++ b/GuavaUI/Sources/GuavaUICompose/Primitives/ViewportHost.swift @@ -103,8 +103,12 @@ public struct ViewportHost: _PrimitiveView { } node.draw = { list, origin in - let width = UInt32(max(Int(node.frame.width.rounded()), 1)) - let height = UInt32(max(Int(node.frame.height.rounded()), 1)) + // Report the drawable in physical pixels: the layout frame is in + // logical points, so honor the window's content scale or the + // scene renders at 1/scale² resolution and gets upscaled blurry. + let scale = CGFloat(max(1, ContentScaleHolder.current)) + let width = UInt32(max(Int((node.frame.width * scale).rounded()), 1)) + let height = UInt32(max(Int((node.frame.height * scale).rounded()), 1)) let drawableSize = RenderDrawableSize(width: width, height: height) let key = "__viewport_host_drawable_size" let previous = node.attachments[key] as? RenderDrawableSize @@ -128,8 +132,8 @@ public struct ViewportHost: _PrimitiveView { guard let bridge = ViewportTextureBridgeHolder.current, let textureID = bridge.textureID(surfaceID: snap.surface.surfaceID, handle: snap.surface.handle, - width: snap.surface.width, - height: snap.surface.height) + width: snap.surface.textureWidth, + height: snap.surface.textureHeight) else { return } @@ -138,7 +142,15 @@ public struct ViewportHost: _PrimitiveView { y: Float(origin.y), width: Float(frame.width), height: Float(frame.height)) - list.addImageQuad(rect: rect, textureID: textureID, tint: .white) + // The engine renders into the top-left sub-region of a grow-only + // allocated texture; crop to the used extent. + let uvMax: (x: Float, y: Float) = ( + snap.surface.textureWidth > 0 + ? Float(snap.surface.width) / Float(snap.surface.textureWidth) : 1, + snap.surface.textureHeight > 0 + ? Float(snap.surface.height) / Float(snap.surface.textureHeight) : 1 + ) + list.addImageQuad(rect: rect, textureID: textureID, tint: .white, uvMax: uvMax) snap.onDrawOverlay?(list, screenFrame) } diff --git a/GuavaUI/Sources/GuavaUIRuntime/InGameUIRuntime.swift b/GuavaUI/Sources/GuavaUIRuntime/InGameUIRuntime.swift index eff37a54..5bd5d48e 100644 --- a/GuavaUI/Sources/GuavaUIRuntime/InGameUIRuntime.swift +++ b/GuavaUI/Sources/GuavaUIRuntime/InGameUIRuntime.swift @@ -80,10 +80,14 @@ public final class InGameUIRenderer: InGameUIProviding, @unchecked Sendable { storeOp: .store, clearColor: .clear ) + // The scene occupies the top-left `width`×`height` sub-region of a + // grow-only allocated target; pin the HUD to the same region. + pass.setViewport(x: 0, y: 0, width: Float(width), height: Float(height)) + pass.setScissorRect(x: 0, y: 0, width: UInt32(width), height: UInt32(height)) try renderer.render( list: renderThreadList, pass: pass, - viewportPx: (snapshot.viewportWidth, snapshot.viewportHeight), + viewportPx: (UInt32(width), UInt32(height)), coordinateSpace: (snapshot.logicalWidth, snapshot.logicalHeight) ) pass.end() diff --git a/GuavaUI/Tests/GuavaUIComposeTests/ViewportHostScaleTests.swift b/GuavaUI/Tests/GuavaUIComposeTests/ViewportHostScaleTests.swift new file mode 100644 index 00000000..657f4c28 --- /dev/null +++ b/GuavaUI/Tests/GuavaUIComposeTests/ViewportHostScaleTests.swift @@ -0,0 +1,95 @@ +import Testing +import EngineKernel +import GuavaUIRuntime +import RenderBackend +@testable import GuavaUICompose + +/// Drawable-size reporting and UV-cropped compositing of `ViewportHost`: +/// the host reports physical pixels (logical frame × content scale) and +/// samples only the used sub-region of the grow-only allocated texture. +@Suite("ViewportHost Scale", .serialized) +struct ViewportHostScaleTests: GuavaUIComposeSerializedSuite { + private final class RecordingBridge: ViewportTextureBridge { + var registeredSize: (width: UInt32, height: UInt32)? + func textureID(surfaceID: UInt64, handle: UInt64, width: UInt32, height: UInt32) -> TextureID? { + guard surfaceID != 0, handle != 0, width > 0, height > 0 else { return nil } + registeredSize = (width, height) + return 10_000 + } + } + + private func firstDrawNode(_ node: Node?) -> Node? { + guard let node else { return nil } + if node.draw != nil { return node } + for child in node.children { + if let found = firstDrawNode(child) { return found } + } + return nil + } + + @Test("Reports drawable size in physical pixels honoring the content scale") + func reportsPixelDrawableSize() { GlobalTestLock.locked { + let previousScale = ContentScaleHolder.current + ContentScaleHolder.current = 2 + defer { ContentScaleHolder.current = previousScale } + + var reported: [RenderDrawableSize] = [] + let tree = NodeTree() + let graph = ViewGraph(tree: tree, recomposer: Recomposer()) + graph.install(root: + ViewportHost(surface: ViewportSurfaceState(surfaceID: 1, + handle: 1, + width: 200, + height: 120), + onDrawableSizeChange: { reported.append($0) }) + .frame(width: 200, height: 120) + ) + graph.computeLayout(width: 200, height: 120) + + let list = DrawList() + let node = try? #require(firstDrawNode(tree.root)) + node?.draw?(list, .zero) + + #expect(reported.last == RenderDrawableSize(width: 400, height: 240)) + } } + + @Test("Composites the used sub-region of the allocated texture") + func cropsToUsedRegion() { GlobalTestLock.locked { + let previousScale = ContentScaleHolder.current + ContentScaleHolder.current = 1 + defer { ContentScaleHolder.current = previousScale } + + let bridge = RecordingBridge() + let previousBridge = ViewportTextureBridgeHolder.current + ViewportTextureBridgeHolder.current = bridge + defer { ViewportTextureBridgeHolder.current = previousBridge } + + let tree = NodeTree() + let graph = ViewGraph(tree: tree, recomposer: Recomposer()) + graph.install(root: + ViewportHost(surface: ViewportSurfaceState(surfaceID: 7, + handle: 7, + width: 300, + height: 200, + textureWidth: 512, + textureHeight: 256)) + .frame(width: 200, height: 120) + ) + graph.computeLayout(width: 200, height: 120) + + let list = DrawList() + let node = try? #require(firstDrawNode(tree.root)) + node?.draw?(list, .zero) + + #expect(bridge.registeredSize?.width == 512) + #expect(bridge.registeredSize?.height == 256) + + // Image quad u carries a +10 sentinel bias (see DrawList.addImageQuad). + let us = list.vertices.map { $0.u - 10 } + let vs = list.vertices.map(\.v) + let maxU = us.max() ?? -1 + let maxV = vs.max() ?? -1 + #expect(abs(maxU - 300.0 / 512.0) < 1e-5) + #expect(abs(maxV - 200.0 / 256.0) < 1e-5) + } } +}