From 7c45aa86bc4fc29445651a6bec5f36d4f27f567f Mon Sep 17 00:00:00 2001 From: Lilia_Chen Date: Mon, 6 Jul 2026 22:21:15 +0100 Subject: [PATCH] feat(stage-godot): rim light (#2032) ## Description Adds the Godot Stage rim light pipeline and development observation tooling. - Replaces the old avatar glow-only compositor path with a staged post-process compositor. - Adds avatar-scoped edge light, glow, material overlay, and final colour mapping stages. - Implements a dev observation adapter for exporting viewport/render-stage artefacts. - Adds visual verification tooling and baseline docs for Godot Stage rendering work. - Documents the technical art workflow and rendering effect behaviour. --- .../main/services/airi/godot-stage/index.ts | 10 + engines/stage-tamagotchi-godot/.gitignore | 1 + engines/stage-tamagotchi-godot/README.md | 52 +- .../docs/rendering-effects.md | 58 +- .../docs/technical-art-workflow.md | 144 ++ .../docs/verification-scenes.md | 41 +- engines/stage-tamagotchi-godot/package.json | 4 +- .../scripts/StageRoot.cs | 413 ++++- .../scripts/dev/StageDevObservationAdapter.cs | 463 ++++++ .../scripts/transport/StageViewJson.cs | 146 ++ .../scripts/transport/StageViewPayloads.cs | 39 + .../scripts/view/StageViewRuntime.cs | 26 +- .../StageAvatarGlowCompositorEffect.cs | 1384 ----------------- .../scripts/visuals/StageAvatarGlowRuntime.cs | 119 -- .../scripts/visuals/StageCompositorOwner.cs | 65 + .../visuals/StageMaterialOverlayOwner.cs | 158 ++ ...agePostProcessCompositorEffect.Pipeline.cs | 283 ++++ ...gePostProcessCompositorEffect.Resources.cs | 1088 +++++++++++++ ...tagePostProcessCompositorEffect.Shaders.cs | 496 ++++++ .../StagePostProcessCompositorEffect.cs | 289 ++++ .../visuals/StageRenderEffectsRuntime.cs | 68 + .../tools/captureWindowClientPng.ps1 | 241 +++ .../tools/dumpRenderStages.mjs | 664 ++++++++ .../tools/exportXiaoerEdgeReferenceStages.py | 213 +++ vitest.config.ts | 1 - 25 files changed, 4901 insertions(+), 1565 deletions(-) create mode 100644 engines/stage-tamagotchi-godot/docs/technical-art-workflow.md create mode 100644 engines/stage-tamagotchi-godot/scripts/dev/StageDevObservationAdapter.cs delete mode 100644 engines/stage-tamagotchi-godot/scripts/visuals/StageAvatarGlowCompositorEffect.cs delete mode 100644 engines/stage-tamagotchi-godot/scripts/visuals/StageAvatarGlowRuntime.cs create mode 100644 engines/stage-tamagotchi-godot/scripts/visuals/StageCompositorOwner.cs create mode 100644 engines/stage-tamagotchi-godot/scripts/visuals/StageMaterialOverlayOwner.cs create mode 100644 engines/stage-tamagotchi-godot/scripts/visuals/StagePostProcessCompositorEffect.Pipeline.cs create mode 100644 engines/stage-tamagotchi-godot/scripts/visuals/StagePostProcessCompositorEffect.Resources.cs create mode 100644 engines/stage-tamagotchi-godot/scripts/visuals/StagePostProcessCompositorEffect.Shaders.cs create mode 100644 engines/stage-tamagotchi-godot/scripts/visuals/StagePostProcessCompositorEffect.cs create mode 100644 engines/stage-tamagotchi-godot/scripts/visuals/StageRenderEffectsRuntime.cs create mode 100644 engines/stage-tamagotchi-godot/tools/captureWindowClientPng.ps1 create mode 100644 engines/stage-tamagotchi-godot/tools/dumpRenderStages.mjs create mode 100644 engines/stage-tamagotchi-godot/tools/exportXiaoerEdgeReferenceStages.py diff --git a/apps/stage-tamagotchi/src/main/services/airi/godot-stage/index.ts b/apps/stage-tamagotchi/src/main/services/airi/godot-stage/index.ts index 42be675c9..16ef41539 100644 --- a/apps/stage-tamagotchi/src/main/services/airi/godot-stage/index.ts +++ b/apps/stage-tamagotchi/src/main/services/airi/godot-stage/index.ts @@ -252,6 +252,15 @@ function resolveGodotStageDebugLaunchOptions() { } } +function resolveGodotStageProcessEnv(): NodeJS.ProcessEnv { + return { + ...process.env, + AIRI_GODOT_STAGE_DEV_MODE: app.isPackaged + ? process.env.AIRI_GODOT_STAGE_DEV_MODE ?? '0' + : process.env.AIRI_GODOT_STAGE_DEV_MODE ?? '1', + } +} + interface GodotBinaryResolution { executable: string mode: 'engine' | 'exported' @@ -758,6 +767,7 @@ export function createGodotStageManager(): GodotStageManager { spawnArgs, { cwd: spawnCwd, + env: resolveGodotStageProcessEnv(), stdio: ['ignore', 'pipe', 'pipe'], windowsHide: false, }, diff --git a/engines/stage-tamagotchi-godot/.gitignore b/engines/stage-tamagotchi-godot/.gitignore index 7f4591b46..c5125ade6 100644 --- a/engines/stage-tamagotchi-godot/.gitignore +++ b/engines/stage-tamagotchi-godot/.gitignore @@ -1,6 +1,7 @@ # Godot 4+ specific ignores .godot/ /android/ +/artifacts/ # .NET build artifacts bin/ diff --git a/engines/stage-tamagotchi-godot/README.md b/engines/stage-tamagotchi-godot/README.md index 77ccd3deb..f12ad424e 100644 --- a/engines/stage-tamagotchi-godot/README.md +++ b/engines/stage-tamagotchi-godot/README.md @@ -165,23 +165,26 @@ Godot Environment Glow, and leaves Godot tonemap/adjustment neutral. The current custom color mapping is applied by the stage compositor instead of Godot's Environment adjustment controls. -## Avatar Glow And Color Mapping +## Avatar Mask, Glow, And Color Mapping -The current avatar presentation path uses a camera-local compositor: +The current avatar presentation path uses stage-owned overlay and compositor +owners: -1. `StageAvatarGlowRuntime` assigns a `Compositor` to the active `Camera3D`. -2. The runtime marks loaded avatar `GeometryInstance3D` nodes with a - depth-tested `MaterialOverlay` that writes stencil reference `1`. -3. `StageAvatarGlowCompositorEffect` runs at `PostTransparent`, extracts - stencil-marked avatar pixels from the resolved scene color, builds the bloom - pyramid, composites avatar glare, and applies the current NAES/toon color - mapping before Godot's neutral output path. +1. `StageRenderEffectsRuntime` wires the active camera and loaded avatar into the + stage render-effect owners. +2. `StageMaterialOverlayOwner` records render-effect source claims and assigns + the shared avatar mask overlay material to loaded avatar `GeometryInstance3D` + nodes. +3. `StageCompositorOwner` installs the stage post-process compositor effect on + the active `Camera3D`. +4. `StagePostProcessCompositorEffect` runs at `PostTransparent` and applies the + ordered scene copy, avatar glow, and final color mapping stages. This is not Godot Environment Glow and does not use material emission as the -avatar-style glow source. The color mapping currently lives in the same -compositor effect as avatar glow; before adding more post effects such as rim -light or screen-space outlines, pass orchestration and shared transient render -textures should move behind a shared post-process owner. +avatar-style glow source. The color mapping is an independently enabled final +stage inside the stage post-process compositor. Future avatar-only edge light +work should establish same-scene visual comparison artifacts before changing the +pipeline. Design notes live in [`docs/rendering-effects.md`](docs/rendering-effects.md). @@ -203,6 +206,29 @@ outline passes, and mesh shadow casters. The current A/B fixtures do not contain unlit materials, so this check reports `unlit = 0` and does not treat that as a failure. +## Visual Observation + +Renderer-facing changes should produce comparable render artifacts before they +are accepted: + +```powershell +pnpm -F @proj-airi/stage-tamagotchi-godot dump:render-stages +``` + +For rim or edge-light shape checks, prefer the upper-body camera preset: + +```powershell +pnpm -F @proj-airi/stage-tamagotchi-godot dump:render-stages:upper-body +``` + +The command launches the real `StageRoot` scene through the local WebSocket +protocol, loads `AvatarSample_A.vrm`, captures the visible Godot window client +area as the final rendered output, and writes diagnostic render-stage artifacts +under `artifacts/`. + +The Technical Art workflow and acceptance rules live in +[`docs/technical-art-workflow.md`](docs/technical-art-workflow.md). + ## Live Debugging From The Godot Editor Use this path when Electron is the real host and the model is selected from the diff --git a/engines/stage-tamagotchi-godot/docs/rendering-effects.md b/engines/stage-tamagotchi-godot/docs/rendering-effects.md index 12a652c6a..025d24e19 100644 --- a/engines/stage-tamagotchi-godot/docs/rendering-effects.md +++ b/engines/stage-tamagotchi-godot/docs/rendering-effects.md @@ -23,29 +23,38 @@ CompositorEffect For avatar glow, this means: -- `StageAvatarGlowRuntime` marks avatar meshes with an overlay material that - depth-tests against the scene and writes stencil reference `1` without writing - scene depth. -- `StageAvatarGlowCompositorEffect` reads the stencil-marked scene color, - extracts the glow source, diffuses it, composites it, and applies the current - toon color mapping. +- `StageRenderEffectsRuntime` wires the stage-level overlay and compositor + owners for the active camera and loaded avatar. +- `StageMaterialOverlayOwner` records render-effect source claims and assigns + the shared avatar mask overlay material. The overlay depth-tests against the + scene and writes stencil reference `1` without writing scene depth. +- `StagePostProcessCompositorEffect` always owns the final NAES/toon color + mapping stage. Its internal pipeline currently runs scene copy, avatar glow, + and final color mapping in that order. +- When avatar glow is active, it extracts bright source from the stencil-marked + scene color, diffuses it, and composites avatar glare into the HDR input + consumed by final color mapping. - The vendored V-Sekai MToon shader remains responsible for the avatar's base material look. Current implementation details: -- `StageAvatarGlowRuntime` currently assigns a new `Compositor` directly to the - active `Camera3D`. This is acceptable while avatar glow is the only stage - compositor, but multiple camera effects need a shared compositor owner. -- `StageAvatarGlowCompositorEffect` currently owns both avatar glow and the - stage toon color mapping. That is an implementation coupling, not the desired - long-term feature boundary. -- The effect owns its transient bloom textures. It uses Godot's +- `StageCompositorOwner` owns the active `Camera3D` compositor slot and registers + the stage post-process compositor effect without feature runtimes replacing + the camera compositor independently. +- `StageMaterialOverlayOwner` owns `GeometryInstance3D.MaterialOverlay` writes + for stage render-effect source passes. +- The compositor effect owns its transient post-process textures. It uses Godot's `FramebufferCacheRD` and `UniformSetCacheRD` for derived framebuffer and - uniform-set RIDs, while texture ownership stays local to the effect until a - shared post-process resource context exists. + uniform-set RIDs, while texture ownership stays local to the stage + post-process pass graph. +- The final NAES/toon color mapping is a separate compositor stage and no + longer depends on avatar glow being enabled. Later source effects can feed the + same final color stage before display output after same-scene visual + comparison. - Godot Environment Glow is disabled in `StageVisualPreset`; avatar-style glow - source selection comes from the stencil overlay, not from material emission. + source selection comes from the shared stencil overlay, not from material + emission. ## Why Overlay First @@ -57,7 +66,7 @@ Use overlay first when an effect can be represented as an extra whole-geometry pass: - stencil or object-mask tagging; -- avatar-only glow source selection; +- avatar mask selection for glow and future avatar-only effects; - silhouette, outline, selection, or interaction masks; - depth-tested x-ray or rim/shell source passes; - temporary verification passes that should not mutate imported materials. @@ -86,11 +95,11 @@ authoring data that the overlay pass can read. ## Known Constraints - `MaterialOverlay` is a single slot on each `GeometryInstance3D`. Multiple - effects cannot assign it independently without an owner that arbitrates the - slot. -- `Camera3D.Compositor` is also a single owner slot in the current stage code. - Additional post effects should be registered through a shared compositor owner - instead of independently replacing the camera compositor. + effects must go through `StageMaterialOverlayOwner` instead of assigning it + independently. +- `Camera3D.Compositor` is also a single owner slot. Additional post effects + must be registered through the stage compositor owner instead of independently + replacing the camera compositor. - The overlay material applies to the whole geometry and all surfaces unless the mesh is split or the overlay shader has reliable mask data. - Overlay adds draw work for every marked geometry. This is acceptable for the @@ -102,9 +111,8 @@ authoring data that the overlay pass can read. - Screen-space diffusion, blur pyramids, and color mapping still belong in a same-frame compositor. Overlay should produce source information, not replace the compositor pass graph. -- The current color mapping is tied to `StageAvatarGlowCompositorEffect`. - Splitting color mapping into a stage-level post-process pass should happen - together with the next multi-effect refactor. +- The current final color mapping still lives inside the stage post-process + compositor, but it is an independently enabled stage, not a glow sub-pass. ## Decision Rule diff --git a/engines/stage-tamagotchi-godot/docs/technical-art-workflow.md b/engines/stage-tamagotchi-godot/docs/technical-art-workflow.md new file mode 100644 index 000000000..cdb024174 --- /dev/null +++ b/engines/stage-tamagotchi-godot/docs/technical-art-workflow.md @@ -0,0 +1,144 @@ +# Technical Art Visual Workflow + +This workflow exists for renderer and Technical Art work where visual judgment +is part of correctness. It is specifically written for the Godot stage runtime. + +## Required Evidence + +Every renderer-facing change needs comparable evidence before it can be +accepted: + +1. Reference evidence. + Use screenshots, Blender captures, shader references, or shipped-game + examples. Record the camera, model, active feature state, and what visual + property the reference is meant to prove. +2. Before/after AIRI evidence. + Capture the current AIRI output before and after changing the renderer when + the question depends on a visual difference. +3. Pipeline evidence. + Capture the relevant intermediate render stages when debugging compositor or + material pipeline behavior. Do not rely on memory or a manually inspected + live viewport alone. + +The current render-stage dump command is: + +```powershell +pnpm -F @proj-airi/stage-tamagotchi-godot dump:render-stages +``` + +For rim or edge-light shape checks, prefer the upper-body camera preset: + +```powershell +pnpm -F @proj-airi/stage-tamagotchi-godot dump:render-stages:upper-body +``` + +The dump is end-to-end: it launches the real main stage, loads the tracked +avatar through the WebSocket host path, and captures the visible Godot window +client area. Internal viewport texture readback is not acceptable as final +renderer evidence because it can bypass the final display conversion and +produce darker, higher-contrast PNGs than the actual window. + +The command writes: + +```text +engines/stage-tamagotchi-godot/artifacts/render-stages/ +``` + +The upper-body variant writes: + +```text +engines/stage-tamagotchi-godot/artifacts/render-stages-upper-body/ +``` + +Default outputs are: + +- `scene-copy.png` +- `avatar-mask.png` +- `avatar-edge-mask.png` +- `after-avatar-edge-light.png` +- `after-avatar-glow.png` +- `final.png` +- `final-edge-off.png` + +These are diagnostic outputs, not accepted baselines. Use them to locate the +first divergent stage before changing the next stage. + +This workflow intentionally does not define a project-wide visual baseline yet. +If visual baselines are added later, they need a separate design for scope, +fixture ownership, feature-specific acceptance, and update policy. + +## Work Order + +1. Define the visual question. + Write the narrow question first: color mapping parity, avatar-only glow, + rim-light shape, outline width, alpha sorting, or another specific property. + Do not start from an implementation idea. + +2. Choose the reference and fixture. + Use a tracked fixture when the result should stay reproducible from the + repository. Use a private fixture only for temporary research, and record the + path and limitation in the working notes. + +3. Capture the current output. + Dump the relevant render stages and keep the artifact path in the working + notes or PR context. For refactors, this is the behavior that must remain + visually stable. + +4. Make one renderer change. + Keep the change aligned to one pipeline stage or one material/source owner + decision. A refactor and a new visual effect should not be accepted in the + same comparison unless there is an explicit reason. + +5. Compare before judging. + Dump the same scene after the change and compare the relevant artifacts + before changing code again. + +6. Classify the difference. + Put the difference into one bucket before fixing: + - expected visual change + - expected drift caused by the intended change + - regression in an unrelated visual property + - capture instability + - fixture or camera mismatch + +7. Record the acceptance. + If the image changes intentionally, record why and keep the artifact path. If + the code change is supposed to preserve behavior, compare the before/after + artifacts and document the result. + +## Debug Rules + +- Start from the rendered artifact, not from a shader guess. +- Use final window output for visual acceptance. `GetViewport().GetTexture()` + readback is only an intermediate diagnostic and must not become the accepted + final evidence for color, contrast, glow, rim light, or tone-mapping + judgments. +- Compare the same model, camera, viewport size, and frame timing. +- For pipeline work, identify the stage that first diverges before modifying the + next stage. +- For reference-driven compositor work, compare the reference stage output and + Godot stage output side by side. Do not accept the final image until the + intermediate mask and after-stage image explain the final difference. +- For material work, inspect imported material parameters before changing the + post-process shader. +- For color issues, separate scene color, source mask, glow composite, and final + color mapping. Do not merge these explanations into one cause. +- If three local fixes do not explain the visual difference, stop and re-check + the pipeline ownership model before adding another patch. + +## Gate For Edge Light Work + +Edge light work can start only after these are true: + +1. Current render-stage artifacts exist and have been manually inspected. +2. Refactor-only changes have matching before/after output for the relevant + views. +3. The reference case has a documented camera and enabled/disabled comparison. +4. The planned edge-light implementation is behind a toggle or stage boundary so + feature-off output can still be captured and compared. +5. Xiaoer reference stages and Godot render stages have been dumped for the same + visual question: edge mask, after-edge image, and final enabled/disabled + comparison. + +This avoids repeating the previous failure mode: changing rendering architecture +and a new visual effect at the same time without proving same-scene parity. diff --git a/engines/stage-tamagotchi-godot/docs/verification-scenes.md b/engines/stage-tamagotchi-godot/docs/verification-scenes.md index c97e53b63..51a538a2a 100644 --- a/engines/stage-tamagotchi-godot/docs/verification-scenes.md +++ b/engines/stage-tamagotchi-godot/docs/verification-scenes.md @@ -30,12 +30,13 @@ A visual renderer script should: 3. Load the target avatar or fixture through the same runtime path being tested. 4. Create one active `Camera3D` and use fixed poses. 5. Install the renderer feature runtime being tested. For avatar glow, create - `StageAvatarGlowRuntime(camera)` and call `UseAvatar(avatar)` after loading - the avatar; `StageVisualPreset.Apply(this)` alone does not install the glow - compositor. + `StageRenderEffectsRuntime(camera)` and call `UseAvatar(avatar)` after + loading the avatar; `StageVisualPreset.Apply(this)` alone does not install + the glow compositor. 6. Wait several frames before each capture so imports, materials, and compositor resources settle. -7. Save PNG captures to `Path.Combine(Path.GetTempPath(), "")`. +7. Save PNG captures to `Path.Combine(Path.GetTempPath(), "")` only + for intermediate pass diagnostics. 8. Print compact diagnostics that can be compared across runs. 9. Quit with `GetTree().Quit(0)` after the final capture. @@ -79,9 +80,10 @@ public override void _Process(double delta) } ``` -The capture helper should create the output directory, call -`GetViewport().GetTexture().GetImage()`, save the image, and throw if -`SavePng` fails. +The in-scene capture helper may call `GetViewport().GetTexture().GetImage()` for +intermediate data, but this is not final display output. Do not use viewport +readback PNGs as acceptance evidence for color mapping, contrast, glow, rim +light, or other final-frame judgments. ## Run Command @@ -93,15 +95,30 @@ C:\Godot_v4.6.2-stable_mono_win64\Godot_v4.6.2-stable_mono_win64.exe ` --scene "res://tests//.tscn" ``` -For shipped stage visuals, follow this with the main stage verification flow: +For shipped stage visuals, export render-stage artifacts instead of relying on a +temporary manual screenshot: + +```powershell +pnpm -F @proj-airi/stage-tamagotchi-godot dump:render-stages +``` + +For rim or edge-light shape checks, prefer the upper-body camera preset: + +```powershell +pnpm -F @proj-airi/stage-tamagotchi-godot dump:render-stages:upper-body +``` + +The dump command runs this flow: 1. Start a local WebSocket host. -2. Launch the main Godot stage with `-- --airi-ws-url=ws://127.0.0.1:/`. +2. Launch the main Godot stage with a fixed `--resolution`. 3. Wait for `stage.ready`. -4. Send `host.scene.apply` with the target VRM. +4. Send `host.scene.apply` with the tracked AvatarSample A VRM. 5. Wait for `scene.applied`. -6. Bring the Godot window to the front and capture it. -7. Send `host.shutdown`. +6. Capture the visible Godot window client area for each requested render-stage + view. +7. Write diagnostic PNGs under `artifacts/`. +8. Send `host.shutdown`. ## Cleanup diff --git a/engines/stage-tamagotchi-godot/package.json b/engines/stage-tamagotchi-godot/package.json index 49ce34c35..3cb6fd0f1 100644 --- a/engines/stage-tamagotchi-godot/package.json +++ b/engines/stage-tamagotchi-godot/package.json @@ -17,6 +17,8 @@ }, "scripts": { "build": "dotnet build ./stage-tamagotchi-godot.csproj", - "typecheck": "dotnet build ./stage-tamagotchi-godot.csproj" + "typecheck": "dotnet build ./stage-tamagotchi-godot.csproj", + "dump:render-stages": "node ./tools/dumpRenderStages.mjs --dump-render-stages ./artifacts/render-stages", + "dump:render-stages:upper-body": "node ./tools/dumpRenderStages.mjs --dump-render-stages ./artifacts/render-stages-upper-body --view-preset upper-body" } } diff --git a/engines/stage-tamagotchi-godot/scripts/StageRoot.cs b/engines/stage-tamagotchi-godot/scripts/StageRoot.cs index b83fc46b0..ac3d73601 100644 --- a/engines/stage-tamagotchi-godot/scripts/StageRoot.cs +++ b/engines/stage-tamagotchi-godot/scripts/StageRoot.cs @@ -1,4 +1,6 @@ using System; +using System.Collections.Generic; +using System.IO; using System.Text.Json; using System.Text.Json.Serialization; using Godot; @@ -21,6 +23,7 @@ public partial class StageRoot : Node3D private const string CameraNodeName = "Camera3D"; private const string EditorPreviewRootNodeName = "EditorPreviewRoot"; private const string WebSocketUrlArgumentPrefix = "--airi-ws-url="; + private const int DevRenderExportSettleFrames = 2; private readonly JsonSerializerOptions _jsonOptions = new() { @@ -33,9 +36,14 @@ public partial class StageRoot : Node3D private StageSceneController _sceneController = null!; private StageViewController _viewController = null!; private StageCameraInputController _cameraInputController = null!; - private StageAvatarGlowRuntime _avatarGlowRuntime = null!; + private StageRenderEffectsRuntime _renderEffectsRuntime = null!; private StageViewRuntime _viewRuntime = null!; + private StageDevObservationAdapter _devObservationAdapter; private string _activeSceneModelId; + private StageSceneApplyPayload _activeScenePayload; + private StageViewCapturePngRequestPayload _pendingViewCapture; + private int _pendingViewCaptureFrames; + private DevRenderExportRun _pendingDevRenderExport; private bool _shutdownRequested; /// @@ -48,7 +56,7 @@ public partial class StageRoot : Node3D var camera = ResolveCamera(); _sceneController = new StageSceneController(avatarRoot, new VrmAvatarLoader()); InitializeViewRuntime(avatarRoot, camera); - _avatarGlowRuntime = new StageAvatarGlowRuntime(camera); + _renderEffectsRuntime = new StageRenderEffectsRuntime(camera); var webSocketUrl = ResolveWebSocketUrl(); if (string.IsNullOrWhiteSpace(webSocketUrl)) @@ -69,6 +77,8 @@ public partial class StageRoot : Node3D GetTree().Quit(); return; } + + StartDevObservationAdapterIfEnabled(); } /// @@ -80,14 +90,18 @@ public partial class StageRoot : Node3D } _bridge.Poll(); + _devObservationAdapter?.Poll(); _viewRuntime?.Process(delta); _cameraInputController?.Process(delta); + ProcessPendingViewCapture(); + ProcessPendingDevRenderExport(); } /// public override void _ExitTree() { - _avatarGlowRuntime?.Dispose(); + _devObservationAdapter?.Dispose(); + _renderEffectsRuntime?.Dispose(); } /// @@ -98,11 +112,21 @@ public partial class StageRoot : Node3D private void HandleBridgeOpened() { + if (_devObservationAdapter != null) + { + _devObservationAdapter.AiriBridgeConnected = true; + } + _bridge.SendEnvelope("stage.ready"); } private void HandleBridgeClosed(string message) { + if (_devObservationAdapter != null) + { + _devObservationAdapter.AiriBridgeConnected = false; + } + if (_shutdownRequested) { GetTree().Quit(); @@ -179,6 +203,15 @@ public partial class StageRoot : Node3D case "host.view.request_snapshot": RequestViewSnapshot(envelope.Payload); break; + case "host.view.capture_png": + QueueViewPngCapture(envelope.Payload); + break; + case "host.render.set_debug_view": + SetRenderDebugView(envelope.Payload); + break; + case "host.render.set_avatar_edge_light": + SetAvatarEdgeLight(envelope.Payload); + break; case "host.shutdown": _shutdownRequested = true; GetTree().Quit(); @@ -221,11 +254,13 @@ public partial class StageRoot : Node3D // avatar already loaded. var avatar = _sceneController.Apply(payload); _viewController?.UseAvatar(avatar); - _avatarGlowRuntime?.UseAvatar(avatar); + _renderEffectsRuntime?.UseAvatar(avatar); _viewRuntime?.BootstrapForAvatar(); _activeSceneModelId = payload.ModelId; } + _activeScenePayload = payload; + _bridge.SendEnvelope("scene.applied", new { modelId = payload.ModelId, @@ -278,6 +313,129 @@ public partial class StageRoot : Node3D } } + private void QueueViewPngCapture(JsonElement? payloadElement) + { + if (payloadElement == null) + { + SendViewCaptureError("View PNG capture request payload was empty."); + return; + } + + var requestId = StageViewJson.TryReadRequestId(payloadElement.Value); + try + { + if (_pendingViewCapture != null) + { + throw new InvalidOperationException("A viewport PNG capture is already pending."); + } + + var payload = StageViewJson.ParseCapturePngRequest(payloadElement.Value); + _pendingViewCapture = payload; + _pendingViewCaptureFrames = payload.SettleFrames; + } + catch (Exception error) + { + SendViewCaptureError(error.Message, requestId); + } + } + + private void SetRenderDebugView(JsonElement? payloadElement) + { + if (payloadElement == null) + { + SendRenderDebugViewError("Render debug view request payload was empty."); + return; + } + + var requestId = StageViewJson.TryReadRequestId(payloadElement.Value); + try + { + var payload = StageViewJson.ParseRenderDebugViewRequest(payloadElement.Value); + var appliedView = _renderEffectsRuntime.SetDebugView(payload.View); + _bridge.SendEnvelope("stage.render.debug_view", new StageRenderDebugViewPayload( + payload.RequestId, + appliedView + )); + } + catch (Exception error) + { + SendRenderDebugViewError(error.Message, requestId); + } + } + + private void SetAvatarEdgeLight(JsonElement? payloadElement) + { + if (payloadElement == null) + { + SendAvatarEdgeLightError("Avatar edge-light request payload was empty."); + return; + } + + var requestId = StageViewJson.TryReadRequestId(payloadElement.Value); + try + { + var payload = StageViewJson.ParseRenderAvatarEdgeLightRequest(payloadElement.Value); + var enabled = _renderEffectsRuntime.SetAvatarEdgeLightEnabled(payload.Enabled); + _bridge.SendEnvelope("stage.render.avatar_edge_light", new StageRenderAvatarEdgeLightPayload( + payload.RequestId, + enabled + )); + } + catch (Exception error) + { + SendAvatarEdgeLightError(error.Message, requestId); + } + } + + private void ProcessPendingViewCapture() + { + if (_pendingViewCapture == null) + { + return; + } + + if (_pendingViewCaptureFrames > 0) + { + _pendingViewCaptureFrames--; + return; + } + + var payload = _pendingViewCapture; + _pendingViewCapture = null; + _pendingViewCaptureFrames = 0; + CaptureViewPng(payload); + } + + private void CaptureViewPng(StageViewCapturePngRequestPayload payload) + { + try + { + var image = GetViewport().GetTexture().GetImage(); + var directory = Path.GetDirectoryName(payload.Path); + if (!string.IsNullOrWhiteSpace(directory)) + { + Directory.CreateDirectory(directory); + } + + var saveError = image.SavePng(payload.Path); + if (saveError != Error.Ok) + { + throw new InvalidOperationException($"Godot failed to save viewport PNG: {saveError}."); + } + + _bridge.SendEnvelope("stage.view.capture_png", new StageViewCapturePngPayload( + payload.RequestId, + payload.Path, + image.GetWidth(), + image.GetHeight() + )); + } + catch (Exception error) + { + SendViewCaptureError(error.Message, payload.RequestId); + } + } + private static string ResolveWebSocketUrl() { return ResolveArgumentValue(WebSocketUrlArgumentPrefix); @@ -313,6 +471,195 @@ public partial class StageRoot : Node3D _cameraInputController = new StageCameraInputController(_viewRuntime, cameraController); } + private void StartDevObservationAdapterIfEnabled() + { + if (!StageDevObservationAdapter.IsEnabled()) + { + return; + } + + _devObservationAdapter = new StageDevObservationAdapter( + _jsonOptions, + ProjectSettings.GlobalizePath("res://"), + new[] + { + StageRenderDebugViewNames.Final, + StageRenderDebugViewNames.SceneCopy, + StageRenderDebugViewNames.AvatarMask, + StageRenderDebugViewNames.AvatarEdgeMask, + StageRenderDebugViewNames.AfterAvatarEdgeLight, + StageRenderDebugViewNames.AfterAvatarGlow, + } + ); + _devObservationAdapter.RenderExportRequested += QueueDevRenderExport; + + try + { + _devObservationAdapter.Start(); + } + catch (Exception error) + { + GD.PushWarning(error.Message); + _devObservationAdapter.Dispose(); + _devObservationAdapter = null; + } + } + + private void QueueDevRenderExport(StageDevObservationRenderExportRequest request) + { + if (_pendingDevRenderExport != null) + { + _devObservationAdapter?.SendRenderExportError( + request.RequestId, + "export_busy", + "A render export is already pending." + ); + return; + } + + if (_renderEffectsRuntime == null) + { + _devObservationAdapter?.SendRenderExportError( + request.RequestId, + "not_ready", + "Render effects runtime is not ready." + ); + return; + } + + foreach (var stage in request.Stages) + { + if (!StageRenderDebugViewNames.TryParse(stage, out _)) + { + _devObservationAdapter?.SendRenderExportError( + request.RequestId, + "invalid_payload", + $"Unknown render stage: {stage}." + ); + return; + } + } + + _pendingDevRenderExport = new DevRenderExportRun( + request, + _renderEffectsRuntime.CurrentDebugView + ); + } + + private void ProcessPendingDevRenderExport() + { + if (_pendingDevRenderExport == null) + { + return; + } + + try + { + if (_pendingDevRenderExport.ActiveStage == null) + { + BeginNextDevRenderExportStage(); + return; + } + + if (_pendingDevRenderExport.SettleFramesRemaining > 0) + { + _pendingDevRenderExport.SettleFramesRemaining--; + return; + } + + CaptureActiveDevRenderExportStage(); + if (_pendingDevRenderExport.NextStageIndex >= _pendingDevRenderExport.Request.Stages.Length) + { + CompleteDevRenderExport(); + return; + } + + BeginNextDevRenderExportStage(); + } + catch (Exception error) + { + FailDevRenderExport("export_failed", error.Message); + } + } + + private void BeginNextDevRenderExportStage() + { + var stage = _pendingDevRenderExport.Request.Stages[_pendingDevRenderExport.NextStageIndex]; + _pendingDevRenderExport.NextStageIndex++; + _renderEffectsRuntime.SetDebugView(stage); + _pendingDevRenderExport.ActiveStage = stage; + _pendingDevRenderExport.SettleFramesRemaining = DevRenderExportSettleFrames; + } + + private void CaptureActiveDevRenderExportStage() + { + var stage = _pendingDevRenderExport.ActiveStage; + var outputDir = _pendingDevRenderExport.Request.OutputDir; + Directory.CreateDirectory(outputDir); + + var image = GetViewport().GetTexture().GetImage(); + var path = Path.Combine(outputDir, $"{stage}.png"); + var saveError = image.SavePng(path); + if (saveError != Error.Ok) + { + throw new InvalidOperationException($"Godot failed to save render stage {stage}: {saveError}."); + } + + _pendingDevRenderExport.ExportedFiles.Add(new StageDevObservationExportedFile( + stage, + path, + image.GetWidth(), + image.GetHeight() + )); + _pendingDevRenderExport.ActiveStage = null; + } + + private void CompleteDevRenderExport() + { + var export = _pendingDevRenderExport; + _pendingDevRenderExport = null; + RestoreRenderDebugView(export.RestoreDebugView); + _devObservationAdapter?.SendRenderExportResponse(new StageDevObservationRenderExportResult( + export.Request.RequestId, + export.Request.OutputDir, + export.ExportedFiles.ToArray(), + BuildDevObservationContext() + )); + } + + private StageDevObservationContext BuildDevObservationContext() + { + return new StageDevObservationContext( + _viewRuntime?.CreateSnapshot("dev-render-observation"), + _activeScenePayload, + _renderEffectsRuntime?.CurrentDebugView, + DateTimeOffset.UtcNow.ToString("O") + ); + } + + private void FailDevRenderExport(string code, string message) + { + var export = _pendingDevRenderExport; + _pendingDevRenderExport = null; + if (export != null) + { + RestoreRenderDebugView(export.RestoreDebugView); + _devObservationAdapter?.SendRenderExportError(export.Request.RequestId, code, message); + } + } + + private void RestoreRenderDebugView(string debugView) + { + try + { + _renderEffectsRuntime?.SetDebugView(debugView); + } + catch (Exception error) + { + GD.PushWarning($"Failed to restore render debug view {debugView}: {error.Message}"); + } + } + private void SendSceneError(string message) { _bridge.SendEnvelope("scene.error", new @@ -320,4 +667,62 @@ public partial class StageRoot : Node3D message, }); } + + private void SendViewCaptureError(string message, string requestId = null) + { + _bridge.SendEnvelope("stage.view.capture_error", new StageViewErrorPayload( + "view-capture-failed", + message, + requestId + )); + } + + private void SendRenderDebugViewError(string message, string requestId = null) + { + _bridge.SendEnvelope("stage.render.debug_view_error", new StageViewErrorPayload( + "render-debug-view-failed", + message, + requestId + )); + } + + private void SendAvatarEdgeLightError(string message, string requestId = null) + { + _bridge.SendEnvelope("stage.render.avatar_edge_light_error", new StageViewErrorPayload( + "avatar-edge-light-failed", + message, + requestId + )); + } + + private sealed class DevRenderExportRun + { + public DevRenderExportRun(StageDevObservationRenderExportRequest request, string restoreDebugView) + { + Request = request; + RestoreDebugView = restoreDebugView; + } + + public StageDevObservationRenderExportRequest Request + { + get; + } + public string RestoreDebugView + { + get; + } + public int NextStageIndex + { + get; set; + } + public string ActiveStage + { + get; set; + } + public int SettleFramesRemaining + { + get; set; + } + public List ExportedFiles { get; } = new(); + } } diff --git a/engines/stage-tamagotchi-godot/scripts/dev/StageDevObservationAdapter.cs b/engines/stage-tamagotchi-godot/scripts/dev/StageDevObservationAdapter.cs new file mode 100644 index 000000000..1136d976b --- /dev/null +++ b/engines/stage-tamagotchi-godot/scripts/dev/StageDevObservationAdapter.cs @@ -0,0 +1,463 @@ +using System; +using System.Diagnostics; +using System.IO; +using System.Text.Json; +using Godot; + +/// +/// Dev-only localhost adapter for AUV observation tools. +/// +public sealed class StageDevObservationAdapter : IDisposable +{ + private const string EnableEnvironmentVariable = "AIRI_GODOT_STAGE_DEV_MODE"; + private const string Transport = "websocket-json"; + private const string CapabilityQueryMessageType = "capability.query"; + private const string RenderExportStagesMessageType = "render.export_stages"; + private const int FallbackPortStart = 43170; + private const int FallbackPortEnd = 43199; + + private readonly JsonSerializerOptions _jsonOptions; + private readonly string[] _renderStages; + private readonly int _processId; + private readonly string _projectPath; + private readonly string _token; + + private TcpServer _server; + private WebSocketPeer _peer; + private string _instancePath; + private bool _disposed; + + public bool AiriBridgeConnected + { + get; + set; + } + + public event Action RenderExportRequested; + + public StageDevObservationAdapter( + JsonSerializerOptions jsonOptions, + string projectPath, + string[] renderStages + ) + { + _jsonOptions = jsonOptions; + _projectPath = projectPath; + _renderStages = renderStages; + _processId = Process.GetCurrentProcess().Id; + _token = Guid.NewGuid().ToString("N"); + } + + public static bool IsEnabled() + { + var rawValue = System.Environment.GetEnvironmentVariable(EnableEnvironmentVariable); + if (string.IsNullOrWhiteSpace(rawValue)) + { + return false; + } + + var normalizedValue = rawValue.Trim().ToLowerInvariant(); + return normalizedValue == "1" + || normalizedValue == "true" + || normalizedValue == "yes" + || normalizedValue == "on"; + } + + public void Start() + { + if (_disposed || _server != null) + { + return; + } + + _server = new TcpServer(); + var listenError = _server.Listen(0, "127.0.0.1"); + if (listenError != Error.Ok || _server.GetLocalPort() <= 0) + { + _server.Dispose(); + _server = new TcpServer(); + listenError = ListenOnFallbackPort(); + } + + if (listenError != Error.Ok) + { + throw new InvalidOperationException($"Failed to start Godot dev observation adapter: {listenError}."); + } + + WriteDiscoveryRecord(_server.GetLocalPort()); + GD.Print($"AIRI Godot dev observation adapter listening on 127.0.0.1:{_server.GetLocalPort()}."); + } + + public void Poll() + { + if (_disposed || _server == null) + { + return; + } + + AcceptPendingConnection(); + PollPeer(); + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + CleanupDiscoveryRecord(); + + if (_peer != null) + { + _peer.Close(); + _peer.Dispose(); + _peer = null; + } + + if (_server != null) + { + _server.Dispose(); + _server = null; + } + } + + private Error ListenOnFallbackPort() + { + for (var port = FallbackPortStart; port <= FallbackPortEnd; port++) + { + var listenError = _server.Listen((ushort)port, "127.0.0.1"); + if (listenError == Error.Ok) + { + return Error.Ok; + } + } + + return Error.CantCreate; + } + + private void AcceptPendingConnection() + { + if (!_server.IsConnectionAvailable()) + { + return; + } + + var stream = _server.TakeConnection(); + var nextPeer = new WebSocketPeer(); + var acceptError = nextPeer.AcceptStream(stream); + if (acceptError != Error.Ok) + { + GD.PushWarning($"AIRI Godot dev observation adapter rejected connection: {acceptError}."); + nextPeer.Dispose(); + stream.Dispose(); + return; + } + + if (_peer != null) + { + _peer.Close(); + _peer.Dispose(); + } + + _peer = nextPeer; + } + + private void PollPeer() + { + if (_peer == null) + { + return; + } + + _peer.Poll(); + switch (_peer.GetReadyState()) + { + case WebSocketPeer.State.Open: + DrainPeerMessages(); + break; + case WebSocketPeer.State.Closed: + _peer.Dispose(); + _peer = null; + break; + } + } + + private void DrainPeerMessages() + { + while (_peer.GetAvailablePacketCount() > 0) + { + var rawMessage = _peer.GetPacket().GetStringFromUtf8(); + HandleMessage(rawMessage); + } + } + + private void HandleMessage(string rawMessage) + { + StageDevObservationRequest request = null; + try + { + request = JsonSerializer.Deserialize(rawMessage, _jsonOptions); + if (request == null || string.IsNullOrWhiteSpace(request.Type)) + { + SendError(null, "invalid_request", "Request must include a type."); + return; + } + + if (!string.Equals(request.Token, _token, StringComparison.Ordinal)) + { + SendError(request.RequestId, "unauthorized", "Request token does not match this Godot dev instance."); + return; + } + + switch (request.Type) + { + case CapabilityQueryMessageType: + SendCapabilityResponse(request.RequestId); + break; + case RenderExportStagesMessageType: + QueueRenderExport(request); + break; + default: + SendError(request.RequestId, "unknown_message_type", $"Unsupported message type: {request.Type}."); + break; + } + } + catch (Exception error) + { + SendError(request?.RequestId, "invalid_json", error.Message); + } + } + + private void SendCapabilityResponse(string requestId) + { + SendMessage(new + { + type = "capability.query.response", + requestId, + status = "ok", + result = new + { + transport = Transport, + features = new[] + { + CapabilityQueryMessageType, + RenderExportStagesMessageType, + }, + renderStages = _renderStages, + cameraPresets = Array.Empty(), + process = new + { + pid = _processId, + projectPath = _projectPath, + airiBridgeConnected = AiriBridgeConnected, + }, + }, + }); + } + + private void QueueRenderExport(StageDevObservationRequest request) + { + if (RenderExportRequested == null) + { + SendError(request.RequestId, "not_ready", "No render export handler is available."); + return; + } + + StageDevObservationRenderExportPayload payload; + try + { + payload = request.Payload.Deserialize(_jsonOptions); + } + catch (Exception error) + { + SendError(request.RequestId, "invalid_payload", error.Message); + return; + } + + if (payload == null || string.IsNullOrWhiteSpace(payload.OutputDir)) + { + SendError(request.RequestId, "invalid_payload", "Render export payload must include outputDir."); + return; + } + + if (payload.Stages == null || payload.Stages.Length == 0) + { + SendError(request.RequestId, "invalid_payload", "Render export payload must include at least one stage."); + return; + } + + RenderExportRequested.Invoke(new StageDevObservationRenderExportRequest( + request.RequestId, + payload.OutputDir, + payload.Stages + )); + } + + public void SendRenderExportResponse(StageDevObservationRenderExportResult result) + { + SendMessage(new + { + type = "render.export_stages.response", + requestId = result.RequestId, + status = "ok", + result = new + { + outputDir = result.OutputDir, + exportedFiles = result.ExportedFiles, + context = result.Context, + }, + }); + } + + public void SendRenderExportError(string requestId, string code, string message) + { + SendMessage(new + { + type = "render.export_stages.response", + requestId, + status = "error", + error = new + { + code, + message, + }, + }); + } + + private void SendError(string requestId, string code, string message) + { + SendMessage(new + { + type = "error", + requestId, + status = "error", + error = new + { + code, + message, + }, + }); + } + + private void SendMessage(object message) + { + if (_peer?.GetReadyState() != WebSocketPeer.State.Open) + { + return; + } + + _peer.SendText(JsonSerializer.Serialize(message, _jsonOptions)); + } + + private void WriteDiscoveryRecord(int port) + { + var discoveryRoot = ResolveDiscoveryRoot(); + var instancesDirectory = Path.Combine(discoveryRoot, "instances"); + Directory.CreateDirectory(instancesDirectory); + + _instancePath = Path.Combine(instancesDirectory, $"{_processId}.json"); + var startedAt = DateTimeOffset.UtcNow.ToString("O"); + var instanceRecord = new + { + schemaVersion = 1, + kind = "airi-godot-stage-dev-observation-instance", + pid = _processId, + projectPath = _projectPath, + transport = Transport, + endpoint = $"127.0.0.1:{port}", + token = _token, + startedAt, + }; + File.WriteAllText(_instancePath, JsonSerializer.Serialize(instanceRecord, _jsonOptions)); + + var currentRecord = new + { + schemaVersion = 1, + kind = "airi-godot-stage-dev-observation-current", + pid = _processId, + projectPath = _projectPath, + instancePath = _instancePath, + updatedAt = startedAt, + }; + File.WriteAllText( + Path.Combine(discoveryRoot, "current.json"), + JsonSerializer.Serialize(currentRecord, _jsonOptions) + ); + } + + private void CleanupDiscoveryRecord() + { + if (string.IsNullOrWhiteSpace(_instancePath)) + { + return; + } + + try + { + if (File.Exists(_instancePath)) + { + File.Delete(_instancePath); + } + + var currentPath = Path.Combine(ResolveDiscoveryRoot(), "current.json"); + if (IsCurrentDiscoveryRecord(currentPath)) + { + File.Delete(currentPath); + } + } + catch (Exception error) + { + GD.PushWarning($"Failed to clean AIRI Godot dev observation discovery record: {error.Message}"); + } + } + + private bool IsCurrentDiscoveryRecord(string currentPath) + { + if (!File.Exists(currentPath)) + { + return false; + } + + var currentRecord = JsonSerializer.Deserialize( + File.ReadAllText(currentPath), + _jsonOptions + ); + + return string.Equals(currentRecord?.InstancePath, _instancePath, StringComparison.Ordinal); + } + + private static string ResolveDiscoveryRoot() + { + var userProfile = System.Environment.GetFolderPath(System.Environment.SpecialFolder.UserProfile); + if (string.IsNullOrWhiteSpace(userProfile)) + { + userProfile = OS.GetUserDataDir(); + } + + return Path.Combine(userProfile, ".airi", "godot-stage", "dev"); + } + + private sealed record StageDevObservationRequest(string Type, string RequestId, string Token, JsonElement Payload); + private sealed record StageDevObservationCurrentRecord(string InstancePath); + private sealed record StageDevObservationRenderExportPayload(string OutputDir, string[] Stages); +} + +public sealed record StageDevObservationRenderExportRequest(string RequestId, string OutputDir, string[] Stages); + +public sealed record StageDevObservationRenderExportResult( + string RequestId, + string OutputDir, + StageDevObservationExportedFile[] ExportedFiles, + StageDevObservationContext Context +); + +public sealed record StageDevObservationExportedFile(string Stage, string Path, int Width, int Height); + +public sealed record StageDevObservationContext( + StageViewSnapshotPayload ViewSnapshot, + StageSceneApplyPayload Scene, + string RenderDebugView, + string CapturedAt +); diff --git a/engines/stage-tamagotchi-godot/scripts/transport/StageViewJson.cs b/engines/stage-tamagotchi-godot/scripts/transport/StageViewJson.cs index 071e7c44b..35934df35 100644 --- a/engines/stage-tamagotchi-godot/scripts/transport/StageViewJson.cs +++ b/engines/stage-tamagotchi-godot/scripts/transport/StageViewJson.cs @@ -83,6 +83,125 @@ public static class StageViewJson return new StageViewSnapshotRequestPayload(requestId); } + public static StageViewCapturePngRequestPayload ParseCapturePngRequest(JsonElement payload) + { + ExpectObject(payload, "view PNG capture request"); + + string requestId = null; + string path = null; + var settleFrames = 1; + foreach (var property in payload.EnumerateObject()) + { + switch (property.Name) + { + case "requestId": + requestId = ReadRequiredRequestId(property.Value, "View PNG capture requestId"); + break; + case "path": + path = ReadRequiredString(property.Value, "View PNG capture path"); + break; + case "settleFrames": + settleFrames = ReadNonNegativeInt32( + property.Value, + "View PNG capture settleFrames" + ); + break; + default: + throw Invalid($"Unknown view PNG capture request field: {property.Name}."); + } + } + + if (requestId == null) + { + throw Invalid("View PNG capture requestId is required."); + } + + if (path == null) + { + throw Invalid("View PNG capture path is required."); + } + + return new StageViewCapturePngRequestPayload(requestId, path, settleFrames); + } + + public static StageRenderDebugViewRequestPayload ParseRenderDebugViewRequest( + JsonElement payload + ) + { + ExpectObject(payload, "render debug view request"); + + string requestId = null; + string view = null; + foreach (var property in payload.EnumerateObject()) + { + switch (property.Name) + { + case "requestId": + requestId = ReadRequiredRequestId( + property.Value, + "Render debug view requestId" + ); + break; + case "view": + view = ReadRequiredString(property.Value, "Render debug view"); + break; + default: + throw Invalid($"Unknown render debug view request field: {property.Name}."); + } + } + + if (requestId == null) + { + throw Invalid("Render debug view requestId is required."); + } + + if (view == null) + { + throw Invalid("Render debug view is required."); + } + + return new StageRenderDebugViewRequestPayload(requestId, view); + } + + public static StageRenderAvatarEdgeLightRequestPayload ParseRenderAvatarEdgeLightRequest( + JsonElement payload + ) + { + ExpectObject(payload, "render avatar edge-light request"); + + string requestId = null; + bool? enabled = null; + foreach (var property in payload.EnumerateObject()) + { + switch (property.Name) + { + case "requestId": + requestId = ReadRequiredRequestId( + property.Value, + "Render avatar edge-light requestId" + ); + break; + case "enabled": + enabled = ReadBoolean(property.Value, "Render avatar edge-light enabled"); + break; + default: + throw Invalid($"Unknown render avatar edge-light request field: {property.Name}."); + } + } + + if (requestId == null) + { + throw Invalid("Render avatar edge-light requestId is required."); + } + + if (enabled == null) + { + throw Invalid("Render avatar edge-light enabled is required."); + } + + return new StageRenderAvatarEdgeLightRequestPayload(requestId, enabled.Value); + } + public static StageViewState ParseState(JsonElement payload) { ExpectObject(payload, "view state"); @@ -282,6 +401,12 @@ public static class StageViewJson } private static string ReadRequiredRequestId(JsonElement payload, string field) + { + var value = ReadRequiredString(payload, field); + return value; + } + + private static string ReadRequiredString(JsonElement payload, string field) { var value = ReadString(payload, field); if (string.IsNullOrWhiteSpace(value)) @@ -312,6 +437,27 @@ public static class StageViewJson return value; } + private static int ReadNonNegativeInt32(JsonElement payload, string field) + { + var value = ReadInt32(payload, field); + if (value < 0) + { + throw Invalid($"Expected {field} to be non-negative."); + } + + return value; + } + + private static bool ReadBoolean(JsonElement payload, string field) + { + if (payload.ValueKind != JsonValueKind.True && payload.ValueKind != JsonValueKind.False) + { + throw Invalid($"Expected {field} to be a boolean."); + } + + return payload.GetBoolean(); + } + private static long ReadInt64(JsonElement payload, string field) { if (payload.ValueKind != JsonValueKind.Number || !payload.TryGetInt64(out var value)) diff --git a/engines/stage-tamagotchi-godot/scripts/transport/StageViewPayloads.cs b/engines/stage-tamagotchi-godot/scripts/transport/StageViewPayloads.cs index 23cc5f87e..b9935fa80 100644 --- a/engines/stage-tamagotchi-godot/scripts/transport/StageViewPayloads.cs +++ b/engines/stage-tamagotchi-godot/scripts/transport/StageViewPayloads.cs @@ -71,6 +71,25 @@ public sealed record StageViewPatchRequestPayload(string RequestId, StageViewPat /// public sealed record StageViewSnapshotRequestPayload(string RequestId); +/// +/// Host-origin diagnostic request to save the current stage viewport as a PNG. +/// +public sealed record StageViewCapturePngRequestPayload( + string RequestId, + string Path, + int SettleFrames +); + +/// +/// Host-origin request to display a render pipeline diagnostic stage in the final window. +/// +public sealed record StageRenderDebugViewRequestPayload(string RequestId, string View); + +/// +/// Host-origin diagnostic request to enable or bypass the avatar edge-light stage. +/// +public sealed record StageRenderAvatarEdgeLightRequestPayload(string RequestId, bool Enabled); + /// /// Snapshot emitted by Godot after load, mutation, local input, or request. /// @@ -89,3 +108,23 @@ public sealed record StageViewErrorPayload( string Message, string RequestId = null ); + +/// +/// Diagnostic PNG capture emitted after Godot writes the current viewport image. +/// +public sealed record StageViewCapturePngPayload( + string RequestId, + string Path, + int Width, + int Height +); + +/// +/// Acknowledgement emitted after Godot applies a render pipeline diagnostic stage. +/// +public sealed record StageRenderDebugViewPayload(string RequestId, string View); + +/// +/// Acknowledgement emitted after Godot applies the avatar edge-light diagnostic toggle. +/// +public sealed record StageRenderAvatarEdgeLightPayload(string RequestId, bool Enabled); diff --git a/engines/stage-tamagotchi-godot/scripts/view/StageViewRuntime.cs b/engines/stage-tamagotchi-godot/scripts/view/StageViewRuntime.cs index ab5fe336f..66d01e5b5 100644 --- a/engines/stage-tamagotchi-godot/scripts/view/StageViewRuntime.cs +++ b/engines/stage-tamagotchi-godot/scripts/view/StageViewRuntime.cs @@ -98,6 +98,21 @@ public sealed class StageViewRuntime EmitSnapshot("loaded"); } + public StageViewSnapshotPayload CreateSnapshot(string reason, string requestId = null) + { + if (!_hasViewState) + { + return null; + } + + return new StageViewSnapshotPayload( + _state, + reason, + requestId, + _controller.ResolveAvatarBounds() + ); + } + public void EmitInvalidPayload(string message, string requestId = null) { EmitError("invalid-payload", message, requestId); @@ -155,12 +170,11 @@ public sealed class StageViewRuntime private void EmitSnapshot(string reason, string requestId = null) { - SnapshotReady?.Invoke(new StageViewSnapshotPayload( - _state, - reason, - requestId, - _controller.ResolveAvatarBounds() - )); + var snapshot = CreateSnapshot(reason, requestId); + if (snapshot != null) + { + SnapshotReady?.Invoke(snapshot); + } } private void EmitError(string code, string message, string requestId = null) diff --git a/engines/stage-tamagotchi-godot/scripts/visuals/StageAvatarGlowCompositorEffect.cs b/engines/stage-tamagotchi-godot/scripts/visuals/StageAvatarGlowCompositorEffect.cs deleted file mode 100644 index 60772ac7a..000000000 --- a/engines/stage-tamagotchi-godot/scripts/visuals/StageAvatarGlowCompositorEffect.cs +++ /dev/null @@ -1,1384 +0,0 @@ -using System; -using System.Collections.Generic; -using Godot; - -/// -/// Runs same-frame avatar glare and toon color mapping for meshes marked by stencil. -/// -public partial class StageAvatarGlowCompositorEffect : CompositorEffect -{ - private const int MaxBloomLevels = 9; - private const int BloomQualityFactor = 2; - - private static readonly GlowSettings Glow = new( - BloomTint: new Color(1.0f, 0.6938719749450684f, 0.6795425415039062f), - BloomStrength: 0.09f, - BloomSize: 0.26f, - HighlightThreshold: 0.56f, - HighlightSmoothness: 0.5f, - MaxHighlightBrightness: 1.0e20f - ); - - private static readonly NaesTonemapSettings NaesTonemap = new( - A: 1.36f, - B: 0.047f, - C: 0.93f, - D: 0.56f, - E: 0.14f, - InputMax: 10.0f - ); - - private static readonly ToonColorGradeSettings ToonColorGrade = new( - LumaRiseStart: 0.42f, - LumaRiseEnd: 0.58f, - LumaFallStart: 0.66f, - LumaFallEnd: 0.78f, - LumaMidDip: 0.050f, - VibranceLumaStart: 0.24f, - VibranceLumaEnd: 0.50f, - VibranceSaturationStart: 0.10f, - VibranceSaturationEnd: 0.70f, - ChromaBase: 1.05f, - ChromaBoost: 0.47f - ); - - private const string FullscreenVertexShaderCode = """ - #version 450 - - layout(location = 0) in vec2 position; - layout(location = 0) out vec2 uv; - - void main() - { - uv = position * 0.5 + vec2(0.5); - gl_Position = vec4(position, 0.0, 1.0); - } - """; - - private const string CopySceneFragmentShaderCode = """ - #version 450 - - layout(set = 0, binding = 0) uniform sampler2D scene_texture; - - layout(location = 0) in vec2 uv; - layout(location = 0) out vec4 out_color; - - void main() - { - out_color = texture(scene_texture, uv); - } - """; - - private const string ExtractHighlightsFragmentShaderCode = """ - #version 450 - - layout(set = 0, binding = 0) uniform sampler2D input_texture; - layout(push_constant, std430) uniform Params - { - vec4 values; - } params; - - layout(location = 0) in vec2 uv; - layout(location = 0) out vec4 out_color; - - float max_channel_of(vec3 color) - { - return max(max(color.r, color.g), color.b); - } - - float smooth_min(float a, float b, float smoothness) - { - if (smoothness == 0.0) - { - return min(a, b); - } - - float h = max(smoothness - abs(a - b), 0.0) / smoothness; - return min(a, b) - h * h * smoothness * 0.25; - } - - float smooth_max(float a, float b, float smoothness) - { - return -smooth_min(-a, -b, smoothness); - } - - float smooth_clamp( - float value, - float min_value, - float max_value, - float min_smoothness, - float max_smoothness - ) - { - return smooth_min( - max_value, - smooth_max(min_value, value, min_smoothness), - max_smoothness - ); - } - - float adaptive_smooth_clamp( - float value, - float min_value, - float max_value, - float smoothness - ) - { - float range_distance = abs(max_value - min_value); - float min_smoothness = min(smoothness, min(min_value, range_distance)); - float max_smoothness = min(smoothness, min(max_value, range_distance)); - return smooth_clamp(value, min_value, max_value, min_smoothness, max_smoothness); - } - - void main() - { - vec3 color = texture(input_texture, uv).rgb; - float threshold = params.values.x; - float smoothness = params.values.y; - float max_brightness = params.values.z; - - float value = max_channel_of(color); - float clamped_value = adaptive_smooth_clamp( - value, - threshold, - threshold + max_brightness, - smoothness - ); - float extracted_value = max(clamped_value - threshold, 0.0); - float source = extracted_value / max(value, 0.001); - out_color = vec4(color * source, 1.0); - } - """; - - private const string DownsampleFragmentShaderCode = """ - #version 450 - - layout(set = 0, binding = 0) uniform sampler2D input_texture; - layout(push_constant, std430) uniform Params - { - vec4 values; - } params; - - layout(location = 0) in vec2 uv; - layout(location = 0) out vec4 out_color; - - float reduce_max(vec4 color) - { - return max(max(max(color.r, color.g), color.b), color.a); - } - - vec4 weighted_sum(vec4 a, vec4 b, vec4 c, vec4 d, vec4 weights) - { - float total_weight = weights.x + weights.y + weights.z + weights.w; - return (a * weights.x + b * weights.y + c * weights.z + d * weights.w) / - max(total_weight, 0.0001); - } - - vec4 karis_brightness_weighted_sum(vec4 a, vec4 b, vec4 c, vec4 d) - { - vec4 brightness = vec4(reduce_max(a), reduce_max(b), reduce_max(c), reduce_max(d)); - vec4 weights = vec4(1.0) / (brightness + vec4(1.0)); - return weighted_sum(a, b, c, d, weights); - } - - void main() - { - vec2 pixel_size = params.values.xy; - float use_karis_average = params.values.z; - - vec4 center = texture(input_texture, uv); - vec4 upper_left_near = texture(input_texture, uv + pixel_size * vec2(-1.0, 1.0)); - vec4 upper_right_near = texture(input_texture, uv + pixel_size * vec2(1.0, 1.0)); - vec4 lower_left_near = texture(input_texture, uv + pixel_size * vec2(-1.0, -1.0)); - vec4 lower_right_near = texture(input_texture, uv + pixel_size * vec2(1.0, -1.0)); - vec4 left_far = texture(input_texture, uv + pixel_size * vec2(-2.0, 0.0)); - vec4 right_far = texture(input_texture, uv + pixel_size * vec2(2.0, 0.0)); - vec4 upper_far = texture(input_texture, uv + pixel_size * vec2(0.0, 2.0)); - vec4 lower_far = texture(input_texture, uv + pixel_size * vec2(0.0, -2.0)); - vec4 upper_left_far = texture(input_texture, uv + pixel_size * vec2(-2.0, 2.0)); - vec4 upper_right_far = texture(input_texture, uv + pixel_size * vec2(2.0, 2.0)); - vec4 lower_left_far = texture(input_texture, uv + pixel_size * vec2(-2.0, -2.0)); - vec4 lower_right_far = texture(input_texture, uv + pixel_size * vec2(2.0, -2.0)); - - vec4 result; - if (use_karis_average > 0.5) - { - vec4 center_weighted_sum = karis_brightness_weighted_sum( - upper_left_near, - upper_right_near, - lower_right_near, - lower_left_near - ); - vec4 upper_left_weighted_sum = karis_brightness_weighted_sum( - upper_left_far, - upper_far, - center, - left_far - ); - vec4 upper_right_weighted_sum = karis_brightness_weighted_sum( - upper_far, - upper_right_far, - right_far, - center - ); - vec4 lower_right_weighted_sum = karis_brightness_weighted_sum( - center, - right_far, - lower_right_far, - lower_far - ); - vec4 lower_left_weighted_sum = karis_brightness_weighted_sum( - left_far, - center, - lower_far, - lower_left_far - ); - - result = center_weighted_sum * (4.0 / 8.0) + - ( - upper_left_weighted_sum + - upper_right_weighted_sum + - lower_left_weighted_sum + - lower_right_weighted_sum - ) * (1.0 / 8.0); - } - else - { - result = center * (4.0 / 32.0) + - ( - upper_left_near + - upper_right_near + - lower_left_near + - lower_right_near - ) * (4.0 / 32.0) + - (left_far + right_far + upper_far + lower_far) * (2.0 / 32.0) + - ( - upper_left_far + - upper_right_far + - lower_left_far + - lower_right_far - ) * (1.0 / 32.0); - } - - out_color = vec4(result.rgb, 1.0); - } - """; - - private const string UpsampleFragmentShaderCode = """ - #version 450 - - layout(set = 0, binding = 0) uniform sampler2D base_texture; - layout(set = 0, binding = 1) uniform sampler2D input_texture; - layout(push_constant, std430) uniform Params - { - vec4 values; - } params; - - layout(location = 0) in vec2 uv; - layout(location = 0) out vec4 out_color; - - void main() - { - vec2 pixel_size = params.values.xy; - vec4 upsampled = vec4(0.0); - upsampled += texture(input_texture, uv) * (4.0 / 16.0); - upsampled += texture(input_texture, uv + pixel_size * vec2(-1.0, 0.0)) * (2.0 / 16.0); - upsampled += texture(input_texture, uv + pixel_size * vec2(0.0, 1.0)) * (2.0 / 16.0); - upsampled += texture(input_texture, uv + pixel_size * vec2(1.0, 0.0)) * (2.0 / 16.0); - upsampled += texture(input_texture, uv + pixel_size * vec2(0.0, -1.0)) * (2.0 / 16.0); - upsampled += texture(input_texture, uv + pixel_size * vec2(-1.0, -1.0)) * (1.0 / 16.0); - upsampled += texture(input_texture, uv + pixel_size * vec2(-1.0, 1.0)) * (1.0 / 16.0); - upsampled += texture(input_texture, uv + pixel_size * vec2(1.0, -1.0)) * (1.0 / 16.0); - upsampled += texture(input_texture, uv + pixel_size * vec2(1.0, 1.0)) * (1.0 / 16.0); - - vec3 base = texture(base_texture, uv).rgb; - out_color = vec4(base + upsampled.rgb, 1.0); - } - """; - - private const string CompositeFragmentShaderCode = """ - #version 450 - - layout(set = 0, binding = 0) uniform sampler2D scene_texture; - layout(set = 0, binding = 1) uniform sampler2D bloom_texture; - layout(push_constant, std430) uniform Params - { - vec4 bloom_tint_strength; - vec4 naes_curve0; - vec4 naes_curve1; - vec4 luma_curve; - vec4 color_grade0; - vec4 color_grade1; - } params; - - layout(location = 0) in vec2 uv; - layout(location = 0) out vec4 out_color; - - vec3 clamp_tonemap_input(vec3 color) - { - return clamp(max(color, vec3(0.0)), 0.0, params.naes_curve1.y); - } - - vec3 naes_tonemap(vec3 color) - { - vec3 x = clamp_tonemap_input(color); - return (x * (params.naes_curve0.x * x + vec3(params.naes_curve0.y))) / - ( - x * (params.naes_curve0.z * x + vec3(params.naes_curve0.w)) + - vec3(params.naes_curve1.x) - ); - } - - float apply_luma_curve(float luma) - { - float mid_gate = - smoothstep(params.luma_curve.x, params.luma_curve.y, luma) * - (1.0 - smoothstep(params.luma_curve.z, params.luma_curve.w, luma)); - return max(luma * (1.0 - params.color_grade0.x * mid_gate), 0.0); - } - - vec3 apply_toon_color_grade(vec3 color) - { - float luma = dot(color, vec3(0.2126, 0.7152, 0.0722)); - float max_channel = max(max(color.r, color.g), color.b); - float min_channel = min(min(color.r, color.g), color.b); - float saturation = max_channel <= 0.001 - ? 0.0 - : (max_channel - min_channel) / max_channel; - float luma_gate = smoothstep(params.color_grade0.y, params.color_grade0.z, luma); - float saturation_gate = - 1.0 - smoothstep(params.color_grade0.w, params.color_grade1.x, saturation); - float chroma_scale = - params.color_grade1.y + params.color_grade1.z * luma_gate * saturation_gate; - float luma2 = apply_luma_curve(luma); - vec3 gray = vec3(luma); - - return max(vec3(luma2) + (color - gray) * chroma_scale, vec3(0.0)); - } - - void main() - { - vec4 scene = texture(scene_texture, uv); - vec3 bloom = texture(bloom_texture, uv).rgb; - vec3 hdr = max( - scene.rgb + bloom * params.bloom_tint_strength.rgb * params.bloom_tint_strength.a, - vec3(0.0) - ); - vec3 mapped = apply_toon_color_grade(naes_tonemap(hdr)); - out_color = vec4(mapped, scene.a); - } - """; - - private static readonly Color[] ClearColor = { new(0.0f, 0.0f, 0.0f, 0.0f) }; - - private readonly uint _stencilReference; - - private RenderingDevice _renderingDevice; - private Rid _sampler; - private long _fullscreenVertexFormat = RenderingDevice.InvalidId; - private Rid _fullscreenVertexBuffer; - private Rid _fullscreenVertexArray; - private Rid _copyShader; - private Rid _extractShader; - private Rid _downsampleShader; - private Rid _upsampleShader; - private Rid _compositeShader; - private BloomResources _resources; - private bool _missingRenderingDeviceWarningPrinted; - private bool _missingRenderBuffersWarningPrinted; - private bool _missingStencilWarningPrinted; - private bool _sourceResolveWarningPrinted; - - public StageAvatarGlowCompositorEffect(int stencilReference) - { - _stencilReference = (uint)stencilReference; - AccessResolvedColor = true; - AccessResolvedDepth = false; - EffectCallbackType = EffectCallbackTypeEnum.PostTransparent; - Enabled = false; - } - - public override void _RenderCallback(int effectCallbackType, RenderData renderData) - { - if (!Enabled || (EffectCallbackTypeEnum)effectCallbackType != EffectCallbackType) - { - return; - } - - if (!EnsureRenderingDevice()) - { - return; - } - - if (renderData.GetRenderSceneBuffers() is not RenderSceneBuffersRD buffers) - { - WarnOnce( - ref _missingRenderBuffersWarningPrinted, - "Avatar glow requires RenderSceneBuffersRD; compositor callback had no RD buffers." - ); - return; - } - - var fullSize = buffers.GetInternalSize(); - if (fullSize.X < 2 || fullSize.Y < 2) - { - return; - } - - var sceneColor = buffers.GetColorTexture(false); - var sceneDepth = SelectStencilDepthTexture(buffers); - if (!sceneColor.IsValid || !sceneDepth.IsValid) - { - return; - } - - var sceneDepthFormat = _renderingDevice.TextureGetFormat(sceneDepth).Format; - if (!HasStencil(sceneDepthFormat)) - { - WarnOnce( - ref _missingStencilWarningPrinted, - $"Avatar glow needs a stencil depth texture; scene depth format is {sceneDepthFormat}." - ); - return; - } - - if (!EnsureResources(fullSize, sceneColor, sceneDepth)) - { - return; - } - - DrawPass( - _resources.SourceFramebuffer, - _resources.ExtractPipeline, - _resources.SourceUniformSet, - _resources.ExtractPushConstants, - clearColor: true, - preserveDepthStencil: true - ); - if (_resources.SourceResolveRequired) - { - var resolveError = _renderingDevice.TextureResolveMultisample( - _resources.SourceRenderTexture, - _resources.SourceTexture - ); - if (resolveError != Error.Ok) - { - WarnOnce( - ref _sourceResolveWarningPrinted, - $"Avatar glow source MSAA resolve failed: {resolveError}." - ); - return; - } - } - - DrawPass( - _resources.SceneSourceFramebuffer, - _resources.SceneCopyPipeline, - _resources.SceneSourceUniformSet, - _resources.SceneCopyPushConstants, - clearColor: false, - preserveDepthStencil: false - ); - - for (int level = 0; level < _resources.BloomLevels; level++) - { - DrawPass( - _resources.DownsampleFramebuffers[level], - _resources.DownsamplePipeline, - _resources.DownsampleUniformSets[level], - _resources.DownsamplePushConstants[level], - clearColor: true, - preserveDepthStencil: false - ); - } - - for (int level = _resources.BloomLevels - 2; level >= 0; level--) - { - DrawPass( - _resources.UpsampleFramebuffers[level], - _resources.UpsamplePipeline, - _resources.UpsampleUniformSets[level], - _resources.UpsamplePushConstants[level], - clearColor: true, - preserveDepthStencil: false - ); - } - - DrawPass( - _resources.CompositeFramebuffer, - _resources.CompositePipeline, - _resources.CompositeUniformSet, - _resources.CompositePushConstants, - clearColor: false, - preserveDepthStencil: false - ); - } - - public override void _Notification(int what) - { - if (what != NotificationPredelete) - { - return; - } - - ReleaseRenderingResources(); - } - - public void ReleaseRenderingResources() - { - if (_renderingDevice != null && !RenderingServer.IsOnRenderThread()) - { - RenderingServer.CallOnRenderThread(Callable.From(ReleaseRenderingResourcesOnRenderThread)); - RenderingServer.ForceSync(); - return; - } - - ReleaseRenderingResourcesOnRenderThread(); - } - - private void ReleaseRenderingResourcesOnRenderThread() - { - ReleaseResources(); - FreeOwnedRid(ref _sampler); - FreeOwnedRid(ref _fullscreenVertexArray); - FreeOwnedRid(ref _fullscreenVertexBuffer); - FreeOwnedRid(ref _copyShader); - FreeOwnedRid(ref _extractShader); - FreeOwnedRid(ref _downsampleShader); - FreeOwnedRid(ref _upsampleShader); - FreeOwnedRid(ref _compositeShader); - _fullscreenVertexFormat = RenderingDevice.InvalidId; - _renderingDevice = null; - } - - private bool EnsureRenderingDevice() - { - if (_renderingDevice != null) - { - if (RenderingResourcesAreValid()) - { - return true; - } - - ReleaseRenderingResourcesOnRenderThread(); - } - - _renderingDevice = RenderingServer.GetRenderingDevice(); - if (_renderingDevice == null) - { - WarnOnce( - ref _missingRenderingDeviceWarningPrinted, - "Avatar glow requires the Forward+/Mobile rendering device." - ); - return false; - } - - _sampler = _renderingDevice.SamplerCreate(new RDSamplerState - { - MagFilter = RenderingDevice.SamplerFilter.Linear, - MinFilter = RenderingDevice.SamplerFilter.Linear, - MipFilter = RenderingDevice.SamplerFilter.Linear, - RepeatU = RenderingDevice.SamplerRepeatMode.ClampToEdge, - RepeatV = RenderingDevice.SamplerRepeatMode.ClampToEdge, - RepeatW = RenderingDevice.SamplerRepeatMode.ClampToEdge, - }); - _fullscreenVertexFormat = _renderingDevice.VertexFormatCreate( - new Godot.Collections.Array - { - new() - { - Binding = 0, - Format = RenderingDevice.DataFormat.R32G32Sfloat, - Frequency = RenderingDevice.VertexFrequency.Vertex, - Location = 0, - Offset = 0, - Stride = sizeof(float) * 2, - }, - } - ); - _fullscreenVertexBuffer = _renderingDevice.VertexBufferCreate( - sizeof(float) * 2 * 3, - CreateFullscreenTriangleVertexData(), - (RenderingDevice.BufferCreationBits)0 - ); - _fullscreenVertexArray = _renderingDevice.VertexArrayCreate( - 3, - _fullscreenVertexFormat, - new Godot.Collections.Array - { - _fullscreenVertexBuffer, - }, - new long[] { 0 } - ); - _copyShader = CompileShader("AIRI avatar glow scene copy", CopySceneFragmentShaderCode); - _extractShader = CompileShader("AIRI avatar glow extract", ExtractHighlightsFragmentShaderCode); - _downsampleShader = CompileShader("AIRI avatar glow downsample", DownsampleFragmentShaderCode); - _upsampleShader = CompileShader("AIRI avatar glow upsample", UpsampleFragmentShaderCode); - _compositeShader = CompileShader("AIRI avatar glow composite", CompositeFragmentShaderCode); - - if (RenderingResourcesAreValid()) - { - return true; - } - - ReleaseRenderingResourcesOnRenderThread(); - return false; - } - - private bool RenderingResourcesAreValid() => - _renderingDevice != null - && _sampler.IsValid - && _fullscreenVertexFormat != RenderingDevice.InvalidId - && _fullscreenVertexBuffer.IsValid - && _fullscreenVertexArray.IsValid - && _copyShader.IsValid - && _extractShader.IsValid - && _downsampleShader.IsValid - && _upsampleShader.IsValid - && _compositeShader.IsValid; - - private Rid SelectStencilDepthTexture(RenderSceneBuffersRD buffers) - { - var resolvedDepth = buffers.GetDepthTexture(false); - if (resolvedDepth.IsValid && HasStencil(_renderingDevice.TextureGetFormat(resolvedDepth).Format)) - { - return resolvedDepth; - } - - var msaaDepth = buffers.GetDepthTexture(true); - if (msaaDepth.IsValid && HasStencil(_renderingDevice.TextureGetFormat(msaaDepth).Format)) - { - return msaaDepth; - } - - return resolvedDepth; - } - - private bool EnsureResources( - Vector2I fullSize, - Rid sceneColor, - Rid sceneDepth - ) - { - if (_resources != null - && _resources.FullSize == fullSize - && _resources.SceneColor == sceneColor - && _resources.SceneDepth == sceneDepth - && _resources.IsValid) - { - return true; - } - - ReleaseResources(); - - var resources = new BloomResources - { - FullSize = fullSize, - SceneColor = sceneColor, - SceneDepth = sceneDepth, - BloomLevels = ComputeBloomLevels(fullSize), - }; - var sceneDepthTextureFormat = _renderingDevice.TextureGetFormat(sceneDepth); - var sourceSamples = sceneDepthTextureFormat.Samples; - resources.SourceTexture = CreateColorTexture( - fullSize, - RenderingDevice.TextureSamples.Samples1, - canResolveTo: sourceSamples != RenderingDevice.TextureSamples.Samples1 - ); - resources.SourceRenderTexture = sourceSamples == RenderingDevice.TextureSamples.Samples1 - ? resources.SourceTexture - : CreateColorTexture(fullSize, sourceSamples, canResolveFrom: true); - resources.SourceResolveRequired = resources.SourceRenderTexture != resources.SourceTexture; - resources.SourceFramebuffer = GetCachedFramebuffer(resources.SourceRenderTexture, sceneDepth); - resources.SourceUniformSet = GetCachedSamplerUniformSet(_extractShader, sceneColor); - resources.ExtractPipeline = CreatePipelineForFramebuffer( - _extractShader, - resources.SourceFramebuffer, - useStencil: true, - sampleCount: sourceSamples - ); - resources.ExtractPushConstants = PushConstants( - Glow.HighlightThreshold, - Glow.HighlightSmoothness, - Glow.MaxHighlightBrightness, - 0.0f - ); - - resources.SceneSourceTexture = CreateColorTexture(fullSize); - resources.SceneSourceFramebuffer = GetCachedFramebuffer(resources.SceneSourceTexture); - resources.SceneSourceUniformSet = GetCachedSamplerUniformSet(_copyShader, sceneColor); - resources.SceneCopyPipeline = CreatePipelineForFramebuffer( - _copyShader, - resources.SceneSourceFramebuffer, - useStencil: false, - sampleCount: RenderingDevice.TextureSamples.Samples1 - ); - resources.SceneCopyPushConstants = Array.Empty(); - - resources.DownsampleSizes = CreateDownsampleSizes(fullSize, resources.BloomLevels); - resources.DownsampleTextures = new Rid[resources.BloomLevels]; - resources.DownsampleFramebuffers = new Rid[resources.BloomLevels]; - resources.DownsampleUniformSets = new Rid[resources.BloomLevels]; - resources.DownsamplePushConstants = new byte[resources.BloomLevels][]; - - for (int level = 0; level < resources.BloomLevels; level++) - { - var outputSize = resources.DownsampleSizes[level]; - var inputSize = level == 0 ? fullSize : resources.DownsampleSizes[level - 1]; - var inputTexture = level == 0 - ? resources.SourceTexture - : resources.DownsampleTextures[level - 1]; - - resources.DownsampleTextures[level] = CreateColorTexture(outputSize); - resources.DownsampleFramebuffers[level] = GetCachedFramebuffer( - resources.DownsampleTextures[level] - ); - resources.DownsampleUniformSets[level] = GetCachedSamplerUniformSet( - _downsampleShader, - inputTexture - ); - resources.DownsamplePushConstants[level] = PushConstants( - 1.0f / Math.Max(1, inputSize.X), - 1.0f / Math.Max(1, inputSize.Y), - level == 0 ? 1.0f : 0.0f, - 0.0f - ); - } - - resources.UpsampleTextures = new Rid[resources.BloomLevels - 1]; - resources.UpsampleFramebuffers = new Rid[resources.BloomLevels - 1]; - resources.UpsampleUniformSets = new Rid[resources.BloomLevels - 1]; - resources.UpsamplePushConstants = new byte[resources.BloomLevels - 1][]; - - for (int level = resources.BloomLevels - 2; level >= 0; level--) - { - var outputSize = resources.DownsampleSizes[level]; - var baseTexture = resources.DownsampleTextures[level]; - var inputTexture = level == resources.BloomLevels - 2 - ? resources.DownsampleTextures[level + 1] - : resources.UpsampleTextures[level + 1]; - - resources.UpsampleTextures[level] = CreateColorTexture(outputSize); - resources.UpsampleFramebuffers[level] = GetCachedFramebuffer(resources.UpsampleTextures[level]); - resources.UpsampleUniformSets[level] = GetCachedSamplerUniformSet( - _upsampleShader, - baseTexture, - inputTexture - ); - resources.UpsamplePushConstants[level] = PushConstants( - 1.0f / Math.Max(1, outputSize.X), - 1.0f / Math.Max(1, outputSize.Y), - 0.0f, - 0.0f - ); - } - - resources.CompositeFramebuffer = GetCachedFramebuffer(sceneColor); - resources.CompositeUniformSet = GetCachedSamplerUniformSet( - _compositeShader, - resources.SceneSourceTexture, - resources.UpsampleTextures[0] - ); - resources.CompositePushConstants = CreateCompositePushConstants(); - - resources.DownsamplePipeline = CreatePipelineForFramebuffer( - _downsampleShader, - resources.DownsampleFramebuffers[0], - useStencil: false, - sampleCount: RenderingDevice.TextureSamples.Samples1 - ); - resources.UpsamplePipeline = CreatePipelineForFramebuffer( - _upsampleShader, - resources.UpsampleFramebuffers[0], - useStencil: false, - sampleCount: RenderingDevice.TextureSamples.Samples1 - ); - resources.CompositePipeline = CreatePipelineForFramebuffer( - _compositeShader, - resources.CompositeFramebuffer, - useStencil: false, - sampleCount: RenderingDevice.TextureSamples.Samples1 - ); - - if (resources.IsValid) - { - _resources = resources; - return true; - } - - _resources = resources; - ReleaseResources(); - return false; - } - - private Rid CompileShader(string name, string fragmentShaderCode) - { - var shaderSource = new RDShaderSource - { - Language = RenderingDevice.ShaderLanguage.Glsl, - SourceVertex = FullscreenVertexShaderCode, - SourceFragment = fragmentShaderCode, - }; - var spirv = _renderingDevice.ShaderCompileSpirVFromSource(shaderSource, true); - if (!string.IsNullOrWhiteSpace(spirv.CompileErrorVertex) - || !string.IsNullOrWhiteSpace(spirv.CompileErrorFragment)) - { - GD.PushError( - $"Failed to compile {name}: " + - $"{spirv.CompileErrorVertex} {spirv.CompileErrorFragment}" - ); - return new Rid(); - } - - return _renderingDevice.ShaderCreateFromSpirV(spirv, name); - } - - // TODO: When rim light, post-process outline, or other custom post effects land, - // split pass orchestration and shared transient render textures from this avatar-glow - // feature. Effect-owned textures are acceptable for this isolated glow pipeline, but - // multiple effects should share one viewport-level post-process resource context. - private Rid CreateColorTexture( - Vector2I size, - RenderingDevice.TextureSamples samples = RenderingDevice.TextureSamples.Samples1, - bool canResolveFrom = false, - bool canResolveTo = false - ) - { - var usageBits = - RenderingDevice.TextureUsageBits.SamplingBit | - RenderingDevice.TextureUsageBits.ColorAttachmentBit; - if (canResolveFrom) - { - usageBits |= RenderingDevice.TextureUsageBits.CanCopyFromBit; - } - - if (canResolveTo) - { - usageBits |= RenderingDevice.TextureUsageBits.CanCopyToBit; - } - - var textureFormat = new RDTextureFormat - { - Format = RenderingDevice.DataFormat.R16G16B16A16Sfloat, - Width = (uint)Math.Max(1, size.X), - Height = (uint)Math.Max(1, size.Y), - Depth = 1, - ArrayLayers = 1, - Mipmaps = 1, - Samples = samples, - TextureType = RenderingDevice.TextureType.Type2D, - UsageBits = usageBits, - }; - - return _renderingDevice.TextureCreate( - textureFormat, - new RDTextureView(), - new Godot.Collections.Array() - ); - } - - private static Rid GetCachedFramebuffer(params Rid[] textures) - { - if (!AllRidsValid(textures)) - { - return new Rid(); - } - - var attachments = new Godot.Collections.Array(); - foreach (var texture in textures) - { - attachments.Add(texture); - } - - return FramebufferCacheRD.GetCacheMultipass( - attachments, - new Godot.Collections.Array(), - 1 - ); - } - - private Rid GetCachedSamplerUniformSet(Rid shader, params Rid[] textures) - { - if (!_sampler.IsValid || !shader.IsValid || !AllRidsValid(textures)) - { - return new Rid(); - } - - var uniforms = new Godot.Collections.Array(); - for (int index = 0; index < textures.Length; index++) - { - var uniform = new RDUniform - { - Binding = index, - UniformType = RenderingDevice.UniformType.SamplerWithTexture, - }; - uniform.AddId(_sampler); - uniform.AddId(textures[index]); - uniforms.Add(uniform); - } - - return UniformSetCacheRD.GetCache(shader, 0, uniforms); - } - - private Rid CreatePipelineForFramebuffer( - Rid shader, - Rid framebuffer, - bool useStencil, - RenderingDevice.TextureSamples sampleCount - ) - { - if (!framebuffer.IsValid) - { - return new Rid(); - } - - return CreatePipeline( - shader, - _renderingDevice.FramebufferGetFormat(framebuffer), - useStencil, - sampleCount - ); - } - - private Rid CreatePipeline( - Rid shader, - long framebufferFormat, - bool useStencil, - RenderingDevice.TextureSamples sampleCount - ) - { - if (!shader.IsValid - || framebufferFormat == RenderingDevice.InvalidId - || _fullscreenVertexFormat == RenderingDevice.InvalidId) - { - return new Rid(); - } - - var blendAttachment = new RDPipelineColorBlendStateAttachment - { - EnableBlend = false, - WriteR = true, - WriteG = true, - WriteB = true, - WriteA = true, - }; - - var blendAttachments = new Godot.Collections.Array - { - blendAttachment, - }; - var depthStencil = new RDPipelineDepthStencilState(); - if (useStencil) - { - ConfigureStencilTest(depthStencil); - } - - return _renderingDevice.RenderPipelineCreate( - shader, - framebufferFormat, - _fullscreenVertexFormat, - RenderingDevice.RenderPrimitive.Triangles, - new RDPipelineRasterizationState - { - CullMode = RenderingDevice.PolygonCullMode.Disabled, - }, - new RDPipelineMultisampleState - { - SampleCount = sampleCount, - }, - depthStencil, - new RDPipelineColorBlendState - { - Attachments = blendAttachments, - }, - (RenderingDevice.PipelineDynamicStateFlags)0, - 0, - new Godot.Collections.Array() - ); - } - - private void ConfigureStencilTest(RDPipelineDepthStencilState depthStencil) - { - depthStencil.EnableStencil = true; - - depthStencil.FrontOpFail = RenderingDevice.StencilOperation.Keep; - depthStencil.FrontOpPass = RenderingDevice.StencilOperation.Keep; - depthStencil.FrontOpDepthFail = RenderingDevice.StencilOperation.Keep; - depthStencil.FrontOpCompare = RenderingDevice.CompareOperator.Equal; - depthStencil.FrontOpCompareMask = 0xff; - depthStencil.FrontOpWriteMask = 0x00; - depthStencil.FrontOpReference = _stencilReference; - - depthStencil.BackOpFail = RenderingDevice.StencilOperation.Keep; - depthStencil.BackOpPass = RenderingDevice.StencilOperation.Keep; - depthStencil.BackOpDepthFail = RenderingDevice.StencilOperation.Keep; - depthStencil.BackOpCompare = RenderingDevice.CompareOperator.Equal; - depthStencil.BackOpCompareMask = 0xff; - depthStencil.BackOpWriteMask = 0x00; - depthStencil.BackOpReference = _stencilReference; - } - - private void DrawPass( - Rid framebuffer, - Rid pipeline, - Rid uniformSet, - byte[] pushConstants, - bool clearColor, - bool preserveDepthStencil - ) - { - if (!framebuffer.IsValid || !pipeline.IsValid || !uniformSet.IsValid) - { - return; - } - - var drawFlags = clearColor - ? RenderingDevice.DrawFlags.ClearColor0 - : 0; - if (!preserveDepthStencil) - { - drawFlags |= RenderingDevice.DrawFlags.IgnoreDepth; - drawFlags |= RenderingDevice.DrawFlags.IgnoreStencil; - } - - var drawList = _renderingDevice.DrawListBegin( - framebuffer, - drawFlags, - ClearColor, - 1.0f, - 0, - null, - 0 - ); - _renderingDevice.DrawListBindRenderPipeline(drawList, pipeline); - _renderingDevice.DrawListBindUniformSet(drawList, uniformSet, 0); - _renderingDevice.DrawListBindVertexArray(drawList, _fullscreenVertexArray); - if (pushConstants.Length > 0) - { - _renderingDevice.DrawListSetPushConstant(drawList, pushConstants, (uint)pushConstants.Length); - } - - _renderingDevice.DrawListDraw(drawList, false, 1, 0); - _renderingDevice.DrawListEnd(); - } - - private void ReleaseResources() - { - if (_resources == null) - { - return; - } - - if (_renderingDevice == null) - { - _resources = null; - return; - } - - foreach (var rid in _resources.PipelineRids) - { - FreeRenderPipelineRid(rid); - } - - foreach (var rid in _resources.TextureRids) - { - FreeTextureRid(rid); - } - - _resources = null; - } - - private void FreeRenderPipelineRid(Rid rid) - { - if (_renderingDevice != null - && rid.IsValid - && _renderingDevice.RenderPipelineIsValid(rid)) - { - _renderingDevice.FreeRid(rid); - } - } - - private void FreeTextureRid(Rid rid) - { - if (_renderingDevice != null - && rid.IsValid - && _renderingDevice.TextureIsValid(rid)) - { - _renderingDevice.FreeRid(rid); - } - } - - private void FreeOwnedRid(ref Rid rid) - { - if (_renderingDevice != null && rid.IsValid) - { - _renderingDevice.FreeRid(rid); - rid = new Rid(); - } - } - - private static int ComputeBloomLevels(Vector2I fullSize) - { - var glareSize = GetGlareImageSize(fullSize); - int smallerDimension = Math.Max(1, Math.Min(glareSize.X, glareSize.Y)); - float scaledDimension = Math.Max(1.0f, smallerDimension * Glow.BloomSize); - int levels = Math.Max(2, Mathf.FloorToInt(Mathf.Log(scaledDimension) / Mathf.Log(2.0f))); - return Math.Min(levels, MaxBloomLevels); - } - - private static Vector2I GetGlareImageSize(Vector2I fullSize) => new( - Math.Max(2, (fullSize.X + BloomQualityFactor - 1) / BloomQualityFactor), - Math.Max(2, (fullSize.Y + BloomQualityFactor - 1) / BloomQualityFactor) - ); - - private static Vector2I[] CreateDownsampleSizes(Vector2I fullSize, int bloomLevels) - { - var sizes = new Vector2I[bloomLevels]; - sizes[0] = GetGlareImageSize(fullSize); - for (int index = 1; index < sizes.Length; index++) - { - sizes[index] = new Vector2I( - Math.Max(2, sizes[index - 1].X / 2), - Math.Max(2, sizes[index - 1].Y / 2) - ); - } - - return sizes; - } - - private static bool HasStencil(RenderingDevice.DataFormat format) => - format == RenderingDevice.DataFormat.D16UnormS8Uint - || format == RenderingDevice.DataFormat.D24UnormS8Uint - || format == RenderingDevice.DataFormat.D32SfloatS8Uint; - - private static bool AllRidsValid(Rid[] rids) - { - if (rids == null || rids.Length == 0) - { - return false; - } - - foreach (var rid in rids) - { - if (!rid.IsValid) - { - return false; - } - } - - return true; - } - - private static byte[] CreateCompositePushConstants() => PushConstants( - Glow.BloomTint.R, - Glow.BloomTint.G, - Glow.BloomTint.B, - Glow.BloomStrength, - NaesTonemap.A, - NaesTonemap.B, - NaesTonemap.C, - NaesTonemap.D, - NaesTonemap.E, - NaesTonemap.InputMax, - 0.0f, - 0.0f, - ToonColorGrade.LumaRiseStart, - ToonColorGrade.LumaRiseEnd, - ToonColorGrade.LumaFallStart, - ToonColorGrade.LumaFallEnd, - ToonColorGrade.LumaMidDip, - ToonColorGrade.VibranceLumaStart, - ToonColorGrade.VibranceLumaEnd, - ToonColorGrade.VibranceSaturationStart, - ToonColorGrade.VibranceSaturationEnd, - ToonColorGrade.ChromaBase, - ToonColorGrade.ChromaBoost, - 0.0f - ); - - private static byte[] PushConstants(params float[] values) - { - var bytes = new byte[sizeof(float) * values.Length]; - Buffer.BlockCopy(values, 0, bytes, 0, bytes.Length); - return bytes; - } - - private static byte[] CreateFullscreenTriangleVertexData() - { - var vertices = new[] - { - -1.0f, -1.0f, - -1.0f, 3.0f, - 3.0f, -1.0f, - }; - var bytes = new byte[vertices.Length * sizeof(float)]; - Buffer.BlockCopy(vertices, 0, bytes, 0, bytes.Length); - return bytes; - } - - private static void WarnOnce(ref bool printed, string message) - { - if (printed) - { - return; - } - - printed = true; - GD.PushWarning(message); - } - - private readonly record struct GlowSettings( - Color BloomTint, - float BloomStrength, - float BloomSize, - float HighlightThreshold, - float HighlightSmoothness, - float MaxHighlightBrightness - ); - - private readonly record struct NaesTonemapSettings( - float A, - float B, - float C, - float D, - float E, - float InputMax - ); - - private readonly record struct ToonColorGradeSettings( - float LumaRiseStart, - float LumaRiseEnd, - float LumaFallStart, - float LumaFallEnd, - float LumaMidDip, - float VibranceLumaStart, - float VibranceLumaEnd, - float VibranceSaturationStart, - float VibranceSaturationEnd, - float ChromaBase, - float ChromaBoost - ); - - private sealed class BloomResources - { - public Vector2I FullSize; - public Rid SceneColor; - public Rid SceneDepth; - public int BloomLevels; - - public Rid SourceTexture; - public Rid SourceRenderTexture; - public bool SourceResolveRequired; - public Rid SourceFramebuffer; - public Rid SourceUniformSet; - public Rid ExtractPipeline; - public byte[] ExtractPushConstants; - - public Rid SceneSourceTexture; - public Rid SceneSourceFramebuffer; - public Rid SceneSourceUniformSet; - public Rid SceneCopyPipeline; - public byte[] SceneCopyPushConstants; - - public Vector2I[] DownsampleSizes; - public Rid[] DownsampleTextures; - public Rid[] DownsampleFramebuffers; - public Rid[] DownsampleUniformSets; - public byte[][] DownsamplePushConstants; - public Rid DownsamplePipeline; - - public Rid[] UpsampleTextures; - public Rid[] UpsampleFramebuffers; - public Rid[] UpsampleUniformSets; - public byte[][] UpsamplePushConstants; - public Rid UpsamplePipeline; - - public Rid CompositeFramebuffer; - public Rid CompositeUniformSet; - public byte[] CompositePushConstants; - public Rid CompositePipeline; - - public bool IsValid => - SourceTexture.IsValid - && SourceRenderTexture.IsValid - && SourceFramebuffer.IsValid - && SourceUniformSet.IsValid - && ExtractPipeline.IsValid - && ExtractPushConstants != null - && SceneSourceTexture.IsValid - && SceneSourceFramebuffer.IsValid - && SceneSourceUniformSet.IsValid - && SceneCopyPipeline.IsValid - && SceneCopyPushConstants != null - && DownsampleSizes != null - && DownsampleSizes.Length == BloomLevels - && StageAvatarGlowCompositorEffect.AllRidsValid(DownsampleTextures) - && StageAvatarGlowCompositorEffect.AllRidsValid(DownsampleFramebuffers) - && StageAvatarGlowCompositorEffect.AllRidsValid(DownsampleUniformSets) - && AllArraysPresent(DownsamplePushConstants) - && DownsamplePipeline.IsValid - && StageAvatarGlowCompositorEffect.AllRidsValid(UpsampleTextures) - && StageAvatarGlowCompositorEffect.AllRidsValid(UpsampleFramebuffers) - && StageAvatarGlowCompositorEffect.AllRidsValid(UpsampleUniformSets) - && AllArraysPresent(UpsamplePushConstants) - && UpsamplePipeline.IsValid - && CompositeFramebuffer.IsValid - && CompositeUniformSet.IsValid - && CompositePushConstants != null - && CompositePipeline.IsValid; - - private static bool AllArraysPresent(byte[][] arrays) - { - if (arrays == null || arrays.Length == 0) - { - return false; - } - - foreach (var array in arrays) - { - if (array == null) - { - return false; - } - } - - return true; - } - - public IEnumerable TextureRids - { - get - { - yield return SourceTexture; - if (SourceRenderTexture != SourceTexture) - { - yield return SourceRenderTexture; - } - - yield return SceneSourceTexture; - - for (int level = 0; DownsampleTextures != null && level < DownsampleTextures.Length; level++) - { - yield return DownsampleTextures[level]; - } - - for (int level = 0; UpsampleTextures != null && level < UpsampleTextures.Length; level++) - { - yield return UpsampleTextures[level]; - } - } - } - - public IEnumerable PipelineRids - { - get - { - yield return SceneCopyPipeline; - yield return ExtractPipeline; - yield return DownsamplePipeline; - yield return UpsamplePipeline; - yield return CompositePipeline; - } - } - } -} diff --git a/engines/stage-tamagotchi-godot/scripts/visuals/StageAvatarGlowRuntime.cs b/engines/stage-tamagotchi-godot/scripts/visuals/StageAvatarGlowRuntime.cs deleted file mode 100644 index b85a831ee..000000000 --- a/engines/stage-tamagotchi-godot/scripts/visuals/StageAvatarGlowRuntime.cs +++ /dev/null @@ -1,119 +0,0 @@ -using System; -using System.Collections.Generic; -using Godot; - -/// -/// Owns avatar-only stencil masking and the camera-local glow compositor. -/// -public sealed class StageAvatarGlowRuntime : IDisposable -{ - private const int AvatarStencilReference = 1; - - private readonly Camera3D _camera; - private readonly StageAvatarGlowCompositorEffect _avatarGlowEffect; - private readonly StandardMaterial3D _avatarGlowMaskMaterial; - private readonly Dictionary _previousOverlays = new(); - private bool _disposed; - - public StageAvatarGlowRuntime(Camera3D camera) - { - _camera = camera ?? throw new ArgumentNullException(nameof(camera)); - _avatarGlowEffect = new StageAvatarGlowCompositorEffect(AvatarStencilReference); - _avatarGlowMaskMaterial = CreateAvatarGlowMaskMaterial(); - - // NOTICE: - // Camera3D owns compositor effects. Avatar glow is the only stage compositor today; - // replace this direct assignment with a shared owner before adding more camera passes. - var compositor = new Compositor(); - compositor.CompositorEffects = new Godot.Collections.Array - { - _avatarGlowEffect, - }; - _camera.Compositor = compositor; - } - - public void UseAvatar(Node avatar) - { - if (_disposed) - { - return; - } - - ClearAvatarMask(); - if (avatar == null) - { - _avatarGlowEffect.Enabled = false; - return; - } - - MarkAvatarMask(avatar); - _avatarGlowEffect.Enabled = _previousOverlays.Count > 0; - } - - public void Dispose() - { - if (_disposed) - { - return; - } - - _disposed = true; - _avatarGlowEffect.Enabled = false; - ClearAvatarMask(); - if (_camera.Compositor?.CompositorEffects.Contains(_avatarGlowEffect) == true) - { - _camera.Compositor = null; - } - - _avatarGlowEffect.ReleaseRenderingResources(); - } - - private StandardMaterial3D CreateAvatarGlowMaskMaterial() => new() - { - AlbedoColor = new Color(0.0f, 0.0f, 0.0f, 0.0f), - CullMode = BaseMaterial3D.CullModeEnum.Disabled, - // NOTICE: - // Keep the mask depth-tested but non-writing: occluded avatar fragments do not mark - // stencil, and the mask pass does not mutate scene depth. - DepthDrawMode = BaseMaterial3D.DepthDrawModeEnum.Disabled, - DisableFog = true, - NoDepthTest = false, - RenderPriority = (int)Material.RenderPriorityMax, - ShadingMode = BaseMaterial3D.ShadingModeEnum.Unshaded, - StencilCompare = BaseMaterial3D.StencilCompareEnum.Always, - StencilFlags = (int)BaseMaterial3D.StencilFlagsEnum.Write, - StencilMode = BaseMaterial3D.StencilModeEnum.Custom, - StencilReference = AvatarStencilReference, - Transparency = BaseMaterial3D.TransparencyEnum.Alpha, - }; - - private void MarkAvatarMask(Node node) - { - if (node is GeometryInstance3D geometry) - { - // NOTICE: - // MaterialOverlay is a single instance-level extra pass. Save and restore it while - // this runtime uses the slot as a temporary avatar mask producer. - _previousOverlays[geometry] = geometry.MaterialOverlay; - geometry.MaterialOverlay = _avatarGlowMaskMaterial; - } - - foreach (Node child in node.GetChildren()) - { - MarkAvatarMask(child); - } - } - - private void ClearAvatarMask() - { - foreach (var (geometry, previousOverlay) in _previousOverlays) - { - if (GodotObject.IsInstanceValid(geometry)) - { - geometry.MaterialOverlay = previousOverlay; - } - } - - _previousOverlays.Clear(); - } -} diff --git a/engines/stage-tamagotchi-godot/scripts/visuals/StageCompositorOwner.cs b/engines/stage-tamagotchi-godot/scripts/visuals/StageCompositorOwner.cs new file mode 100644 index 000000000..422af4cbe --- /dev/null +++ b/engines/stage-tamagotchi-godot/scripts/visuals/StageCompositorOwner.cs @@ -0,0 +1,65 @@ +using System; +using Godot; + +/// +/// Owns the camera-local compositor slot for stage post-process effects. +/// +public sealed class StageCompositorOwner : IDisposable +{ + private readonly Camera3D _camera; + private readonly Compositor _ownedCompositor; + private readonly Compositor _previousCompositor; + private bool _disposed; + + public StageCompositorOwner(Camera3D camera, CompositorEffect stageEffect) + { + _camera = camera ?? throw new ArgumentNullException(nameof(camera)); + _previousCompositor = camera.Compositor; + _ownedCompositor = new Compositor + { + CompositorEffects = CreateCompositorEffects(_previousCompositor, stageEffect), + }; + + _camera.Compositor = _ownedCompositor; + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + if (GodotObject.IsInstanceValid(_camera) && _camera.Compositor == _ownedCompositor) + { + _camera.Compositor = _previousCompositor; + } + } + + private static Godot.Collections.Array CreateCompositorEffects( + Compositor previousCompositor, + CompositorEffect stageEffect + ) + { + if (stageEffect == null) + { + throw new ArgumentNullException(nameof(stageEffect)); + } + + var effects = new Godot.Collections.Array(); + if (previousCompositor?.CompositorEffects != null) + { + foreach (CompositorEffect effect in previousCompositor.CompositorEffects) + { + if (effect != null && effect != stageEffect) + { + effects.Add(effect); + } + } + } + + effects.Add(stageEffect); + return effects; + } +} diff --git a/engines/stage-tamagotchi-godot/scripts/visuals/StageMaterialOverlayOwner.cs b/engines/stage-tamagotchi-godot/scripts/visuals/StageMaterialOverlayOwner.cs new file mode 100644 index 000000000..323c6e2ae --- /dev/null +++ b/engines/stage-tamagotchi-godot/scripts/visuals/StageMaterialOverlayOwner.cs @@ -0,0 +1,158 @@ +using System; +using System.Collections.Generic; +using Godot; + +/// +/// Identifies stage overlay source producers that compete for the single MaterialOverlay slot. +/// +public enum StageMaterialOverlaySourceKind +{ + AvatarMask, +} + +/// +/// Owns stage material overlay assignments used by render-effect source passes. +/// +public sealed class StageMaterialOverlayOwner : IDisposable +{ + private readonly Material _avatarSourceOverlayMaterial; + private readonly Dictionary _claims = new(); + private readonly Dictionary _previousOverlays = new(); + private bool _disposed; + + public StageMaterialOverlayOwner(int avatarStencilReference) + { + _avatarSourceOverlayMaterial = CreateAvatarSourceOverlayMaterial(avatarStencilReference); + } + + public bool HasSource(StageMaterialOverlaySourceKind sourceKind) => _claims.ContainsKey(sourceKind); + + public void UseAvatarMask(Node avatar) + { + if (_disposed) + { + return; + } + + if (avatar == null) + { + ClearSource(StageMaterialOverlaySourceKind.AvatarMask); + return; + } + + _claims[StageMaterialOverlaySourceKind.AvatarMask] = new StageOverlaySourceClaim( + avatar, + _avatarSourceOverlayMaterial + ); + RefreshOverlayAssignments(); + } + + public void ClearSource(StageMaterialOverlaySourceKind sourceKind) + { + if (!_claims.Remove(sourceKind)) + { + return; + } + + RefreshOverlayAssignments(); + } + + public void ClearAllSources() + { + _claims.Clear(); + ClearOverlayAssignments(); + } + + private void ClearOverlayAssignments() + { + foreach (var (geometry, previousOverlay) in _previousOverlays) + { + if (GodotObject.IsInstanceValid(geometry)) + { + geometry.MaterialOverlay = previousOverlay; + } + } + + _previousOverlays.Clear(); + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + ClearAllSources(); + } + + private static StandardMaterial3D CreateAvatarSourceOverlayMaterial(int avatarStencilReference) => + new() + { + AlbedoColor = new Color(0.0f, 0.0f, 0.0f, 0.0f), + CullMode = BaseMaterial3D.CullModeEnum.Disabled, + // NOTICE: + // Keep the mask depth-tested but non-writing: occluded avatar fragments do not mark + // stencil, and the mask pass does not mutate scene depth. + DepthDrawMode = BaseMaterial3D.DepthDrawModeEnum.Disabled, + DisableFog = true, + NoDepthTest = false, + RenderPriority = (int)Material.RenderPriorityMax, + ShadingMode = BaseMaterial3D.ShadingModeEnum.Unshaded, + StencilCompare = BaseMaterial3D.StencilCompareEnum.Always, + StencilFlags = (int)BaseMaterial3D.StencilFlagsEnum.Write, + StencilMode = BaseMaterial3D.StencilModeEnum.Custom, + StencilReference = avatarStencilReference, + Transparency = BaseMaterial3D.TransparencyEnum.Alpha, + }; + + private void RefreshOverlayAssignments() + { + ClearOverlayAssignments(); + if (_claims.Count == 0) + { + return; + } + + // NOTICE: + // Godot exposes only one MaterialOverlay slot per GeometryInstance3D. Until the + // overlay material encodes multiple source channels, the selected claim owns the + // physical overlay assignment for this refresh. + var claim = SelectOverlayClaim(); + MarkSource(claim.Root, claim.OverlayMaterial); + } + + private StageOverlaySourceClaim SelectOverlayClaim() + { + if (_claims.TryGetValue(StageMaterialOverlaySourceKind.AvatarMask, out var avatarMaskClaim)) + { + return avatarMaskClaim; + } + + throw new InvalidOperationException("Stage overlay owner had no selectable source claim."); + } + + private void MarkSource(Node node, Material overlayMaterial) + { + if (node is GeometryInstance3D geometry + && !_previousOverlays.ContainsKey(geometry)) + { + // NOTICE: + // MaterialOverlay is a single instance-level extra pass. This owner is the only + // stage runtime allowed to occupy the slot for render-effect source passes. + _previousOverlays[geometry] = geometry.MaterialOverlay; + geometry.MaterialOverlay = overlayMaterial; + } + + foreach (Node child in node.GetChildren()) + { + MarkSource(child, overlayMaterial); + } + } + + private readonly record struct StageOverlaySourceClaim( + Node Root, + Material OverlayMaterial + ); +} diff --git a/engines/stage-tamagotchi-godot/scripts/visuals/StagePostProcessCompositorEffect.Pipeline.cs b/engines/stage-tamagotchi-godot/scripts/visuals/StagePostProcessCompositorEffect.Pipeline.cs new file mode 100644 index 000000000..48f3f1d57 --- /dev/null +++ b/engines/stage-tamagotchi-godot/scripts/visuals/StagePostProcessCompositorEffect.Pipeline.cs @@ -0,0 +1,283 @@ +using System; +using Godot; + +public partial class StagePostProcessCompositorEffect +{ + private void RunPipeline() + { + foreach (var stage in PipelineStages) + { + DrawStage(stage); + } + + DrawDebugOutputStage(); + } + + private void DrawStage(StageRenderPipelineStage stage) + { + switch (stage) + { + case StageRenderPipelineStage.SceneCopy: + DrawSceneCopyStage(); + break; + case StageRenderPipelineStage.AvatarMask: + DrawAvatarMaskStage(); + break; + case StageRenderPipelineStage.AvatarEdgeLight: + DrawAvatarEdgeLightStage(); + break; + case StageRenderPipelineStage.AvatarGlow: + DrawAvatarGlowStage(); + break; + case StageRenderPipelineStage.FinalColorMapping: + DrawFinalColorMappingStage(); + break; + default: + throw new ArgumentOutOfRangeException(nameof(stage), stage, null); + } + } + + private void DrawSceneCopyStage() + { + DrawPass( + _resources.SceneSourceFramebuffer, + _resources.SceneCopyPipeline, + _resources.SceneSourceUniformSet, + _resources.SceneCopyPushConstants, + clearColor: false, + preserveDepthStencil: false + ); + } + + private void DrawAvatarMaskStage() + { + if (!_resources.IncludesAvatarMask) + { + return; + } + + DrawPass( + _resources.AvatarMaskFramebuffer, + _resources.AvatarMaskPipeline, + _resources.AvatarMaskUniformSet, + _resources.AvatarMaskPushConstants, + clearColor: true, + preserveDepthStencil: true + ); + if (_resources.AvatarMaskResolveRequired) + { + var resolveError = _renderingDevice.TextureResolveMultisample( + _resources.AvatarMaskRenderTexture, + _resources.AvatarMaskTexture + ); + if (resolveError != Error.Ok) + { + WarnOnce( + ref _avatarMaskResolveWarningPrinted, + $"Avatar mask MSAA resolve failed: {resolveError}." + ); + } + } + } + + private void DrawAvatarEdgeLightStage() + { + if (!_resources.IncludesAvatarEdgeLight) + { + return; + } + + DrawPass( + _resources.AvatarEdgeLightFramebuffer, + _resources.AvatarEdgeLightPipeline, + _resources.AvatarEdgeLightUniformSet, + _resources.AvatarEdgeLightPushConstants, + clearColor: true, + preserveDepthStencil: false + ); + } + + private void DrawAvatarGlowStage() + { + if (!_resources.IncludesAvatarMask) + { + return; + } + + DrawPass( + _resources.SourceFramebuffer, + _resources.ExtractPipeline, + _resources.SourceUniformSet, + _resources.ExtractPushConstants, + clearColor: true, + preserveDepthStencil: true + ); + if (_resources.SourceResolveRequired) + { + var resolveError = _renderingDevice.TextureResolveMultisample( + _resources.SourceRenderTexture, + _resources.SourceTexture + ); + if (resolveError != Error.Ok) + { + WarnOnce( + ref _sourceResolveWarningPrinted, + $"Avatar glow source MSAA resolve failed: {resolveError}." + ); + return; + } + } + + for (int level = 0; level < _resources.BloomLevels; level++) + { + DrawPass( + _resources.DownsampleFramebuffers[level], + _resources.DownsamplePipeline, + _resources.DownsampleUniformSets[level], + _resources.DownsamplePushConstants[level], + clearColor: true, + preserveDepthStencil: false + ); + } + + for (int level = _resources.BloomLevels - 2; level >= 0; level--) + { + DrawPass( + _resources.UpsampleFramebuffers[level], + _resources.UpsamplePipeline, + _resources.UpsampleUniformSets[level], + _resources.UpsamplePushConstants[level], + clearColor: true, + preserveDepthStencil: false + ); + } + + DrawPass( + _resources.GlowCompositeFramebuffer, + _resources.GlowCompositePipeline, + _resources.GlowCompositeUniformSet, + _resources.GlowCompositePushConstants, + clearColor: true, + preserveDepthStencil: false + ); + } + + private void DrawFinalColorMappingStage() + { + DrawPass( + _resources.FinalColorFramebuffer, + _resources.FinalColorPipeline, + _resources.FinalColorUniformSet, + _resources.FinalColorPushConstants, + clearColor: false, + preserveDepthStencil: false + ); + } + + private void DrawDebugOutputStage() + { + if (_debugView == StageRenderDebugView.Final) + { + return; + } + + var uniformSet = ResolveDebugOutputUniformSet(); + if (!uniformSet.IsValid) + { + return; + } + + DrawPass( + _resources.FinalColorFramebuffer, + _resources.DebugOutputPipeline, + uniformSet, + Array.Empty(), + clearColor: false, + preserveDepthStencil: false + ); + } + + private Rid ResolveDebugOutputUniformSet() + { + return _debugView switch + { + StageRenderDebugView.SceneCopy => _resources.DebugSceneCopyUniformSet, + StageRenderDebugView.AvatarMask => _resources.DebugAvatarMaskUniformSet, + StageRenderDebugView.AvatarEdgeMask => _resources.DebugAvatarEdgeLightUniformSet, + StageRenderDebugView.AfterAvatarEdgeLight => _resources.DebugAvatarEdgeLightUniformSet, + StageRenderDebugView.AfterAvatarGlow => _resources.DebugGlowCompositeUniformSet, + _ => new Rid(), + }; + } +} + +internal enum StageRenderPipelineStage +{ + SceneCopy, + AvatarMask, + AvatarEdgeLight, + AvatarGlow, + FinalColorMapping, +} + +internal enum StageRenderDebugView +{ + Final, + SceneCopy, + AvatarMask, + AvatarEdgeMask, + AfterAvatarEdgeLight, + AfterAvatarGlow, +} + +internal static class StageRenderDebugViewNames +{ + public const string Final = "final"; + public const string SceneCopy = "scene-copy"; + public const string AvatarMask = "avatar-mask"; + public const string AvatarEdgeMask = "avatar-edge-mask"; + public const string AfterAvatarEdgeLight = "after-avatar-edge-light"; + public const string AfterAvatarGlow = "after-avatar-glow"; + + public static bool TryParse(string value, out StageRenderDebugView debugView) + { + switch (value) + { + case Final: + debugView = StageRenderDebugView.Final; + return true; + case SceneCopy: + debugView = StageRenderDebugView.SceneCopy; + return true; + case AvatarMask: + debugView = StageRenderDebugView.AvatarMask; + return true; + case AvatarEdgeMask: + debugView = StageRenderDebugView.AvatarEdgeMask; + return true; + case AfterAvatarEdgeLight: + debugView = StageRenderDebugView.AfterAvatarEdgeLight; + return true; + case AfterAvatarGlow: + debugView = StageRenderDebugView.AfterAvatarGlow; + return true; + default: + debugView = StageRenderDebugView.Final; + return false; + } + } + + public static string ToTransportValue(StageRenderDebugView debugView) + { + return debugView switch + { + StageRenderDebugView.Final => Final, + StageRenderDebugView.SceneCopy => SceneCopy, + StageRenderDebugView.AvatarMask => AvatarMask, + StageRenderDebugView.AvatarEdgeMask => AvatarEdgeMask, + StageRenderDebugView.AfterAvatarEdgeLight => AfterAvatarEdgeLight, + StageRenderDebugView.AfterAvatarGlow => AfterAvatarGlow, + _ => Final, + }; + } +} diff --git a/engines/stage-tamagotchi-godot/scripts/visuals/StagePostProcessCompositorEffect.Resources.cs b/engines/stage-tamagotchi-godot/scripts/visuals/StagePostProcessCompositorEffect.Resources.cs new file mode 100644 index 000000000..4be5622ff --- /dev/null +++ b/engines/stage-tamagotchi-godot/scripts/visuals/StagePostProcessCompositorEffect.Resources.cs @@ -0,0 +1,1088 @@ +using System; +using System.Collections.Generic; +using Godot; + +public partial class StagePostProcessCompositorEffect +{ + private bool EnsureRenderingDevice() + { + if (_renderingDevice != null) + { + if (RenderingResourcesAreValid()) + { + return true; + } + + ReleaseRenderingResourcesOnRenderThread(); + } + + _renderingDevice = RenderingServer.GetRenderingDevice(); + if (_renderingDevice == null) + { + WarnOnce( + ref _missingRenderingDeviceWarningPrinted, + "Stage post-process requires the Forward+/Mobile rendering device." + ); + return false; + } + + _sampler = _renderingDevice.SamplerCreate(new RDSamplerState + { + MagFilter = RenderingDevice.SamplerFilter.Linear, + MinFilter = RenderingDevice.SamplerFilter.Linear, + MipFilter = RenderingDevice.SamplerFilter.Linear, + RepeatU = RenderingDevice.SamplerRepeatMode.ClampToEdge, + RepeatV = RenderingDevice.SamplerRepeatMode.ClampToEdge, + RepeatW = RenderingDevice.SamplerRepeatMode.ClampToEdge, + }); + _fullscreenVertexFormat = _renderingDevice.VertexFormatCreate( + new Godot.Collections.Array + { + new() + { + Binding = 0, + Format = RenderingDevice.DataFormat.R32G32Sfloat, + Frequency = RenderingDevice.VertexFrequency.Vertex, + Location = 0, + Offset = 0, + Stride = sizeof(float) * 2, + }, + } + ); + _fullscreenVertexBuffer = _renderingDevice.VertexBufferCreate( + sizeof(float) * 2 * 3, + CreateFullscreenTriangleVertexData(), + (RenderingDevice.BufferCreationBits)0 + ); + _fullscreenVertexArray = _renderingDevice.VertexArrayCreate( + 3, + _fullscreenVertexFormat, + new Godot.Collections.Array + { + _fullscreenVertexBuffer, + }, + new long[] { 0 } + ); + _copyShader = CompileShader("AIRI stage scene copy", CopySceneFragmentShaderCode); + _extractShader = CompileShader("AIRI avatar source extract", ExtractHighlightsFragmentShaderCode); + _downsampleShader = CompileShader("AIRI avatar glow downsample", DownsampleFragmentShaderCode); + _upsampleShader = CompileShader("AIRI avatar glow upsample", UpsampleFragmentShaderCode); + _glowCompositeShader = CompileShader( + "AIRI stage glow composite", + GlowCompositeFragmentShaderCode + ); + _finalColorShader = CompileShader("AIRI stage final color", FinalColorFragmentShaderCode); + _avatarMaskShader = CompileShader("AIRI avatar mask", AvatarMaskFragmentShaderCode); + _edgeLightShader = CompileShader("AIRI avatar edge light", EdgeLightFragmentShaderCode); + + if (RenderingResourcesAreValid()) + { + return true; + } + + ReleaseRenderingResourcesOnRenderThread(); + return false; + } + + private bool RenderingResourcesAreValid() => + _renderingDevice != null + && _sampler.IsValid + && _fullscreenVertexFormat != RenderingDevice.InvalidId + && _fullscreenVertexBuffer.IsValid + && _fullscreenVertexArray.IsValid + && _copyShader.IsValid + && _avatarMaskShader.IsValid + && _edgeLightShader.IsValid + && _extractShader.IsValid + && _downsampleShader.IsValid + && _upsampleShader.IsValid + && _glowCompositeShader.IsValid + && _finalColorShader.IsValid; + + private Rid GetNormalRoughnessTexture(RenderSceneBuffersRD buffers) + { + if (!buffers.HasTexture("forward_clustered", "normal_roughness")) + { + return new Rid(); + } + + return buffers.GetTexture("forward_clustered", "normal_roughness"); + } + + private Rid SelectStencilDepthTexture(RenderSceneBuffersRD buffers) + { + var resolvedDepth = buffers.GetDepthTexture(false); + if (resolvedDepth.IsValid && HasStencil(_renderingDevice.TextureGetFormat(resolvedDepth).Format)) + { + return resolvedDepth; + } + + var msaaDepth = buffers.GetDepthTexture(true); + if (msaaDepth.IsValid && HasStencil(_renderingDevice.TextureGetFormat(msaaDepth).Format)) + { + return msaaDepth; + } + + return resolvedDepth; + } + + private bool EnsureResources( + Vector2I fullSize, + Rid sceneColor, + Rid stencilDepth, + Rid resolvedDepth, + Rid normalRoughness, + bool includeAvatarMask, + bool includeAvatarEdgeLight, + StageRenderDebugView debugView + ) + { + if (_resources != null + && _resources.FullSize == fullSize + && _resources.SceneColor == sceneColor + && _resources.StencilDepth == stencilDepth + && _resources.ResolvedDepth == resolvedDepth + && _resources.NormalRoughness == normalRoughness + && _resources.IncludesAvatarMask == includeAvatarMask + && _resources.IncludesAvatarEdgeLight == includeAvatarEdgeLight + && _resources.DebugView == debugView + && _resources.IsValid) + { + return true; + } + + ReleaseResources(); + + var resources = new PostProcessResources + { + FullSize = fullSize, + SceneColor = sceneColor, + StencilDepth = stencilDepth, + ResolvedDepth = resolvedDepth, + NormalRoughness = normalRoughness, + IncludesAvatarMask = includeAvatarMask, + IncludesAvatarEdgeLight = includeAvatarEdgeLight, + DebugView = debugView, + BloomLevels = includeAvatarMask ? ComputeBloomLevels(fullSize) : 0, + }; + + resources.SceneSourceTexture = CreateColorTexture(fullSize); + resources.SceneSourceFramebuffer = GetCachedFramebuffer(resources.SceneSourceTexture); + resources.SceneSourceUniformSet = GetCachedSamplerUniformSet(_copyShader, sceneColor); + resources.SceneCopyPipeline = CreatePipelineForFramebuffer( + _copyShader, + resources.SceneSourceFramebuffer, + useStencil: false, + sampleCount: RenderingDevice.TextureSamples.Samples1 + ); + resources.SceneCopyPushConstants = Array.Empty(); + + if (includeAvatarMask) + { + var sceneDepthTextureFormat = _renderingDevice.TextureGetFormat(stencilDepth); + var sourceSamples = sceneDepthTextureFormat.Samples; + resources.AvatarMaskTexture = CreateColorTexture( + fullSize, + RenderingDevice.TextureSamples.Samples1, + canResolveTo: sourceSamples != RenderingDevice.TextureSamples.Samples1 + ); + resources.AvatarMaskRenderTexture = sourceSamples == RenderingDevice.TextureSamples.Samples1 + ? resources.AvatarMaskTexture + : CreateColorTexture(fullSize, sourceSamples, canResolveFrom: true); + resources.AvatarMaskResolveRequired = + resources.AvatarMaskRenderTexture != resources.AvatarMaskTexture; + resources.AvatarMaskFramebuffer = GetCachedFramebuffer( + resources.AvatarMaskRenderTexture, + stencilDepth + ); + resources.AvatarMaskUniformSet = GetCachedSamplerUniformSet( + _avatarMaskShader, + resources.SceneSourceTexture + ); + resources.AvatarMaskPushConstants = Array.Empty(); + resources.AvatarMaskPipeline = CreatePipelineForFramebuffer( + _avatarMaskShader, + resources.AvatarMaskFramebuffer, + useStencil: true, + sampleCount: sourceSamples + ); + + if (includeAvatarEdgeLight) + { + resources.AvatarEdgeLightTexture = CreateColorTexture(fullSize); + resources.AvatarEdgeLightFramebuffer = GetCachedFramebuffer( + resources.AvatarEdgeLightTexture + ); + resources.AvatarEdgeLightUniformSet = GetCachedSamplerUniformSet( + _edgeLightShader, + resources.SceneSourceTexture, + resolvedDepth, + normalRoughness, + resources.AvatarMaskTexture + ); + resources.AvatarEdgeLightPushConstants = CreateEdgeLightPushConstants( + fullSize, + debugView == StageRenderDebugView.AvatarEdgeMask + ); + resources.AvatarEdgeLightPipeline = CreatePipelineForFramebuffer( + _edgeLightShader, + resources.AvatarEdgeLightFramebuffer, + useStencil: false, + sampleCount: RenderingDevice.TextureSamples.Samples1 + ); + } + + var avatarGlowInputTexture = includeAvatarEdgeLight + ? resources.AvatarEdgeLightTexture + : resources.SceneSourceTexture; + resources.SourceTexture = CreateColorTexture( + fullSize, + RenderingDevice.TextureSamples.Samples1, + canResolveTo: sourceSamples != RenderingDevice.TextureSamples.Samples1 + ); + resources.SourceRenderTexture = sourceSamples == RenderingDevice.TextureSamples.Samples1 + ? resources.SourceTexture + : CreateColorTexture(fullSize, sourceSamples, canResolveFrom: true); + resources.SourceResolveRequired = resources.SourceRenderTexture != resources.SourceTexture; + resources.SourceFramebuffer = GetCachedFramebuffer(resources.SourceRenderTexture, stencilDepth); + resources.SourceUniformSet = GetCachedSamplerUniformSet( + _extractShader, + avatarGlowInputTexture + ); + resources.ExtractPipeline = CreatePipelineForFramebuffer( + _extractShader, + resources.SourceFramebuffer, + useStencil: true, + sampleCount: sourceSamples + ); + resources.ExtractPushConstants = PushConstants( + Glow.HighlightThreshold, + Glow.HighlightSmoothness, + Glow.MaxHighlightBrightness, + 0.0f + ); + + resources.DownsampleSizes = CreateDownsampleSizes(fullSize, resources.BloomLevels); + resources.DownsampleTextures = new Rid[resources.BloomLevels]; + resources.DownsampleFramebuffers = new Rid[resources.BloomLevels]; + resources.DownsampleUniformSets = new Rid[resources.BloomLevels]; + resources.DownsamplePushConstants = new byte[resources.BloomLevels][]; + + for (int level = 0; level < resources.BloomLevels; level++) + { + var outputSize = resources.DownsampleSizes[level]; + var inputSize = level == 0 ? fullSize : resources.DownsampleSizes[level - 1]; + var inputTexture = level == 0 + ? resources.SourceTexture + : resources.DownsampleTextures[level - 1]; + + resources.DownsampleTextures[level] = CreateColorTexture(outputSize); + resources.DownsampleFramebuffers[level] = GetCachedFramebuffer( + resources.DownsampleTextures[level] + ); + resources.DownsampleUniformSets[level] = GetCachedSamplerUniformSet( + _downsampleShader, + inputTexture + ); + resources.DownsamplePushConstants[level] = PushConstants( + 1.0f / Math.Max(1, inputSize.X), + 1.0f / Math.Max(1, inputSize.Y), + level == 0 ? 1.0f : 0.0f, + 0.0f + ); + } + + resources.UpsampleTextures = new Rid[resources.BloomLevels - 1]; + resources.UpsampleFramebuffers = new Rid[resources.BloomLevels - 1]; + resources.UpsampleUniformSets = new Rid[resources.BloomLevels - 1]; + resources.UpsamplePushConstants = new byte[resources.BloomLevels - 1][]; + + for (int level = resources.BloomLevels - 2; level >= 0; level--) + { + var outputSize = resources.DownsampleSizes[level]; + var baseTexture = resources.DownsampleTextures[level]; + var inputTexture = level == resources.BloomLevels - 2 + ? resources.DownsampleTextures[level + 1] + : resources.UpsampleTextures[level + 1]; + + resources.UpsampleTextures[level] = CreateColorTexture(outputSize); + resources.UpsampleFramebuffers[level] = GetCachedFramebuffer( + resources.UpsampleTextures[level] + ); + resources.UpsampleUniformSets[level] = GetCachedSamplerUniformSet( + _upsampleShader, + baseTexture, + inputTexture + ); + resources.UpsamplePushConstants[level] = PushConstants( + 1.0f / Math.Max(1, outputSize.X), + 1.0f / Math.Max(1, outputSize.Y), + 0.0f, + 0.0f + ); + } + + resources.GlowCompositeTexture = CreateColorTexture(fullSize); + resources.GlowCompositeFramebuffer = GetCachedFramebuffer(resources.GlowCompositeTexture); + resources.GlowCompositeUniformSet = GetCachedSamplerUniformSet( + _glowCompositeShader, + avatarGlowInputTexture, + resources.UpsampleTextures[0] + ); + resources.GlowCompositePushConstants = CreateGlowCompositePushConstants(); + resources.GlowCompositePipeline = CreatePipelineForFramebuffer( + _glowCompositeShader, + resources.GlowCompositeFramebuffer, + useStencil: false, + sampleCount: RenderingDevice.TextureSamples.Samples1 + ); + } + + resources.FinalColorFramebuffer = GetCachedFramebuffer(sceneColor); + resources.FinalColorUniformSet = GetCachedSamplerUniformSet( + _finalColorShader, + includeAvatarMask + ? resources.GlowCompositeTexture + : resources.SceneSourceTexture + ); + resources.FinalColorPushConstants = CreateFinalColorPushConstants(); + resources.DebugOutputPipeline = CreatePipelineForFramebuffer( + _copyShader, + resources.FinalColorFramebuffer, + useStencil: false, + sampleCount: RenderingDevice.TextureSamples.Samples1 + ); + resources.DebugSceneCopyUniformSet = GetCachedSamplerUniformSet( + _copyShader, + resources.SceneSourceTexture + ); + + if (includeAvatarMask) + { + resources.DebugAvatarMaskUniformSet = GetCachedSamplerUniformSet( + _copyShader, + resources.AvatarMaskTexture + ); + if (includeAvatarEdgeLight) + { + resources.DebugAvatarEdgeLightUniformSet = GetCachedSamplerUniformSet( + _copyShader, + resources.AvatarEdgeLightTexture + ); + } + + resources.DebugGlowCompositeUniformSet = GetCachedSamplerUniformSet( + _copyShader, + resources.GlowCompositeTexture + ); + resources.DownsamplePipeline = CreatePipelineForFramebuffer( + _downsampleShader, + resources.DownsampleFramebuffers[0], + useStencil: false, + sampleCount: RenderingDevice.TextureSamples.Samples1 + ); + resources.UpsamplePipeline = CreatePipelineForFramebuffer( + _upsampleShader, + resources.UpsampleFramebuffers[0], + useStencil: false, + sampleCount: RenderingDevice.TextureSamples.Samples1 + ); + } + + resources.FinalColorPipeline = CreatePipelineForFramebuffer( + _finalColorShader, + resources.FinalColorFramebuffer, + useStencil: false, + sampleCount: RenderingDevice.TextureSamples.Samples1 + ); + + if (resources.IsValid) + { + _resources = resources; + return true; + } + + _resources = resources; + ReleaseResources(); + return false; + } + + private Rid CompileShader(string name, string fragmentShaderCode) + { + var shaderSource = new RDShaderSource + { + Language = RenderingDevice.ShaderLanguage.Glsl, + SourceVertex = FullscreenVertexShaderCode, + SourceFragment = fragmentShaderCode, + }; + var spirv = _renderingDevice.ShaderCompileSpirVFromSource(shaderSource, true); + if (!string.IsNullOrWhiteSpace(spirv.CompileErrorVertex) + || !string.IsNullOrWhiteSpace(spirv.CompileErrorFragment)) + { + GD.PushError( + $"Failed to compile {name}: " + + $"{spirv.CompileErrorVertex} {spirv.CompileErrorFragment}" + ); + return new Rid(); + } + + return _renderingDevice.ShaderCreateFromSpirV(spirv, name); + } + + // NOTICE: + // StageRenderEffectsRuntime owns the camera/overlay slots; this compositor owns the + // same-frame RD pass graph and transient textures for post-process effects. + private Rid CreateColorTexture( + Vector2I size, + RenderingDevice.TextureSamples samples = RenderingDevice.TextureSamples.Samples1, + bool canResolveFrom = false, + bool canResolveTo = false + ) + { + var usageBits = + RenderingDevice.TextureUsageBits.SamplingBit | + RenderingDevice.TextureUsageBits.ColorAttachmentBit; + if (canResolveFrom) + { + usageBits |= RenderingDevice.TextureUsageBits.CanCopyFromBit; + } + + if (canResolveTo) + { + usageBits |= RenderingDevice.TextureUsageBits.CanCopyToBit; + } + + var textureFormat = new RDTextureFormat + { + Format = RenderingDevice.DataFormat.R16G16B16A16Sfloat, + Width = (uint)Math.Max(1, size.X), + Height = (uint)Math.Max(1, size.Y), + Depth = 1, + ArrayLayers = 1, + Mipmaps = 1, + Samples = samples, + TextureType = RenderingDevice.TextureType.Type2D, + UsageBits = usageBits, + }; + + return _renderingDevice.TextureCreate( + textureFormat, + new RDTextureView(), + new Godot.Collections.Array() + ); + } + + private static Rid GetCachedFramebuffer(params Rid[] textures) + { + if (!AllRidsValid(textures)) + { + return new Rid(); + } + + var attachments = new Godot.Collections.Array(); + foreach (var texture in textures) + { + attachments.Add(texture); + } + + return FramebufferCacheRD.GetCacheMultipass( + attachments, + new Godot.Collections.Array(), + 1 + ); + } + + private Rid GetCachedSamplerUniformSet(Rid shader, params Rid[] textures) + { + if (!_sampler.IsValid || !shader.IsValid || !AllRidsValid(textures)) + { + return new Rid(); + } + + var uniforms = new Godot.Collections.Array(); + for (int index = 0; index < textures.Length; index++) + { + var uniform = new RDUniform + { + Binding = index, + UniformType = RenderingDevice.UniformType.SamplerWithTexture, + }; + uniform.AddId(_sampler); + uniform.AddId(textures[index]); + uniforms.Add(uniform); + } + + return UniformSetCacheRD.GetCache(shader, 0, uniforms); + } + + private Rid CreatePipelineForFramebuffer( + Rid shader, + Rid framebuffer, + bool useStencil, + RenderingDevice.TextureSamples sampleCount + ) + { + if (!framebuffer.IsValid) + { + return new Rid(); + } + + return CreatePipeline( + shader, + _renderingDevice.FramebufferGetFormat(framebuffer), + useStencil, + sampleCount + ); + } + + private Rid CreatePipeline( + Rid shader, + long framebufferFormat, + bool useStencil, + RenderingDevice.TextureSamples sampleCount + ) + { + if (!shader.IsValid + || framebufferFormat == RenderingDevice.InvalidId + || _fullscreenVertexFormat == RenderingDevice.InvalidId) + { + return new Rid(); + } + + var blendAttachment = new RDPipelineColorBlendStateAttachment + { + EnableBlend = false, + WriteR = true, + WriteG = true, + WriteB = true, + WriteA = true, + }; + + var blendAttachments = new Godot.Collections.Array + { + blendAttachment, + }; + var depthStencil = new RDPipelineDepthStencilState(); + if (useStencil) + { + ConfigureStencilTest(depthStencil); + } + + return _renderingDevice.RenderPipelineCreate( + shader, + framebufferFormat, + _fullscreenVertexFormat, + RenderingDevice.RenderPrimitive.Triangles, + new RDPipelineRasterizationState + { + CullMode = RenderingDevice.PolygonCullMode.Disabled, + }, + new RDPipelineMultisampleState + { + SampleCount = sampleCount, + }, + depthStencil, + new RDPipelineColorBlendState + { + Attachments = blendAttachments, + }, + (RenderingDevice.PipelineDynamicStateFlags)0, + 0, + new Godot.Collections.Array() + ); + } + + private void ConfigureStencilTest(RDPipelineDepthStencilState depthStencil) + { + depthStencil.EnableStencil = true; + + depthStencil.FrontOpFail = RenderingDevice.StencilOperation.Keep; + depthStencil.FrontOpPass = RenderingDevice.StencilOperation.Keep; + depthStencil.FrontOpDepthFail = RenderingDevice.StencilOperation.Keep; + depthStencil.FrontOpCompare = RenderingDevice.CompareOperator.Equal; + depthStencil.FrontOpCompareMask = 0xff; + depthStencil.FrontOpWriteMask = 0x00; + depthStencil.FrontOpReference = _stencilReference; + + depthStencil.BackOpFail = RenderingDevice.StencilOperation.Keep; + depthStencil.BackOpPass = RenderingDevice.StencilOperation.Keep; + depthStencil.BackOpDepthFail = RenderingDevice.StencilOperation.Keep; + depthStencil.BackOpCompare = RenderingDevice.CompareOperator.Equal; + depthStencil.BackOpCompareMask = 0xff; + depthStencil.BackOpWriteMask = 0x00; + depthStencil.BackOpReference = _stencilReference; + } + + private void DrawPass( + Rid framebuffer, + Rid pipeline, + Rid uniformSet, + byte[] pushConstants, + bool clearColor, + bool preserveDepthStencil + ) + { + if (!framebuffer.IsValid || !pipeline.IsValid || !uniformSet.IsValid) + { + return; + } + + var drawFlags = clearColor + ? RenderingDevice.DrawFlags.ClearColor0 + : 0; + if (!preserveDepthStencil) + { + drawFlags |= RenderingDevice.DrawFlags.IgnoreDepth; + drawFlags |= RenderingDevice.DrawFlags.IgnoreStencil; + } + + var drawList = _renderingDevice.DrawListBegin( + framebuffer, + drawFlags, + ClearColor, + 1.0f, + 0, + null, + 0 + ); + _renderingDevice.DrawListBindRenderPipeline(drawList, pipeline); + _renderingDevice.DrawListBindUniformSet(drawList, uniformSet, 0); + _renderingDevice.DrawListBindVertexArray(drawList, _fullscreenVertexArray); + if (pushConstants.Length > 0) + { + _renderingDevice.DrawListSetPushConstant(drawList, pushConstants, (uint)pushConstants.Length); + } + + _renderingDevice.DrawListDraw(drawList, false, 1, 0); + _renderingDevice.DrawListEnd(); + } + + private void ReleaseResources() + { + if (_resources == null) + { + return; + } + + if (_renderingDevice == null) + { + _resources = null; + return; + } + + foreach (var rid in _resources.PipelineRids) + { + FreeRenderPipelineRid(rid); + } + + foreach (var rid in _resources.TextureRids) + { + FreeTextureRid(rid); + } + + _resources = null; + } + + private void FreeRenderPipelineRid(Rid rid) + { + if (_renderingDevice != null + && rid.IsValid + && _renderingDevice.RenderPipelineIsValid(rid)) + { + _renderingDevice.FreeRid(rid); + } + } + + private void FreeTextureRid(Rid rid) + { + if (_renderingDevice != null + && rid.IsValid + && _renderingDevice.TextureIsValid(rid)) + { + _renderingDevice.FreeRid(rid); + } + } + + private void FreeOwnedRid(ref Rid rid) + { + if (_renderingDevice != null && rid.IsValid) + { + _renderingDevice.FreeRid(rid); + rid = new Rid(); + } + } + + private static int ComputeBloomLevels(Vector2I fullSize) + { + var glareSize = GetGlareImageSize(fullSize); + int smallerDimension = Math.Max(1, Math.Min(glareSize.X, glareSize.Y)); + float scaledDimension = Math.Max(1.0f, smallerDimension * Glow.BloomSize); + int levels = Math.Max(2, Mathf.FloorToInt(Mathf.Log(scaledDimension) / Mathf.Log(2.0f))); + return Math.Min(levels, MaxBloomLevels); + } + + private static Vector2I GetGlareImageSize(Vector2I fullSize) => new( + Math.Max(2, (fullSize.X + BloomQualityFactor - 1) / BloomQualityFactor), + Math.Max(2, (fullSize.Y + BloomQualityFactor - 1) / BloomQualityFactor) + ); + + private static Vector2I[] CreateDownsampleSizes(Vector2I fullSize, int bloomLevels) + { + var sizes = new Vector2I[bloomLevels]; + sizes[0] = GetGlareImageSize(fullSize); + for (int index = 1; index < sizes.Length; index++) + { + sizes[index] = new Vector2I( + Math.Max(2, sizes[index - 1].X / 2), + Math.Max(2, sizes[index - 1].Y / 2) + ); + } + + return sizes; + } + + private static bool HasStencil(RenderingDevice.DataFormat format) => + format == RenderingDevice.DataFormat.D16UnormS8Uint + || format == RenderingDevice.DataFormat.D24UnormS8Uint + || format == RenderingDevice.DataFormat.D32SfloatS8Uint; + + private static bool AllRidsValid(Rid[] rids) + { + if (rids == null || rids.Length == 0) + { + return false; + } + + foreach (var rid in rids) + { + if (!rid.IsValid) + { + return false; + } + } + + return true; + } + + private static byte[] CreateGlowCompositePushConstants() => PushConstants( + Glow.BloomTint.R, + Glow.BloomTint.G, + Glow.BloomTint.B, + Glow.BloomStrength + ); + + private byte[] CreateEdgeLightPushConstants( + Vector2I fullSize, + bool debugEdgeMask + ) => PushConstants( + 1.0f / Math.Max(1, fullSize.X), + 1.0f / Math.Max(1, fullSize.Y), + EdgeLight.WidthPixels, + EdgeLight.VerticalScale, + EdgeLight.DepthThresholdStart, + EdgeLight.DepthThresholdEnd, + EdgeLight.Strength, + EdgeLight.ValueBoost, + debugEdgeMask ? 1.0f : 0.0f, + _camera.Near, + _camera.Far, + EdgeLight.WidthReferenceDepth + ); + + private static byte[] CreateFinalColorPushConstants() => PushConstants( + NaesTonemap.A, + NaesTonemap.B, + NaesTonemap.C, + NaesTonemap.D, + NaesTonemap.E, + NaesTonemap.InputMax, + 0.0f, + 0.0f, + ToonColorGrade.LumaRiseStart, + ToonColorGrade.LumaRiseEnd, + ToonColorGrade.LumaFallStart, + ToonColorGrade.LumaFallEnd, + ToonColorGrade.LumaMidDip, + ToonColorGrade.VibranceLumaStart, + ToonColorGrade.VibranceLumaEnd, + ToonColorGrade.VibranceSaturationStart, + ToonColorGrade.VibranceSaturationEnd, + ToonColorGrade.ChromaBase, + ToonColorGrade.ChromaBoost, + 0.0f + ); + + private static byte[] PushConstants(params float[] values) + { + var bytes = new byte[sizeof(float) * values.Length]; + Buffer.BlockCopy(values, 0, bytes, 0, bytes.Length); + return bytes; + } + + private static byte[] CreateFullscreenTriangleVertexData() + { + var vertices = new[] + { + -1.0f, -1.0f, + -1.0f, 3.0f, + 3.0f, -1.0f, + }; + var bytes = new byte[vertices.Length * sizeof(float)]; + Buffer.BlockCopy(vertices, 0, bytes, 0, bytes.Length); + return bytes; + } + + private static void WarnOnce(ref bool printed, string message) + { + if (printed) + { + return; + } + + printed = true; + GD.PushWarning(message); + } + + private readonly record struct GlowSettings( + Color BloomTint, + float BloomStrength, + float BloomSize, + float HighlightThreshold, + float HighlightSmoothness, + float MaxHighlightBrightness + ); + + private readonly record struct EdgeLightSettings( + float WidthPixels, + float VerticalScale, + float DepthThresholdStart, + float DepthThresholdEnd, + float Strength, + float ValueBoost, + float WidthReferenceDepth + ); + + private readonly record struct NaesTonemapSettings( + float A, + float B, + float C, + float D, + float E, + float InputMax + ); + + private readonly record struct ToonColorGradeSettings( + float LumaRiseStart, + float LumaRiseEnd, + float LumaFallStart, + float LumaFallEnd, + float LumaMidDip, + float VibranceLumaStart, + float VibranceLumaEnd, + float VibranceSaturationStart, + float VibranceSaturationEnd, + float ChromaBase, + float ChromaBoost + ); + + private sealed class PostProcessResources + { + public Vector2I FullSize; + public Rid SceneColor; + public Rid StencilDepth; + public Rid ResolvedDepth; + public Rid NormalRoughness; + public bool IncludesAvatarMask; + public bool IncludesAvatarEdgeLight; + public StageRenderDebugView DebugView; + public int BloomLevels; + + public Rid AvatarMaskTexture; + public Rid AvatarMaskRenderTexture; + public bool AvatarMaskResolveRequired; + public Rid AvatarMaskFramebuffer; + public Rid AvatarMaskUniformSet; + public byte[] AvatarMaskPushConstants; + public Rid AvatarMaskPipeline; + + public Rid AvatarEdgeLightTexture; + public Rid AvatarEdgeLightFramebuffer; + public Rid AvatarEdgeLightUniformSet; + public byte[] AvatarEdgeLightPushConstants; + public Rid AvatarEdgeLightPipeline; + + public Rid SourceTexture; + public Rid SourceRenderTexture; + public bool SourceResolveRequired; + public Rid SourceFramebuffer; + public Rid SourceUniformSet; + public Rid ExtractPipeline; + public byte[] ExtractPushConstants; + + public Rid SceneSourceTexture; + public Rid SceneSourceFramebuffer; + public Rid SceneSourceUniformSet; + public Rid SceneCopyPipeline; + public byte[] SceneCopyPushConstants; + + public Vector2I[] DownsampleSizes; + public Rid[] DownsampleTextures; + public Rid[] DownsampleFramebuffers; + public Rid[] DownsampleUniformSets; + public byte[][] DownsamplePushConstants; + public Rid DownsamplePipeline; + + public Rid[] UpsampleTextures; + public Rid[] UpsampleFramebuffers; + public Rid[] UpsampleUniformSets; + public byte[][] UpsamplePushConstants; + public Rid UpsamplePipeline; + + public Rid GlowCompositeTexture; + public Rid GlowCompositeFramebuffer; + public Rid GlowCompositeUniformSet; + public byte[] GlowCompositePushConstants; + public Rid GlowCompositePipeline; + + public Rid FinalColorFramebuffer; + public Rid FinalColorUniformSet; + public byte[] FinalColorPushConstants; + public Rid FinalColorPipeline; + public Rid DebugOutputPipeline; + public Rid DebugSceneCopyUniformSet; + public Rid DebugAvatarMaskUniformSet; + public Rid DebugAvatarEdgeLightUniformSet; + public Rid DebugGlowCompositeUniformSet; + + public bool IsValid => + SceneSourceTexture.IsValid + && SceneSourceFramebuffer.IsValid + && SceneSourceUniformSet.IsValid + && SceneCopyPipeline.IsValid + && SceneCopyPushConstants != null + && DebugOutputPipeline.IsValid + && DebugSceneCopyUniformSet.IsValid + && FinalColorFramebuffer.IsValid + && FinalColorUniformSet.IsValid + && FinalColorPushConstants != null + && FinalColorPipeline.IsValid + && DebugOutputResourcesAreValid + && (!IncludesAvatarEdgeLight || AvatarEdgeLightResourcesAreValid) + && (!IncludesAvatarMask || AvatarGlowResourcesAreValid); + + private bool DebugOutputResourcesAreValid => + !IncludesAvatarMask + || ( + DebugAvatarMaskUniformSet.IsValid + && DebugGlowCompositeUniformSet.IsValid + && (!IncludesAvatarEdgeLight || DebugAvatarEdgeLightUniformSet.IsValid) + ); + + private bool AvatarGlowResourcesAreValid => + AvatarMaskTexture.IsValid + && AvatarMaskRenderTexture.IsValid + && AvatarMaskFramebuffer.IsValid + && AvatarMaskUniformSet.IsValid + && AvatarMaskPushConstants != null + && AvatarMaskPipeline.IsValid + && SourceTexture.IsValid + && SourceRenderTexture.IsValid + && SourceFramebuffer.IsValid + && SourceUniformSet.IsValid + && ExtractPipeline.IsValid + && ExtractPushConstants != null + && DownsampleSizes != null + && DownsampleSizes.Length == BloomLevels + && StagePostProcessCompositorEffect.AllRidsValid(DownsampleTextures) + && StagePostProcessCompositorEffect.AllRidsValid(DownsampleFramebuffers) + && StagePostProcessCompositorEffect.AllRidsValid(DownsampleUniformSets) + && AllArraysPresent(DownsamplePushConstants) + && DownsamplePipeline.IsValid + && StagePostProcessCompositorEffect.AllRidsValid(UpsampleTextures) + && StagePostProcessCompositorEffect.AllRidsValid(UpsampleFramebuffers) + && StagePostProcessCompositorEffect.AllRidsValid(UpsampleUniformSets) + && AllArraysPresent(UpsamplePushConstants) + && UpsamplePipeline.IsValid + && GlowCompositeTexture.IsValid + && GlowCompositeFramebuffer.IsValid + && GlowCompositeUniformSet.IsValid + && GlowCompositePushConstants != null + && GlowCompositePipeline.IsValid; + + private bool AvatarEdgeLightResourcesAreValid => + ResolvedDepth.IsValid + && NormalRoughness.IsValid + && AvatarEdgeLightTexture.IsValid + && AvatarEdgeLightFramebuffer.IsValid + && AvatarEdgeLightUniformSet.IsValid + && AvatarEdgeLightPushConstants != null + && AvatarEdgeLightPipeline.IsValid; + + private static bool AllArraysPresent(byte[][] arrays) + { + if (arrays == null || arrays.Length == 0) + { + return false; + } + + foreach (var array in arrays) + { + if (array == null) + { + return false; + } + } + + return true; + } + + public IEnumerable TextureRids + { + get + { + yield return AvatarMaskTexture; + if (AvatarMaskRenderTexture.IsValid && AvatarMaskRenderTexture != AvatarMaskTexture) + { + yield return AvatarMaskRenderTexture; + } + + yield return AvatarEdgeLightTexture; + yield return SourceTexture; + if (SourceRenderTexture.IsValid && SourceRenderTexture != SourceTexture) + { + yield return SourceRenderTexture; + } + + yield return SceneSourceTexture; + + for (int level = 0; DownsampleTextures != null && level < DownsampleTextures.Length; level++) + { + yield return DownsampleTextures[level]; + } + + for (int level = 0; UpsampleTextures != null && level < UpsampleTextures.Length; level++) + { + yield return UpsampleTextures[level]; + } + + yield return GlowCompositeTexture; + } + } + + public IEnumerable PipelineRids + { + get + { + yield return SceneCopyPipeline; + yield return AvatarMaskPipeline; + yield return AvatarEdgeLightPipeline; + yield return ExtractPipeline; + yield return DownsamplePipeline; + yield return UpsamplePipeline; + yield return GlowCompositePipeline; + yield return FinalColorPipeline; + yield return DebugOutputPipeline; + } + } + } +} diff --git a/engines/stage-tamagotchi-godot/scripts/visuals/StagePostProcessCompositorEffect.Shaders.cs b/engines/stage-tamagotchi-godot/scripts/visuals/StagePostProcessCompositorEffect.Shaders.cs new file mode 100644 index 000000000..96fd5319d --- /dev/null +++ b/engines/stage-tamagotchi-godot/scripts/visuals/StagePostProcessCompositorEffect.Shaders.cs @@ -0,0 +1,496 @@ +public partial class StagePostProcessCompositorEffect +{ + private const string FullscreenVertexShaderCode = """ + #version 450 + + layout(location = 0) in vec2 position; + layout(location = 0) out vec2 uv; + + void main() + { + uv = position * 0.5 + vec2(0.5); + gl_Position = vec4(position, 0.0, 1.0); + } + """; + + private const string CopySceneFragmentShaderCode = """ + #version 450 + + layout(set = 0, binding = 0) uniform sampler2D scene_texture; + + layout(location = 0) in vec2 uv; + layout(location = 0) out vec4 out_color; + + void main() + { + out_color = texture(scene_texture, uv); + } + """; + + private const string AvatarMaskFragmentShaderCode = """ + #version 450 + + layout(set = 0, binding = 0) uniform sampler2D scene_texture; + + layout(location = 0) in vec2 uv; + layout(location = 0) out vec4 out_color; + + void main() + { + out_color = vec4(1.0); + } + """; + + private const string EdgeLightFragmentShaderCode = """ + #version 450 + + layout(set = 0, binding = 0) uniform sampler2D scene_texture; + layout(set = 0, binding = 1) uniform sampler2D depth_texture; + layout(set = 0, binding = 2) uniform sampler2D normal_roughness_texture; + layout(set = 0, binding = 3) uniform sampler2D avatar_mask_texture; + layout(push_constant, std430) uniform Params + { + vec4 edge0; + vec4 edge1; + vec4 edge2; + } params; + + layout(location = 0) in vec2 uv; + layout(location = 0) out vec4 out_color; + + vec4 normal_roughness_compatibility(vec4 normal_roughness) + { + float roughness = normal_roughness.w; + if (roughness > 0.5) + { + roughness = 1.0 - roughness; + } + + roughness /= 127.0 / 255.0; + return vec4(normalize(normal_roughness.xyz * 2.0 - 1.0) * 0.5 + 0.5, roughness); + } + + float sample_mask(vec2 sample_uv) + { + return clamp(texture(avatar_mask_texture, sample_uv).r, 0.0, 1.0); + } + + float linearize_reverse_z_depth(float depth, float near_plane, float far_plane) + { + return (near_plane * far_plane) / max(near_plane + depth * (far_plane - near_plane), 0.00001); + } + + float sample_linear_depth(vec2 sample_uv, float near_plane, float far_plane) + { + float depth = texture(depth_texture, clamp(sample_uv, vec2(0.0), vec2(1.0))).r; + return linearize_reverse_z_depth(depth, near_plane, far_plane); + } + + float sample_filtered_displaced_linear_depth( + vec2 sample_uv, + vec2 offset_uv, + vec2 pixel_size, + float near_plane, + float far_plane + ) + { + vec2 major_axis = length(offset_uv) > 0.000001 + ? normalize(offset_uv) + : vec2(1.0, 0.0); + vec2 minor_axis = vec2(-major_axis.y, major_axis.x); + vec2 filter_major = major_axis * pixel_size; + vec2 filter_minor = minor_axis * pixel_size; + + float filtered_depth = sample_linear_depth(sample_uv, near_plane, far_plane) * 0.5; + filtered_depth += sample_linear_depth(sample_uv + filter_major, near_plane, far_plane) * 0.125; + filtered_depth += sample_linear_depth(sample_uv - filter_major, near_plane, far_plane) * 0.125; + filtered_depth += sample_linear_depth(sample_uv + filter_minor, near_plane, far_plane) * 0.125; + filtered_depth += sample_linear_depth(sample_uv - filter_minor, near_plane, far_plane) * 0.125; + return filtered_depth; + } + + void main() + { + vec4 scene = texture(scene_texture, uv); + float avatar_mask = sample_mask(uv); + float debug_edge_mask = params.edge2.x; + if (avatar_mask <= 0.001) + { + if (debug_edge_mask > 0.5) + { + out_color = vec4(0.0, 0.0, 0.0, 1.0); + return; + } + + out_color = scene; + return; + } + + float width_pixels = params.edge0.z; + float vertical_scale = params.edge0.w; + float threshold_start = params.edge1.x; + float threshold_end = params.edge1.y; + float strength = params.edge1.z; + float value_boost = params.edge1.w; + float near_plane = max(params.edge2.y, 0.00001); + float far_plane = max(params.edge2.z, near_plane + 0.00001); + float width_reference_depth = max(params.edge2.w, near_plane); + + float current_depth = texture(depth_texture, uv).r; + float current_linear_depth = linearize_reverse_z_depth(current_depth, near_plane, far_plane); + float depth_width_scale = width_reference_depth / max(current_linear_depth, near_plane); + float effective_width_pixels = clamp(width_pixels * depth_width_scale, width_pixels * 0.35, width_pixels * 1.5); + + vec2 pixel_size = params.edge0.xy; + vec4 normal_roughness = normal_roughness_compatibility( + texture(normal_roughness_texture, uv) + ); + vec3 view_normal = normalize(normal_roughness.xyz * 2.0 - 1.0); + vec2 offset_pixels = view_normal.xy * vec2(-effective_width_pixels, -effective_width_pixels * vertical_scale); + vec2 offset_uv = offset_pixels * pixel_size; + vec2 shifted_uv = clamp(uv + offset_uv, vec2(0.0), vec2(1.0)); + + float shifted_linear_depth = sample_filtered_displaced_linear_depth( + shifted_uv, + offset_uv, + pixel_size, + near_plane, + far_plane + ); + float linear_depth_delta = max(shifted_linear_depth - current_linear_depth, 0.0); + float depth_edge = smoothstep( + threshold_start, + threshold_end, + linear_depth_delta + ); + + float edge_mask = clamp(depth_edge * avatar_mask * strength, 0.0, 1.0); + if (debug_edge_mask > 0.5) + { + out_color = vec4(vec3(edge_mask), 1.0); + return; + } + + vec3 brightened = scene.rgb * value_boost; + out_color = vec4(mix(scene.rgb, brightened, edge_mask), scene.a); + } + """; + + private const string ExtractHighlightsFragmentShaderCode = """ + #version 450 + + layout(set = 0, binding = 0) uniform sampler2D input_texture; + layout(push_constant, std430) uniform Params + { + vec4 values; + } params; + + layout(location = 0) in vec2 uv; + layout(location = 0) out vec4 out_color; + + float max_channel_of(vec3 color) + { + return max(max(color.r, color.g), color.b); + } + + float smooth_min(float a, float b, float smoothness) + { + if (smoothness == 0.0) + { + return min(a, b); + } + + float h = max(smoothness - abs(a - b), 0.0) / smoothness; + return min(a, b) - h * h * smoothness * 0.25; + } + + float smooth_max(float a, float b, float smoothness) + { + return -smooth_min(-a, -b, smoothness); + } + + float smooth_clamp( + float value, + float min_value, + float max_value, + float min_smoothness, + float max_smoothness + ) + { + return smooth_min( + max_value, + smooth_max(min_value, value, min_smoothness), + max_smoothness + ); + } + + float adaptive_smooth_clamp( + float value, + float min_value, + float max_value, + float smoothness + ) + { + float range_distance = abs(max_value - min_value); + float min_smoothness = min(smoothness, min(min_value, range_distance)); + float max_smoothness = min(smoothness, min(max_value, range_distance)); + return smooth_clamp(value, min_value, max_value, min_smoothness, max_smoothness); + } + + void main() + { + vec3 color = texture(input_texture, uv).rgb; + float threshold = params.values.x; + float smoothness = params.values.y; + float max_brightness = params.values.z; + + float value = max_channel_of(color); + float clamped_value = adaptive_smooth_clamp( + value, + threshold, + threshold + max_brightness, + smoothness + ); + float extracted_value = max(clamped_value - threshold, 0.0); + float source = extracted_value / max(value, 0.001); + out_color = vec4(color * source, 1.0); + } + """; + + private const string DownsampleFragmentShaderCode = """ + #version 450 + + layout(set = 0, binding = 0) uniform sampler2D input_texture; + layout(push_constant, std430) uniform Params + { + vec4 values; + } params; + + layout(location = 0) in vec2 uv; + layout(location = 0) out vec4 out_color; + + float reduce_max(vec4 color) + { + return max(max(max(color.r, color.g), color.b), color.a); + } + + vec4 weighted_sum(vec4 a, vec4 b, vec4 c, vec4 d, vec4 weights) + { + float total_weight = weights.x + weights.y + weights.z + weights.w; + return (a * weights.x + b * weights.y + c * weights.z + d * weights.w) / + max(total_weight, 0.0001); + } + + vec4 karis_brightness_weighted_sum(vec4 a, vec4 b, vec4 c, vec4 d) + { + vec4 brightness = vec4(reduce_max(a), reduce_max(b), reduce_max(c), reduce_max(d)); + vec4 weights = vec4(1.0) / (brightness + vec4(1.0)); + return weighted_sum(a, b, c, d, weights); + } + + void main() + { + vec2 pixel_size = params.values.xy; + float use_karis_average = params.values.z; + + vec4 center = texture(input_texture, uv); + vec4 upper_left_near = texture(input_texture, uv + pixel_size * vec2(-1.0, 1.0)); + vec4 upper_right_near = texture(input_texture, uv + pixel_size * vec2(1.0, 1.0)); + vec4 lower_left_near = texture(input_texture, uv + pixel_size * vec2(-1.0, -1.0)); + vec4 lower_right_near = texture(input_texture, uv + pixel_size * vec2(1.0, -1.0)); + vec4 left_far = texture(input_texture, uv + pixel_size * vec2(-2.0, 0.0)); + vec4 right_far = texture(input_texture, uv + pixel_size * vec2(2.0, 0.0)); + vec4 upper_far = texture(input_texture, uv + pixel_size * vec2(0.0, 2.0)); + vec4 lower_far = texture(input_texture, uv + pixel_size * vec2(0.0, -2.0)); + vec4 upper_left_far = texture(input_texture, uv + pixel_size * vec2(-2.0, 2.0)); + vec4 upper_right_far = texture(input_texture, uv + pixel_size * vec2(2.0, 2.0)); + vec4 lower_left_far = texture(input_texture, uv + pixel_size * vec2(-2.0, -2.0)); + vec4 lower_right_far = texture(input_texture, uv + pixel_size * vec2(2.0, -2.0)); + + vec4 result; + if (use_karis_average > 0.5) + { + vec4 center_weighted_sum = karis_brightness_weighted_sum( + upper_left_near, + upper_right_near, + lower_right_near, + lower_left_near + ); + vec4 upper_left_weighted_sum = karis_brightness_weighted_sum( + upper_left_far, + upper_far, + center, + left_far + ); + vec4 upper_right_weighted_sum = karis_brightness_weighted_sum( + upper_far, + upper_right_far, + right_far, + center + ); + vec4 lower_right_weighted_sum = karis_brightness_weighted_sum( + center, + right_far, + lower_right_far, + lower_far + ); + vec4 lower_left_weighted_sum = karis_brightness_weighted_sum( + left_far, + center, + lower_far, + lower_left_far + ); + + result = center_weighted_sum * (4.0 / 8.0) + + ( + upper_left_weighted_sum + + upper_right_weighted_sum + + lower_left_weighted_sum + + lower_right_weighted_sum + ) * (1.0 / 8.0); + } + else + { + result = center * (4.0 / 32.0) + + ( + upper_left_near + + upper_right_near + + lower_left_near + + lower_right_near + ) * (4.0 / 32.0) + + (left_far + right_far + upper_far + lower_far) * (2.0 / 32.0) + + ( + upper_left_far + + upper_right_far + + lower_left_far + + lower_right_far + ) * (1.0 / 32.0); + } + + out_color = vec4(result.rgb, 1.0); + } + """; + + private const string UpsampleFragmentShaderCode = """ + #version 450 + + layout(set = 0, binding = 0) uniform sampler2D base_texture; + layout(set = 0, binding = 1) uniform sampler2D input_texture; + layout(push_constant, std430) uniform Params + { + vec4 values; + } params; + + layout(location = 0) in vec2 uv; + layout(location = 0) out vec4 out_color; + + void main() + { + vec2 pixel_size = params.values.xy; + vec4 upsampled = vec4(0.0); + upsampled += texture(input_texture, uv) * (4.0 / 16.0); + upsampled += texture(input_texture, uv + pixel_size * vec2(-1.0, 0.0)) * (2.0 / 16.0); + upsampled += texture(input_texture, uv + pixel_size * vec2(0.0, 1.0)) * (2.0 / 16.0); + upsampled += texture(input_texture, uv + pixel_size * vec2(1.0, 0.0)) * (2.0 / 16.0); + upsampled += texture(input_texture, uv + pixel_size * vec2(0.0, -1.0)) * (2.0 / 16.0); + upsampled += texture(input_texture, uv + pixel_size * vec2(-1.0, -1.0)) * (1.0 / 16.0); + upsampled += texture(input_texture, uv + pixel_size * vec2(-1.0, 1.0)) * (1.0 / 16.0); + upsampled += texture(input_texture, uv + pixel_size * vec2(1.0, -1.0)) * (1.0 / 16.0); + upsampled += texture(input_texture, uv + pixel_size * vec2(1.0, 1.0)) * (1.0 / 16.0); + + vec3 base = texture(base_texture, uv).rgb; + out_color = vec4(base + upsampled.rgb, 1.0); + } + """; + + private const string GlowCompositeFragmentShaderCode = """ + #version 450 + + layout(set = 0, binding = 0) uniform sampler2D scene_texture; + layout(set = 0, binding = 1) uniform sampler2D bloom_texture; + layout(push_constant, std430) uniform Params + { + vec4 bloom_tint_strength; + } params; + + layout(location = 0) in vec2 uv; + layout(location = 0) out vec4 out_color; + + void main() + { + vec4 scene = texture(scene_texture, uv); + vec3 bloom = texture(bloom_texture, uv).rgb; + vec3 hdr = max( + scene.rgb + bloom * params.bloom_tint_strength.rgb * params.bloom_tint_strength.a, + vec3(0.0) + ); + out_color = vec4(hdr, scene.a); + } + """; + + private const string FinalColorFragmentShaderCode = """ + #version 450 + + layout(set = 0, binding = 0) uniform sampler2D hdr_texture; + layout(push_constant, std430) uniform Params + { + vec4 naes_curve0; + vec4 naes_curve1; + vec4 luma_curve; + vec4 color_grade0; + vec4 color_grade1; + } params; + + layout(location = 0) in vec2 uv; + layout(location = 0) out vec4 out_color; + + vec3 clamp_tonemap_input(vec3 color) + { + return clamp(max(color, vec3(0.0)), 0.0, params.naes_curve1.y); + } + + vec3 naes_tonemap(vec3 color) + { + vec3 x = clamp_tonemap_input(color); + return (x * (params.naes_curve0.x * x + vec3(params.naes_curve0.y))) / + ( + x * (params.naes_curve0.z * x + vec3(params.naes_curve0.w)) + + vec3(params.naes_curve1.x) + ); + } + + float apply_luma_curve(float luma) + { + float mid_gate = + smoothstep(params.luma_curve.x, params.luma_curve.y, luma) * + (1.0 - smoothstep(params.luma_curve.z, params.luma_curve.w, luma)); + return max(luma * (1.0 - params.color_grade0.x * mid_gate), 0.0); + } + + vec3 apply_toon_color_grade(vec3 color) + { + float luma = dot(color, vec3(0.2126, 0.7152, 0.0722)); + float max_channel = max(max(color.r, color.g), color.b); + float min_channel = min(min(color.r, color.g), color.b); + float saturation = max_channel <= 0.001 + ? 0.0 + : (max_channel - min_channel) / max_channel; + float luma_gate = smoothstep(params.color_grade0.y, params.color_grade0.z, luma); + float saturation_gate = + 1.0 - smoothstep(params.color_grade0.w, params.color_grade1.x, saturation); + float chroma_scale = + params.color_grade1.y + params.color_grade1.z * luma_gate * saturation_gate; + float luma2 = apply_luma_curve(luma); + vec3 gray = vec3(luma); + + return max(vec3(luma2) + (color - gray) * chroma_scale, vec3(0.0)); + } + + void main() + { + vec4 hdr = texture(hdr_texture, uv); + vec3 mapped = apply_toon_color_grade(naes_tonemap(hdr.rgb)); + out_color = vec4(mapped, hdr.a); + } + """; +} diff --git a/engines/stage-tamagotchi-godot/scripts/visuals/StagePostProcessCompositorEffect.cs b/engines/stage-tamagotchi-godot/scripts/visuals/StagePostProcessCompositorEffect.cs new file mode 100644 index 000000000..1814bb93e --- /dev/null +++ b/engines/stage-tamagotchi-godot/scripts/visuals/StagePostProcessCompositorEffect.cs @@ -0,0 +1,289 @@ +using System; +using Godot; + +/// +/// Runs same-frame stage post-process work for avatar source effects and final color mapping. +/// +public partial class StagePostProcessCompositorEffect : CompositorEffect +{ + private const int MaxBloomLevels = 9; + private const int BloomQualityFactor = 2; + + private static readonly GlowSettings Glow = new( + BloomTint: new Color(1.0f, 0.6938719749450684f, 0.6795425415039062f), + BloomStrength: 0.09f, + BloomSize: 0.26f, + HighlightThreshold: 0.56f, + HighlightSmoothness: 0.5f, + MaxHighlightBrightness: 1.0e20f + ); + + private static readonly EdgeLightSettings EdgeLight = new( + WidthPixels: 8.5f, + VerticalScale: 0.6f, + DepthThresholdStart: 0.030f, + DepthThresholdEnd: 0.135f, + Strength: 1.0f, + ValueBoost: 2.1f, + WidthReferenceDepth: 0.65f + ); + + private static readonly NaesTonemapSettings NaesTonemap = new( + A: 1.36f, + B: 0.047f, + C: 0.93f, + D: 0.56f, + E: 0.14f, + InputMax: 10.0f + ); + + private static readonly ToonColorGradeSettings ToonColorGrade = new( + LumaRiseStart: 0.42f, + LumaRiseEnd: 0.58f, + LumaFallStart: 0.66f, + LumaFallEnd: 0.78f, + LumaMidDip: 0.050f, + VibranceLumaStart: 0.24f, + VibranceLumaEnd: 0.50f, + VibranceSaturationStart: 0.10f, + VibranceSaturationEnd: 0.70f, + ChromaBase: 1.05f, + ChromaBoost: 0.47f + ); + + private static readonly Color[] ClearColor = { new(0.0f, 0.0f, 0.0f, 0.0f) }; + + private readonly uint _stencilReference; + private readonly Camera3D _camera; + + private RenderingDevice _renderingDevice; + private Rid _sampler; + private long _fullscreenVertexFormat = RenderingDevice.InvalidId; + private Rid _fullscreenVertexBuffer; + private Rid _fullscreenVertexArray; + private Rid _copyShader; + private Rid _avatarMaskShader; + private Rid _edgeLightShader; + private Rid _extractShader; + private Rid _downsampleShader; + private Rid _upsampleShader; + private Rid _glowCompositeShader; + private Rid _finalColorShader; + private PostProcessResources _resources; + private bool _missingRenderingDeviceWarningPrinted; + private bool _missingRenderBuffersWarningPrinted; + private bool _missingStencilWarningPrinted; + private bool _missingNormalRoughnessWarningPrinted; + private bool _missingResolvedDepthWarningPrinted; + private bool _avatarMaskResolveWarningPrinted; + private bool _sourceResolveWarningPrinted; + + private bool _avatarMaskEnabled; + private bool _avatarEdgeLightEnabled = true; + private bool _finalColorMappingEnabled = true; + private StageRenderDebugView _debugView = StageRenderDebugView.Final; + + private static readonly StageRenderPipelineStage[] PipelineStages = + { + StageRenderPipelineStage.SceneCopy, + StageRenderPipelineStage.AvatarMask, + StageRenderPipelineStage.AvatarEdgeLight, + StageRenderPipelineStage.AvatarGlow, + StageRenderPipelineStage.FinalColorMapping, + }; + + public StagePostProcessCompositorEffect(int stencilReference, Camera3D camera) + { + _stencilReference = (uint)stencilReference; + _camera = camera ?? throw new ArgumentNullException(nameof(camera)); + AccessResolvedColor = true; + AccessResolvedDepth = true; + NeedsNormalRoughness = true; + EffectCallbackType = EffectCallbackTypeEnum.PostTransparent; + UpdateEnabled(); + } + + public bool AvatarMaskEnabled + { + get => _avatarMaskEnabled; + set + { + _avatarMaskEnabled = value; + UpdateEnabled(); + } + } + + public bool FinalColorMappingEnabled + { + get => _finalColorMappingEnabled; + set + { + _finalColorMappingEnabled = value; + UpdateEnabled(); + } + } + + public bool AvatarEdgeLightEnabled + { + get => _avatarEdgeLightEnabled; + set => _avatarEdgeLightEnabled = value; + } + + internal StageRenderDebugView DebugView + { + get => _debugView; + set + { + _debugView = value; + UpdateEnabled(); + } + } + + private void UpdateEnabled() + { + Enabled = _finalColorMappingEnabled || _debugView != StageRenderDebugView.Final; + } + + public override void _RenderCallback(int effectCallbackType, RenderData renderData) + { + if (!Enabled || (EffectCallbackTypeEnum)effectCallbackType != EffectCallbackType) + { + return; + } + + if (!EnsureRenderingDevice()) + { + return; + } + + if (renderData.GetRenderSceneBuffers() is not RenderSceneBuffersRD buffers) + { + WarnOnce( + ref _missingRenderBuffersWarningPrinted, + "Stage post-process requires RenderSceneBuffersRD; compositor callback had no RD buffers." + ); + return; + } + + var fullSize = buffers.GetInternalSize(); + if (fullSize.X < 2 || fullSize.Y < 2) + { + return; + } + + var sceneColor = buffers.GetColorTexture(false); + if (!sceneColor.IsValid) + { + return; + } + + bool includeAvatarMask = _avatarMaskEnabled; + var stencilDepth = new Rid(); + var resolvedDepth = new Rid(); + var normalRoughness = new Rid(); + bool includeAvatarEdgeLight = false; + if (includeAvatarMask) + { + stencilDepth = SelectStencilDepthTexture(buffers); + if (!stencilDepth.IsValid) + { + includeAvatarMask = false; + } + else + { + var sceneDepthFormat = _renderingDevice.TextureGetFormat(stencilDepth).Format; + if (!HasStencil(sceneDepthFormat)) + { + WarnOnce( + ref _missingStencilWarningPrinted, + $"Avatar mask needs a stencil depth texture; scene depth format is {sceneDepthFormat}." + ); + includeAvatarMask = false; + stencilDepth = new Rid(); + } + } + + if (includeAvatarMask) + { + resolvedDepth = buffers.GetDepthTexture(false); + if (!resolvedDepth.IsValid) + { + WarnOnce( + ref _missingResolvedDepthWarningPrinted, + "Avatar edge light needs a resolved depth texture." + ); + } + + normalRoughness = GetNormalRoughnessTexture(buffers); + if (!normalRoughness.IsValid) + { + WarnOnce( + ref _missingNormalRoughnessWarningPrinted, + "Avatar edge light needs forward_clustered/normal_roughness." + ); + } + + includeAvatarEdgeLight = + _avatarEdgeLightEnabled + && resolvedDepth.IsValid + && normalRoughness.IsValid; + } + } + + if (!EnsureResources( + fullSize, + sceneColor, + stencilDepth, + resolvedDepth, + normalRoughness, + includeAvatarMask, + includeAvatarEdgeLight, + _debugView + )) + { + return; + } + + RunPipeline(); + } + + public override void _Notification(int what) + { + if (what != NotificationPredelete) + { + return; + } + + ReleaseRenderingResources(); + } + + public void ReleaseRenderingResources() + { + if (_renderingDevice != null && !RenderingServer.IsOnRenderThread()) + { + RenderingServer.CallOnRenderThread(Callable.From(ReleaseRenderingResourcesOnRenderThread)); + RenderingServer.ForceSync(); + return; + } + + ReleaseRenderingResourcesOnRenderThread(); + } + + private void ReleaseRenderingResourcesOnRenderThread() + { + ReleaseResources(); + FreeOwnedRid(ref _sampler); + FreeOwnedRid(ref _fullscreenVertexArray); + FreeOwnedRid(ref _fullscreenVertexBuffer); + FreeOwnedRid(ref _copyShader); + FreeOwnedRid(ref _avatarMaskShader); + FreeOwnedRid(ref _edgeLightShader); + FreeOwnedRid(ref _extractShader); + FreeOwnedRid(ref _downsampleShader); + FreeOwnedRid(ref _upsampleShader); + FreeOwnedRid(ref _glowCompositeShader); + FreeOwnedRid(ref _finalColorShader); + _fullscreenVertexFormat = RenderingDevice.InvalidId; + _renderingDevice = null; + } +} diff --git a/engines/stage-tamagotchi-godot/scripts/visuals/StageRenderEffectsRuntime.cs b/engines/stage-tamagotchi-godot/scripts/visuals/StageRenderEffectsRuntime.cs new file mode 100644 index 000000000..4e240ea52 --- /dev/null +++ b/engines/stage-tamagotchi-godot/scripts/visuals/StageRenderEffectsRuntime.cs @@ -0,0 +1,68 @@ +using System; +using Godot; + +/// +/// Coordinates stage render-effect ownership for avatar source overlays and post-processing. +/// +public sealed class StageRenderEffectsRuntime : IDisposable +{ + private const int AvatarStencilReference = 1; + + private readonly StageCompositorOwner _compositorOwner; + private readonly StageMaterialOverlayOwner _overlayOwner; + private readonly StagePostProcessCompositorEffect _postProcessEffect; + private bool _disposed; + + public StageRenderEffectsRuntime(Camera3D camera) + { + _postProcessEffect = new StagePostProcessCompositorEffect(AvatarStencilReference, camera); + _overlayOwner = new StageMaterialOverlayOwner(AvatarStencilReference); + _compositorOwner = new StageCompositorOwner(camera, _postProcessEffect); + } + + public void UseAvatar(Node avatar) + { + if (_disposed) + { + return; + } + + _overlayOwner.UseAvatarMask(avatar); + _postProcessEffect.AvatarMaskEnabled = + _overlayOwner.HasSource(StageMaterialOverlaySourceKind.AvatarMask); + } + + public string CurrentDebugView => StageRenderDebugViewNames.ToTransportValue(_postProcessEffect.DebugView); + + public string SetDebugView(string view) + { + if (!StageRenderDebugViewNames.TryParse(view, out var debugView)) + { + throw new ArgumentException($"Unknown render debug view: {view}.", nameof(view)); + } + + _postProcessEffect.DebugView = debugView; + return StageRenderDebugViewNames.ToTransportValue(debugView); + } + + public bool SetAvatarEdgeLightEnabled(bool enabled) + { + _postProcessEffect.AvatarEdgeLightEnabled = enabled; + return _postProcessEffect.AvatarEdgeLightEnabled; + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + _postProcessEffect.AvatarMaskEnabled = false; + _postProcessEffect.FinalColorMappingEnabled = false; + _overlayOwner.Dispose(); + _compositorOwner.Dispose(); + _postProcessEffect.ReleaseRenderingResources(); + } +} diff --git a/engines/stage-tamagotchi-godot/tools/captureWindowClientPng.ps1 b/engines/stage-tamagotchi-godot/tools/captureWindowClientPng.ps1 new file mode 100644 index 000000000..26e1b702b --- /dev/null +++ b/engines/stage-tamagotchi-godot/tools/captureWindowClientPng.ps1 @@ -0,0 +1,241 @@ +param( + [Parameter(Mandatory = $true)] + [int] $TargetProcessId, + + [Parameter(Mandatory = $true)] + [string] $OutputPath, + + [int] $TimeoutMs = 15000, + + [int] $SettleMs = 1000 +) + +$ErrorActionPreference = 'Stop' + +Add-Type -AssemblyName System.Drawing + +Add-Type @" +using System; +using System.Runtime.InteropServices; +using System.Text; + +public static class WindowCaptureNative +{ + public delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam); + + [DllImport("user32.dll")] + public static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam); + + [DllImport("user32.dll")] + public static extern bool IsWindowVisible(IntPtr hWnd); + + [DllImport("user32.dll")] + public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint lpdwProcessId); + + [DllImport("user32.dll", CharSet = CharSet.Unicode)] + public static extern int GetWindowText(IntPtr hWnd, StringBuilder lpString, int nMaxCount); + + [DllImport("user32.dll")] + public static extern int GetWindowTextLength(IntPtr hWnd); + + [DllImport("user32.dll")] + public static extern bool GetClientRect(IntPtr hWnd, out Rect lpRect); + + [DllImport("user32.dll")] + public static extern bool ClientToScreen(IntPtr hWnd, ref Point lpPoint); + + [DllImport("user32.dll")] + public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow); + + [DllImport("user32.dll")] + public static extern bool SetForegroundWindow(IntPtr hWnd); + + [DllImport("user32.dll")] + public static extern bool SetWindowPos( + IntPtr hWnd, + IntPtr hWndInsertAfter, + int X, + int Y, + int cx, + int cy, + uint uFlags + ); + + [DllImport("user32.dll")] + public static extern bool SetProcessDPIAware(); + + [StructLayout(LayoutKind.Sequential)] + public struct Rect + { + public int Left; + public int Top; + public int Right; + public int Bottom; + } + + [StructLayout(LayoutKind.Sequential)] + public struct Point + { + public int X; + public int Y; + } + + public struct WindowInfo + { + public IntPtr Handle; + public int X; + public int Y; + public int Width; + public int Height; + public string Title; + } + + public static WindowInfo FindBestWindow(int processId) + { + WindowInfo best = new WindowInfo(); + + EnumWindows(delegate (IntPtr hWnd, IntPtr lParam) + { + uint windowProcessId; + GetWindowThreadProcessId(hWnd, out windowProcessId); + if (windowProcessId != processId || !IsWindowVisible(hWnd)) + { + return true; + } + + WindowInfo candidate = GetWindowInfo(hWnd); + long bestArea = (long)best.Width * best.Height; + long candidateArea = (long)candidate.Width * candidate.Height; + if (candidateArea > bestArea) + { + best = candidate; + } + + return true; + }, IntPtr.Zero); + + return best; + } + + public static WindowInfo GetWindowInfo(IntPtr hWnd) + { + Rect rect; + if (!GetClientRect(hWnd, out rect)) + { + return new WindowInfo(); + } + + Point point = new Point(); + if (!ClientToScreen(hWnd, ref point)) + { + return new WindowInfo(); + } + + return new WindowInfo + { + Handle = hWnd, + X = point.X, + Y = point.Y, + Width = rect.Right - rect.Left, + Height = rect.Bottom - rect.Top, + Title = GetTitle(hWnd), + }; + } + + private static string GetTitle(IntPtr hWnd) + { + int length = GetWindowTextLength(hWnd); + if (length <= 0) + { + return ""; + } + + StringBuilder builder = new StringBuilder(length + 1); + GetWindowText(hWnd, builder, builder.Capacity); + return builder.ToString(); + } +} +"@ + +try { + [WindowCaptureNative]::SetProcessDPIAware() | Out-Null +} +catch { + # The PowerShell host may already have a DPI awareness context. +} + +$deadline = [DateTime]::UtcNow.AddMilliseconds($TimeoutMs) +$window = [WindowCaptureNative]::FindBestWindow($TargetProcessId) +while ($window.Handle -eq [IntPtr]::Zero -and [DateTime]::UtcNow -lt $deadline) { + Start-Sleep -Milliseconds 100 + $window = [WindowCaptureNative]::FindBestWindow($TargetProcessId) +} + +if ($window.Handle -eq [IntPtr]::Zero) { + throw "Could not find a visible top-level window for process $TargetProcessId." +} + +[WindowCaptureNative]::ShowWindow($window.Handle, 9) | Out-Null +[WindowCaptureNative]::SetWindowPos( + $window.Handle, + [IntPtr]::new(-1), + 0, + 0, + 0, + 0, + 0x0001 -bor 0x0002 -bor 0x0040 +) | Out-Null +[WindowCaptureNative]::SetForegroundWindow($window.Handle) | Out-Null +Start-Sleep -Milliseconds $SettleMs +$window = [WindowCaptureNative]::GetWindowInfo($window.Handle) + +if ($window.Width -le 0 -or $window.Height -le 0) { + throw "Godot window client area is empty." +} + +$directory = [System.IO.Path]::GetDirectoryName($OutputPath) +if ($directory) { + New-Item -ItemType Directory -Force -Path $directory | Out-Null +} + +$bitmap = [System.Drawing.Bitmap]::new( + $window.Width, + $window.Height, + [System.Drawing.Imaging.PixelFormat]::Format32bppArgb +) +$graphics = [System.Drawing.Graphics]::FromImage($bitmap) + +try { + $graphics.CopyFromScreen( + $window.X, + $window.Y, + 0, + 0, + [System.Drawing.Size]::new($window.Width, $window.Height), + [System.Drawing.CopyPixelOperation]::SourceCopy + ) + $bitmap.Save($OutputPath, [System.Drawing.Imaging.ImageFormat]::Png) +} +finally { + $graphics.Dispose() + $bitmap.Dispose() + [WindowCaptureNative]::SetWindowPos( + $window.Handle, + [IntPtr]::new(-2), + 0, + 0, + 0, + 0, + 0x0001 -bor 0x0002 + ) | Out-Null +} + +$resolvedPath = (Resolve-Path -LiteralPath $OutputPath).Path +@{ + height = $window.Height + left = $window.X + path = $resolvedPath + title = $window.Title + top = $window.Y + width = $window.Width +} | ConvertTo-Json -Compress diff --git a/engines/stage-tamagotchi-godot/tools/dumpRenderStages.mjs b/engines/stage-tamagotchi-godot/tools/dumpRenderStages.mjs new file mode 100644 index 000000000..1a4f8b8cf --- /dev/null +++ b/engines/stage-tamagotchi-godot/tools/dumpRenderStages.mjs @@ -0,0 +1,664 @@ +import process from 'node:process' + +import { Buffer } from 'node:buffer' +import { spawn } from 'node:child_process' +import { createHash, randomUUID } from 'node:crypto' +import { existsSync } from 'node:fs' +import { mkdir } from 'node:fs/promises' +import { createServer } from 'node:http' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const scriptDirectory = dirname(fileURLToPath(import.meta.url)) +const projectDirectory = resolve(scriptDirectory, '..') +const repositoryRoot = resolve(projectDirectory, '..', '..') +const defaultModelPath = join( + repositoryRoot, + 'packages', + 'stage-ui', + 'src', + 'assets', + 'vrm', + 'models', + 'AvatarSample-A', + 'AvatarSample_A.vrm', +) +const artifactDirectory = join(projectDirectory, 'artifacts', 'render-stages') +const defaultLogPath = join(artifactDirectory, 'godot-stage.log') +const defaultRenderStageViews = [ + 'scene-copy', + 'avatar-mask', + 'avatar-edge-mask', + 'after-avatar-edge-light', + 'after-avatar-glow', + 'final', + 'final-edge-off', +] +const windowCaptureScriptPath = join(scriptDirectory, 'captureWindowClientPng.ps1') +const webSocketGuid = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11' + +function parseArgs(argv) { + const options = { + avatarEdgeLight: true, + godotPath: process.env.GODOT4, + headless: false, + height: 720, + logPath: defaultLogPath, + modelPath: defaultModelPath, + renderStageViews: null, + settleMs: 1000, + stageDumpDirectory: null, + viewPreset: 'default', + width: 1280, + } + + for (let index = 0; index < argv.length; index++) { + const argument = argv[index] + switch (argument) { + case '--avatar-edge-light': + options.avatarEdgeLight = parseAvatarEdgeLight( + resolveRequiredValue(argv, ++index, argument), + argument, + ) + break + case '--godot': + options.godotPath = resolveRequiredValue(argv, ++index, argument) + break + case '--dump-render-stages': + options.stageDumpDirectory = resolveRequiredValue(argv, ++index, argument) + break + case '--headless': + options.headless = true + break + case '--height': + options.height = parsePositiveInteger(resolveRequiredValue(argv, ++index, argument), argument) + break + case '--log-file': + options.logPath = resolveRequiredValue(argv, ++index, argument) + break + case '--model': + options.modelPath = resolveRequiredValue(argv, ++index, argument) + break + case '--render-stages': + options.renderStageViews = parseRenderStageViews( + resolveRequiredValue(argv, ++index, argument), + argument, + ) + break + case '--settle-ms': + options.settleMs = parseNonNegativeInteger( + resolveRequiredValue(argv, ++index, argument), + argument, + ) + break + case '--view-preset': + options.viewPreset = parseViewPreset(resolveRequiredValue(argv, ++index, argument), argument) + break + case '--width': + options.width = parsePositiveInteger(resolveRequiredValue(argv, ++index, argument), argument) + break + default: + throw new Error(`Unknown argument: ${argument}`) + } + } + + if (!options.godotPath) { + throw new Error('GODOT4 is not set. Pass --godot or set GODOT4 to the Godot .NET executable.') + } + + if (options.headless) { + throw new Error('Render-stage window capture requires a visible Godot window. Remove --headless.') + } + + if (!options.stageDumpDirectory) { + throw new Error('Pass --dump-render-stages . Baseline comparison is not supported.') + } + + options.godotPath = resolve(options.godotPath) + options.logPath = resolve(options.logPath) + options.modelPath = resolve(options.modelPath) + options.stageDumpDirectory = resolve(options.stageDumpDirectory) + options.renderStageViews ??= defaultRenderStageViews + return options +} + +function resolveRequiredValue(argv, index, label) { + const value = argv[index] + if (!value || value.startsWith('--')) { + throw new Error(`${label} requires a value.`) + } + + return value +} + +function parsePositiveInteger(value, label) { + const parsed = Number.parseInt(value, 10) + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error(`${label} must be a positive integer.`) + } + + return parsed +} + +function parseNonNegativeInteger(value, label) { + const parsed = Number.parseInt(value, 10) + if (!Number.isInteger(parsed) || parsed < 0) { + throw new Error(`${label} must be a non-negative integer.`) + } + + return parsed +} + +function parseRenderStageViews(value, label) { + const views = value.split(',').map(item => item.trim()).filter(Boolean) + if (views.length === 0) { + throw new Error(`${label} must include at least one render stage.`) + } + + return views +} + +function parseViewPreset(value, label) { + if (value === 'default' || value === 'upper-body') { + return value + } + + throw new Error(`${label} must be "default" or "upper-body".`) +} + +function parseAvatarEdgeLight(value, label) { + switch (value) { + case 'on': + case 'enabled': + case 'true': + return true + case 'off': + case 'disabled': + case 'false': + return false + default: + throw new Error(`${label} must be "on" or "off".`) + } +} + +async function createStageHost() { + let peerSocket + let peerBuffer = Buffer.alloc(0) + const messages = [] + const waiters = [] + const token = randomUUID() + const server = createServer() + + server.on('upgrade', (request, socket) => { + const requestUrl = new URL(request.url ?? '/', 'ws://127.0.0.1') + if (requestUrl.pathname !== '/ws' || requestUrl.searchParams.get('token') !== token) { + socket.destroy() + return + } + + const key = request.headers['sec-websocket-key'] + if (typeof key !== 'string') { + socket.destroy() + return + } + + const accept = createHash('sha1').update(`${key}${webSocketGuid}`).digest('base64') + socket.write([ + 'HTTP/1.1 101 Switching Protocols', + 'Upgrade: websocket', + 'Connection: Upgrade', + `Sec-WebSocket-Accept: ${accept}`, + '\r\n', + ].join('\r\n')) + + peerSocket = socket + socket.on('data', (chunk) => { + peerBuffer = Buffer.concat([peerBuffer, chunk]) + const result = readFrames(peerBuffer) + peerBuffer = result.remaining + for (const frame of result.frames) { + if (frame.opcode === 0x1) { + pushMessage(JSON.parse(frame.payload.toString('utf8'))) + } + else if (frame.opcode === 0x8) { + socket.end() + } + else if (frame.opcode === 0x9) { + writeFrame(socket, 0xA, frame.payload) + } + } + }) + socket.on('close', () => { + if (peerSocket === socket) { + peerSocket = undefined + } + }) + }) + + function pushMessage(message) { + messages.push(message) + for (let index = waiters.length - 1; index >= 0; index--) { + const waiter = waiters[index] + if (waiter.predicate(message)) { + waiters.splice(index, 1) + clearTimeout(waiter.timeout) + waiter.resolve(message) + } + } + } + + function waitFor(predicate, timeoutMs, label) { + const existingIndex = messages.findIndex(predicate) + if (existingIndex >= 0) { + const [message] = messages.splice(existingIndex, 1) + return Promise.resolve(message) + } + + return new Promise((resolvePromise, rejectPromise) => { + const waiter = { + predicate, + resolve: resolvePromise, + timeout: setTimeout(() => { + const waiterIndex = waiters.indexOf(waiter) + if (waiterIndex >= 0) { + waiters.splice(waiterIndex, 1) + } + + rejectPromise(new Error(`Timed out waiting for ${label}.`)) + }, timeoutMs), + } + waiters.push(waiter) + }) + } + + await new Promise((resolvePromise, rejectPromise) => { + server.once('error', rejectPromise) + server.listen(0, '127.0.0.1', () => { + server.off('error', rejectPromise) + resolvePromise() + }) + }) + + const address = server.address() + if (!address || typeof address === 'string') { + throw new Error('Failed to bind local WebSocket host.') + } + + return { + close: () => new Promise((resolvePromise) => { + let resolved = false + let timeout + const resolveOnce = () => { + if (resolved) { + return + } + + resolved = true + clearTimeout(timeout) + resolvePromise() + } + timeout = setTimeout(resolveOnce, 1000) + peerSocket?.destroy() + server.close(resolveOnce) + }), + send(type, payload) { + if (!peerSocket) { + throw new Error(`Cannot send ${type}; Godot WebSocket is not connected.`) + } + + writeFrame(peerSocket, 0x1, Buffer.from(JSON.stringify({ type, payload }), 'utf8')) + }, + url: `ws://127.0.0.1:${address.port}/ws?token=${token}`, + waitForType(type, timeoutMs) { + return waitFor(message => message?.type === type, timeoutMs, type) + }, + waitForRequest(type, requestId, timeoutMs) { + return waitFor( + message => message?.type === type && message?.payload?.requestId === requestId, + timeoutMs, + `${type} ${requestId}`, + ) + }, + } +} + +function readFrames(buffer) { + const frames = [] + let offset = 0 + + while (buffer.length - offset >= 2) { + const first = buffer[offset] + const second = buffer[offset + 1] + const opcode = first & 0x0F + const masked = (second & 0x80) !== 0 + let length = second & 0x7F + let headerLength = 2 + + if (length === 126) { + if (buffer.length - offset < 4) { + break + } + + length = buffer.readUInt16BE(offset + 2) + headerLength = 4 + } + else if (length === 127) { + if (buffer.length - offset < 10) { + break + } + + const bigLength = buffer.readBigUInt64BE(offset + 2) + if (bigLength > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new Error('WebSocket frame was too large.') + } + + length = Number(bigLength) + headerLength = 10 + } + + const maskLength = masked ? 4 : 0 + const totalLength = headerLength + maskLength + length + if (buffer.length - offset < totalLength) { + break + } + + const payloadStart = offset + headerLength + maskLength + const payload = Buffer.from(buffer.subarray(payloadStart, payloadStart + length)) + if (masked) { + const mask = buffer.subarray(offset + headerLength, offset + headerLength + 4) + for (let index = 0; index < payload.length; index++) { + payload[index] ^= mask[index % 4] + } + } + + frames.push({ opcode, payload }) + offset += totalLength + } + + return { + frames, + remaining: buffer.subarray(offset), + } +} + +function writeFrame(socket, opcode, payload) { + const length = payload.length + let header + if (length < 126) { + header = Buffer.from([0x80 | opcode, length]) + } + else if (length <= 0xFFFF) { + header = Buffer.alloc(4) + header[0] = 0x80 | opcode + header[1] = 126 + header.writeUInt16BE(length, 2) + } + else { + header = Buffer.alloc(10) + header[0] = 0x80 | opcode + header[1] = 127 + header.writeBigUInt64BE(BigInt(length), 2) + } + + socket.write(Buffer.concat([header, payload])) +} + +function launchGodot(options, webSocketUrl) { + const args = [ + '--path', + projectDirectory, + '--resolution', + `${options.width}x${options.height}`, + '--log-file', + options.logPath, + ] + + if (options.headless) { + args.push('--headless') + } + + args.push('--', `--airi-ws-url=${webSocketUrl}`) + + return spawn(options.godotPath, args, { + cwd: projectDirectory, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: false, + }) +} + +function waitForProcessExit(processHandle, timeoutMs) { + return new Promise((resolvePromise) => { + const timeout = setTimeout(resolvePromise, timeoutMs, false) + processHandle.once('close', () => { + clearTimeout(timeout) + resolvePromise(true) + }) + }) +} + +async function stopGodot(host, processHandle) { + try { + host.send('host.shutdown') + } + catch {} + + const exited = await waitForProcessExit(processHandle, 3000) + if (!exited) { + processHandle.kill() + await waitForProcessExit(processHandle, 3000) + } +} + +async function captureGodotWindowPng(processHandle, options) { + if (process.platform !== 'win32') { + throw new Error('Render-stage window capture currently requires Windows.') + } + + if (!processHandle.pid) { + throw new Error('Godot process id was not available for window capture.') + } + + const result = await runProcess( + process.env.PWSH ?? 'powershell.exe', + [ + '-NoProfile', + '-ExecutionPolicy', + 'Bypass', + '-File', + windowCaptureScriptPath, + '-TargetProcessId', + String(processHandle.pid), + '-OutputPath', + options.currentPath, + '-SettleMs', + String(options.settleMs), + ], + ) + + const stdout = result.stdout.trim() + const lines = stdout.split(/\r?\n/).filter(Boolean) + const lastLine = lines[lines.length - 1] + if (!lastLine) { + throw new Error('Window capture did not return JSON metadata.') + } + + try { + return JSON.parse(lastLine) + } + catch (error) { + throw new Error(`Failed to parse window capture metadata: ${error.message}\n${stdout}`) + } +} + +async function setRenderDebugView(host, view) { + const requestId = randomUUID() + host.send('host.render.set_debug_view', { + requestId, + view, + }) + const response = await host.waitForRequest('stage.render.debug_view', requestId, 10000) + if (response?.payload?.view !== view) { + throw new Error(`Godot applied unexpected render debug view: ${response?.payload?.view}`) + } +} + +async function setAvatarEdgeLight(host, enabled) { + const requestId = randomUUID() + host.send('host.render.set_avatar_edge_light', { + requestId, + enabled, + }) + const response = await host.waitForRequest('stage.render.avatar_edge_light', requestId, 10000) + if (response?.payload?.enabled !== enabled) { + throw new Error(`Godot applied unexpected avatar edge-light state: ${response?.payload?.enabled}`) + } +} + +async function applyViewPreset(host, preset) { + if (preset === 'default') { + return + } + + const snapshot = await requestViewSnapshot(host) + const patch = createViewPresetPatch(preset, snapshot) + const requestId = randomUUID() + host.send('host.view.patch', { + requestId, + patch, + }) + await host.waitForRequest('stage.view.snapshot', requestId, 10000) +} + +async function requestViewSnapshot(host) { + const requestId = randomUUID() + host.send('host.view.request_snapshot', { + requestId, + }) + const response = await host.waitForRequest('stage.view.snapshot', requestId, 10000) + return response.payload +} + +function createViewPresetPatch(preset, snapshot) { + if (preset !== 'upper-body') { + throw new Error(`Unknown view preset: ${preset}`) + } + + const bounds = snapshot?.avatarBounds + if (!bounds) { + throw new Error('Upper-body view preset requires avatar bounds from Godot.') + } + + const center = bounds.center + const size = bounds.size + const fovDeg = 35 + const targetY = center.y + size.y * 0.22 + const distance = Math.max(size.y * 0.95, 1.2) + const positionY = targetY + size.y * 0.02 + const pitchDeg = Math.atan2(targetY - positionY, distance) * 180 / Math.PI + + return { + camera: { + position: { + x: center.x, + y: positionY, + z: center.z + distance, + }, + yawDeg: 0, + pitchDeg, + fovDeg, + }, + } +} + +async function captureRenderStageViews(host, processHandle, options) { + await mkdir(options.stageDumpDirectory, { recursive: true }) + + for (const view of options.renderStageViews) { + const isEdgeOffView = view === 'final-edge-off' + const edgeLightEnabled = isEdgeOffView ? false : options.avatarEdgeLight + await setAvatarEdgeLight(host, edgeLightEnabled) + await setRenderDebugView(host, isEdgeOffView ? 'final' : view) + const stageCapturePath = join(options.stageDumpDirectory, `${view}.png`) + const capture = await captureGodotWindowPng(processHandle, { + ...options, + currentPath: stageCapturePath, + }) + console.info( + `Captured render stage ${view}: ${capture.path} (${capture.width}x${capture.height})`, + ) + } + + await setAvatarEdgeLight(host, options.avatarEdgeLight) + await setRenderDebugView(host, 'final') +} + +function runProcess(command, args) { + return new Promise((resolvePromise, rejectPromise) => { + const child = spawn(command, args, { + cwd: projectDirectory, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }) + const stdout = [] + const stderr = [] + + child.stdout.on('data', chunk => stdout.push(chunk)) + child.stderr.on('data', chunk => stderr.push(chunk)) + child.once('error', rejectPromise) + child.once('close', (code) => { + const result = { + code, + stderr: Buffer.concat(stderr).toString('utf8'), + stdout: Buffer.concat(stdout).toString('utf8'), + } + if (code !== 0) { + rejectPromise(new Error(result.stderr.trim() || `${command} exited with code ${code}.`)) + return + } + + resolvePromise(result) + }) + }) +} + +async function main() { + const options = parseArgs(process.argv.slice(2)) + if (!existsSync(options.godotPath)) { + throw new Error(`Godot executable does not exist: ${options.godotPath}`) + } + + if (!existsSync(options.modelPath)) { + throw new Error(`VRM model does not exist: ${options.modelPath}`) + } + + await mkdir(dirname(options.logPath), { recursive: true }) + await mkdir(options.stageDumpDirectory, { recursive: true }) + + const host = await createStageHost() + const godot = launchGodot(options, host.url) + godot.stdout.on('data', chunk => process.stdout.write(chunk)) + godot.stderr.on('data', chunk => process.stderr.write(chunk)) + + try { + await host.waitForType('stage.ready', 20000) + host.send('host.scene.apply', { + format: 'vrm', + modelId: 'render-stage-observation-avatar-sample-a', + name: 'AvatarSample_A', + path: options.modelPath, + }) + await host.waitForType('scene.applied', 45000) + await applyViewPreset(host, options.viewPreset) + await setAvatarEdgeLight(host, options.avatarEdgeLight) + await captureRenderStageViews(host, godot, options) + } + finally { + await stopGodot(host, godot) + await host.close() + } +} + +main().catch((error) => { + console.error(error.message) + process.exitCode = 1 +}) diff --git a/engines/stage-tamagotchi-godot/tools/exportXiaoerEdgeReferenceStages.py b/engines/stage-tamagotchi-godot/tools/exportXiaoerEdgeReferenceStages.py new file mode 100644 index 000000000..46f71ab10 --- /dev/null +++ b/engines/stage-tamagotchi-godot/tools/exportXiaoerEdgeReferenceStages.py @@ -0,0 +1,213 @@ +import json +import math +import os +from pathlib import Path + +import bpy +from mathutils import Vector + + +def resolve_output_dir(): + value = os.environ.get("AIRI_XIAOER_EDGE_OUT_DIR") or os.environ.get("AIRI_EDGE_LIGHT_OUT_DIR") + if not value: + raise RuntimeError("Set AIRI_XIAOER_EDGE_OUT_DIR to the reference stage output directory.") + + output_dir = Path(value) + output_dir.mkdir(parents=True, exist_ok=True) + return output_dir + + +def configure_scene(scene): + scene.use_nodes = True + scene.render.use_compositing = True + scene.render.use_sequencer = False + scene.render.image_settings.file_format = "PNG" + scene.render.image_settings.color_mode = "RGBA" + scene.render.resolution_x = 1920 + scene.render.resolution_y = 1080 + scene.render.resolution_percentage = 100 + + +def frame_upper_body(scene): + camera = scene.camera + if camera is None: + raise RuntimeError("Scene camera not found.") + + min_v = Vector((math.inf, math.inf, math.inf)) + max_v = Vector((-math.inf, -math.inf, -math.inf)) + mesh_count = 0 + for obj in scene.objects: + if obj.type != "MESH" or obj.hide_render: + continue + + mesh_count += 1 + for corner in obj.bound_box: + world = obj.matrix_world @ Vector(corner) + min_v.x = min(min_v.x, world.x) + min_v.y = min(min_v.y, world.y) + min_v.z = min(min_v.z, world.z) + max_v.x = max(max_v.x, world.x) + max_v.y = max(max_v.y, world.y) + max_v.z = max(max_v.z, world.z) + + if mesh_count == 0: + raise RuntimeError("No render-visible mesh objects found.") + + height = max_v.z - min_v.z + target = Vector(( + (min_v.x + max_v.x) * 0.5, + (min_v.y + max_v.y) * 0.5, + min_v.z + height * 0.735, + )) + frame_span = height * 0.48 + camera.data.lens = 50 + camera.data.clip_start = 0.01 + camera.data.clip_end = 1000 + distance = frame_span / (2 * math.tan(camera.data.angle_y * 0.5)) * 1.05 + camera.location = Vector((target.x, target.y, target.z + 0.02)) + Vector((0, -1, 0)) * distance + camera.rotation_euler = (target - camera.location).to_track_quat("-Z", "Y").to_euler() + scene.camera = camera + return camera + + +def find_required_node(tree, label, predicate): + node = next((node for node in tree.nodes if predicate(node)), None) + if node is None: + raise RuntimeError(f"Missing compositor node: {label}.") + + return node + + +def find_group_node(tree, group_name): + return find_required_node( + tree, + group_name, + lambda node: getattr(node, "node_tree", None) + and node.node_tree + and node.node_tree.name == group_name, + ) + + +def ensure_group_output_socket(tree): + if not any(getattr(item, "in_out", None) == "OUTPUT" for item in tree.interface.items_tree): + tree.interface.new_socket(name="Image", in_out="OUTPUT", socket_type="NodeSocketColor") + tree.interface_update(bpy.context) + + output = find_required_node( + tree, + "Group Output", + lambda node: node.bl_idname == "NodeGroupOutput", + ) + real_inputs = [socket for socket in output.inputs if socket.type != "CUSTOM"] + if not real_inputs: + raise RuntimeError("Group Output has no real input socket.") + + return output, real_inputs[0] + + +def clear_socket(tree, socket): + for link in list(tree.links): + if link.to_socket == socket: + tree.links.remove(link) + + +def link_once(tree, from_socket, to_socket): + clear_socket(tree, to_socket) + tree.links.new(from_socket, to_socket) + + +def render_path(scene, path): + scene.render.filepath = str(path) + bpy.ops.render.render(write_still=True) + print("AIRI_XIAOER_REFERENCE_RENDER", path) + + +def main(): + output_dir = resolve_output_dir() + scene = bpy.context.scene + configure_scene(scene) + camera = frame_upper_body(scene) + + main_tree = scene.compositing_node_group + if main_tree is None: + raise RuntimeError("Scene has no compositor node group.") + + output, output_socket = ensure_group_output_socket(main_tree) + viewer = next((node for node in main_tree.nodes if node.bl_idname == "CompositorNodeViewer"), None) + render_layers = find_required_node( + main_tree, + "Render Layers", + lambda node: node.bl_idname == "CompositorNodeRLayers", + ) + edge_node = find_group_node(main_tree, "边缘光") + bangs_node = find_group_node(main_tree, "刘海阴影") + glow_node = find_group_node(main_tree, "辉光") + + def connect_final(socket): + link_once(main_tree, socket, output_socket) + if viewer is not None and len(viewer.inputs) > 0: + link_once(main_tree, socket, viewer.inputs[0]) + + def connect_edge_inputs(): + link_once(main_tree, render_layers.outputs["Image"], edge_node.inputs["Image"]) + link_once(main_tree, render_layers.outputs["Depth"], edge_node.inputs["深度"]) + link_once(main_tree, render_layers.outputs["法向"], edge_node.inputs["法向"]) + + edge_group = edge_node.node_tree + edge_output, edge_output_socket = ensure_group_output_socket(edge_group) + mix_node = edge_group.nodes.get("Mix") + mask_node = edge_group.nodes.get("Map Range.001") + if mix_node is None or mask_node is None: + raise RuntimeError("Edge group is missing Mix or Map Range.001.") + + def connect_edge_group_output(socket): + link_once(edge_group, socket, edge_output_socket) + + # Raw render layer image. + connect_final(render_layers.outputs["Image"]) + render_path(scene, output_dir / "reference_raw.png") + + # Edge node output before bangs shadow and glow. + connect_edge_inputs() + connect_edge_group_output(mix_node.outputs["Result"]) + connect_final(edge_node.outputs[0]) + render_path(scene, output_dir / "reference_after_edge.png") + + # Internal edge mask: Map Range.001.Result before Mix. + connect_edge_group_output(mask_node.outputs["Result"]) + connect_final(edge_node.outputs[0]) + render_path(scene, output_dir / "reference_edge_mask.png") + + # Final enabled chain: edge -> bangs shadow -> glow. + connect_edge_group_output(mix_node.outputs["Result"]) + connect_edge_inputs() + link_once(main_tree, edge_node.outputs[0], bangs_node.inputs["Input"]) + link_once(main_tree, render_layers.outputs["Depth"], bangs_node.inputs["深度"]) + link_once(main_tree, render_layers.outputs["脸"], bangs_node.inputs["脸"]) + link_once(main_tree, bangs_node.outputs[0], glow_node.inputs["Input"]) + connect_final(glow_node.outputs[0]) + render_path(scene, output_dir / "reference_final_on.png") + + # Final disabled chain: bypass only the edge-light node. + link_once(main_tree, render_layers.outputs["Image"], bangs_node.inputs["Input"]) + connect_final(glow_node.outputs[0]) + render_path(scene, output_dir / "reference_final_off.png") + + metadata = { + "blend": bpy.data.filepath, + "cameraLocation": [round(value, 6) for value in camera.location], + "cameraRotationEuler": [round(value, 6) for value in camera.rotation_euler], + "viewTransform": scene.view_settings.view_transform, + "look": scene.view_settings.look, + "outputSocket": { + "main": output_socket.name, + "edge": edge_output_socket.name, + }, + } + (output_dir / "reference_metadata.json").write_text( + json.dumps(metadata, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + + +main() diff --git a/vitest.config.ts b/vitest.config.ts index d7d860044..f18d5d115 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -5,7 +5,6 @@ export default defineConfig({ projects: [ 'apps/server', 'apps/ui-server-auth', - 'apps/ui-admin', 'apps/stage-tamagotchi', 'packages/cap-vite', 'packages/core-agent',