Upgrade to Bevy 0.19 and harden editor workflows
Some checks failed
CI / Format, lint, test, build (push) Has been cancelled

This commit is contained in:
Rbanh 2026-07-09 23:43:47 -04:00
parent 3f85d25cc7
commit b5e561904c
204 changed files with 6972 additions and 29907 deletions

View File

@ -6,3 +6,4 @@ rustflags = ["-C", "link-arg=-fuse-ld=mold"]
[alias] [alias]
clean-target = "run -p xtask --bin clean-target --" clean-target = "run -p xtask --bin clean-target --"
validate-levels = "run -p xtask --features validate-levels --bin validate-levels --"

View File

@ -62,7 +62,7 @@ jobs:
run: cargo test -p sim run: cargo test -p sim
- name: Validate level scenes - name: Validate level scenes
run: cargo run -p xtask --bin validate-levels run: cargo validate-levels
- name: Test scene schema crate - name: Test scene schema crate
run: cargo test -p scene run: cargo test -p scene

13
.vscode/tasks.json vendored
View File

@ -122,6 +122,19 @@
"problemMatcher": [], "problemMatcher": [],
"group": "build" "group": "build"
}, },
{
"label": "target cleanup (deep stale, 3 days)",
"type": "shell",
"command": "cargo clean-target --include-artifacts --days 3 --apply",
"options": {
"cwd": "${workspaceFolder}",
"env": {
"CARGO_TARGET_DIR": "${workspaceFolder}/target"
}
},
"problemMatcher": [],
"group": "build"
},
{ {
"label": "run editor (dev fast-link)", "label": "run editor (dev fast-link)",
"type": "cargo", "type": "cargo",

2323
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -18,7 +18,7 @@ edition = "2021"
license = "MIT OR Apache-2.0" license = "MIT OR Apache-2.0"
[workspace.dependencies] [workspace.dependencies]
avian3d = { version = "0.6", default-features = false, features = [ avian3d = { version = "0.7", default-features = false, features = [
"3d", "3d",
"f32", "f32",
"parry-f32", "parry-f32",
@ -26,14 +26,14 @@ avian3d = { version = "0.6", default-features = false, features = [
"collider-from-mesh", "collider-from-mesh",
"serialize", "serialize",
] } ] }
bevy = { version = "0.18", features = ["serialize", "jpeg"] } bevy = { version = "0.19", features = ["serialize", "jpeg"] }
bevy_core_pipeline = "0.18" bevy_core_pipeline = "0.19"
bevy_solari = "0.18" bevy_solari = "0.19"
bevy_ufbx = "0.18" bevy_ufbx = "0.18.1-rc.1"
bevy_egui = "0.39" bevy_egui = "0.40"
bevy-inspector-egui = "0.36" bevy-inspector-egui = "0.37"
egui_dock = { version = "0.18", features = ["serde"] } egui_dock = { version = "0.19.1", features = ["serde"] }
egui_phosphor_icons = "0.2" egui_phosphor_icons = { version = "0.3.1", default-features = false }
transform-gizmo-bevy = "0.9" transform-gizmo-bevy = "0.9"
serde = { version = "1", features = ["derive"] } serde = { version = "1", features = ["derive"] }
shared = { path = "crates/shared" } shared = { path = "crates/shared" }
@ -45,8 +45,8 @@ settings = { path = "crates/settings" }
scene = { path = "crates/scene" } scene = { path = "crates/scene" }
[patch.crates-io] [patch.crates-io]
# Linux surface acquire timeouts can be transient on Wayland/Xwayland drivers. # Local Bevy 0.19 compatibility patch until upstream publishes a matching FBX loader.
bevy_render = { path = "third_party/bevy_render" } bevy_ufbx = { path = "third_party/bevy_ufbx" }
# Atmosphere-aware mesh view bind groups (WYSIWYG editor + transform gizmos). # Atmosphere-aware mesh view bind groups (WYSIWYG editor + transform gizmos).
transform-gizmo-bevy = { path = "third_party/transform-gizmo-bevy" } transform-gizmo-bevy = { path = "third_party/transform-gizmo-bevy" }
@ -59,6 +59,16 @@ rpath = true
[profile.dev.package."*"] [profile.dev.package."*"]
opt-level = 3 opt-level = 3
# Tests keep source line information without retaining full multi-gigabyte Bevy
# debug images or incremental caches. The dev profile remains fully debuggable.
[profile.test]
opt-level = 1
debug = "line-tables-only"
incremental = false
[profile.test.package."*"]
opt-level = 3
# A snappy release profile for shipping builds. # A snappy release profile for shipping builds.
[profile.release] [profile.release]
lto = "thin" lto = "thin"

View File

@ -1,7 +1,7 @@
# Bevy FPS Foundation # Bevy FPS Foundation
A modular first-person game foundation and in-process editor built on **Bevy 0.18** and A modular first-person game foundation and in-process editor built on **Bevy 0.19** and
**Avian 0.6** physics. **Avian 0.7** physics.
The runtime game provides a high-fidelity PBR stack (HDR, procedural atmosphere + image-based The runtime game provides a high-fidelity PBR stack (HDR, procedural atmosphere + image-based
lighting, cascaded shadows, SSAO, TAA, bloom, fog, ACES tonemapping) plus Hybrid Auto Solari lighting, cascaded shadows, SSAO, TAA, bloom, fog, ACES tonemapping) plus Hybrid Auto Solari
@ -76,13 +76,20 @@ Cargo/Bevy debug artifacts can grow quickly. Use the workspace cleanup task befo
| `cargo clean-target --apply` | Safe cleanup: removes incremental and rust-analyzer flycheck cache. | | `cargo clean-target --apply` | Safe cleanup: removes incremental and rust-analyzer flycheck cache. |
| `cargo clean-target --include-artifacts --days 3 --apply` | Deeper cleanup: also removes stale hashed `deps`, `build`, `.fingerprint`, and example artifacts older than 3 days. Cargo will rebuild anything still needed. | | `cargo clean-target --include-artifacts --days 3 --apply` | Deeper cleanup: also removes stale hashed `deps`, `build`, `.fingerprint`, and example artifacts older than 3 days. Cargo will rebuild anything still needed. |
VS Code tasks: **target cleanup (dry run)** and **target cleanup (safe apply)**. Use the safe cleanup during normal iteration and the three-day deep cleanup after Bevy upgrades,
feature-matrix builds, or large test runs. The cutoff preserves recent artifacts and avoids the full
rebuild caused by `cargo clean`. The workspace test profile keeps line tables but disables full test
debuginfo and incremental test caches, so routine test binaries stay materially smaller without
reducing normal editor debugging fidelity. The cleanup binary deliberately excludes the scene/Bevy
validation dependency; `cargo clean-target` therefore stays cheap even from a cold target. Use
`cargo validate-levels` when scene validation is required. VS Code tasks expose dry-run, safe, and
deep-stale variants.
### Launch Troubleshooting ### Launch Troubleshooting
- The native game/editor windows force an opaque Wayland surface and opaque camera clears to avoid compositor alpha issues on mixed HDR/SDR desktops. - The native game/editor windows force an opaque Wayland surface and opaque camera clears to avoid compositor alpha issues on mixed HDR/SDR desktops.
- If the window maps but appears transparent on Hyprland or another Wayland compositor, launch with `BEVY_FPS_HDR=0` to force the SDR camera path while debugging monitor/compositor behavior. - If the window maps but appears transparent on Hyprland or another Wayland compositor, launch with `BEVY_FPS_HDR=0` to force the SDR camera path while debugging monitor/compositor behavior.
- The workspace patches `bevy_render` locally so transient Linux swapchain acquire timeouts skip one frame instead of panicking in `prepare_windows`. - Bevy 0.19 removed the prior local `bevy_render` swapchain-timeout patch; launch troubleshooting should start from current wgpu/driver/compositor logs.
- Debug launch configs and run tasks set `WGPU_VALIDATION=0` to quiet the known wgpu/Vulkan validation-layer warning `VUID-StandaloneSpirv-MemorySemantics-10871`. This only disables the Vulkan validation layer for those launches; Rust panics and application errors still surface normally. - Debug launch configs and run tasks set `WGPU_VALIDATION=0` to quiet the known wgpu/Vulkan validation-layer warning `VUID-StandaloneSpirv-MemorySemantics-10871`. This only disables the Vulkan validation layer for those launches; Rust panics and application errors still surface normally.
- If **CodeLLDB / mold** fails with hundreds of `undefined symbol` linker errors, the incremental `target/` cache is stale. Run the VS Code task **clean build editor (dev)** or `cargo clean -p editor && cargo build -p editor --features dev`, then launch **Debug editor** again. Use `cargo run -p editor --features dev` from the terminal if you need `libbevy_dylib` on `LD_LIBRARY_PATH` automatically. - If **CodeLLDB / mold** fails with hundreds of `undefined symbol` linker errors, the incremental `target/` cache is stale. Run the VS Code task **clean build editor (dev)** or `cargo clean -p editor && cargo build -p editor --features dev`, then launch **Debug editor** again. Use `cargo run -p editor --features dev` from the terminal if you need `libbevy_dylib` on `LD_LIBRARY_PATH` automatically.
@ -111,9 +118,9 @@ VS Code tasks: **target cleanup (dry run)** and **target cleanup (safe apply)**.
| `W` / `E` / `R` | Translate / rotate / scale gizmo | | `W` / `E` / `R` | Translate / rotate / scale gizmo |
| `X` | Toggle world/local gizmo orientation | | `X` | Toggle world/local gizmo orientation |
| `B` | Enter Draw Brush mode | | `B` | Enter Draw Brush mode |
| Draw Brush: LMB / `Enter` / `Esc` / `Backspace` | Place floor points / create brush / cancel / remove last point | | Draw Brush: LMB / `Enter` / mouse up-down / `Esc` / `Backspace` | Place floor points / enter height phase or create / set height / cancel / remove point or return to outline |
| Brush selected: `1` / `2` / `3` / `4` | Vertex / edge / face / clip edit modes | | Brush selected: `1` / `2` / `3` / `4` | Vertex / edge / face / clip edit modes |
| Brush edit mode: LMB / `Shift+LMB` / `Esc` | Select element / toggle element selection / return to object mode | | Brush edit mode: LMB / `Shift+LMB` / `W` / `E` / `R` / `Esc` | Select element / toggle element selection / move / rotate / scale selected brush elements / return to object mode |
| Viewport toolbar (sun / brush / box icons) | Shading: Lit, Unlit (albedo), Colliders (mesh off) | | Viewport toolbar (sun / brush / box icons) | Shading: Lit, Unlit (albedo), Colliders (mesh off) |
| Viewport eye/options | Toggle actor root icon categories, adjust icon/gizmo size, and control colliders, lights, spawns, prefab/model anchors, and runtime player/camera visualizers | | Viewport eye/options | Toggle actor root icon categories, adjust icon/gizmo size, and control colliders, lights, spawns, prefab/model anchors, and runtime player/camera visualizers |
| `Tab` in viewport | Cycle selection through overlapping objects at last click | | `Tab` in viewport | Cycle selection through overlapping objects at last click |
@ -121,7 +128,7 @@ VS Code tasks: **target cleanup (dry run)** and **target cleanup (safe apply)**.
| Select Project Sun | Inspect project default lighting; create a scene sun override | | Select Project Sun | Inspect project default lighting; create a scene sun override |
| Asset Browser project/file views | Browse `assets/`, search/filter/sort, switch grid/list, expand model subassets with generated thumbnails, inspect staged import/material settings, drag assets/submeshes into the viewport | | Asset Browser project/file views | Browse `assets/`, search/filter/sort, switch grid/list, expand model subassets with generated thumbnails, inspect staged import/material settings, drag assets/submeshes into the viewport |
| Asset Browser context/details actions | Apply textures/materials, regenerate thumbnails, reimport models, place assets/submeshes, or move file assets to `assets/.trash/` | | Asset Browser context/details actions | Apply textures/materials, regenerate thumbnails, reimport models, place assets/submeshes, or move file assets to `assets/.trash/` |
| `Ctrl+P` | Command palette; type to filter, use arrow keys to select, Enter to run (`scene.reset_lighting`, `selection.group`, `selection.focus`, `selection.reset_transform`, play commands) | | `Ctrl+P` | Centered command palette; search human labels or stable command IDs, use arrow keys to select, Enter to run |
| `F7` | While paused in Play: advance one sim tick | | `F7` | While paused in Play: advance one sim tick |
| Shift/Ctrl + click (Hierarchy) | Additive selection | | Shift/Ctrl + click (Hierarchy) | Additive selection |
| Hierarchy context | Reparent to other selection / Unparent | | Hierarchy context | Reparent to other selection / Unparent |
@ -182,11 +189,13 @@ Viewport shortcut keys require the pointer to be in the viewport and are suspend
artifacts in `assets/meshes/generated/`. Renderer slots reference imported content-browser mesh artifacts in `assets/meshes/generated/`. Renderer slots reference imported content-browser mesh
and material assets, while generated collision is stored separately in `ColliderDesc` plus and material assets, while generated collision is stored separately in `ColliderDesc` plus
`RigidBodyDesc`. Switch a model asset's placement mode to **Scene Instance** in Asset Browser `RigidBodyDesc`. Switch a model asset's placement mode to **Scene Instance** in Asset Browser
details when you need full `SceneRoot` loading for animation/skinning/scene data. Expanding a details when you need full `WorldAssetRoot` loading for animation/skinning/scene data. Expanding a
model in the Asset Browser exposes normalized mesh/material/texture subassets; dragging a mesh model in the Asset Browser exposes normalized mesh/material/texture subassets; dragging a mesh
subasset places that part through the same static mesh renderer path. subasset places that part through the same static mesh renderer path.
- Brush actors are persisted as `ActorKind::Brush + BrushDesc`; the MVP hydrates additive convex - Brush actors are persisted as `ActorKind::Brush + BrushDesc`; valid convex faces hydrate into
cube brushes into generated preview meshes while face/edge/CSG editing remains roadmap work. generated preview meshes. Vertex, edge, and face selections use the standard transform gizmo,
face material/UV fields are undoable, and clip/intersect/merge/subtract provide conservative
bounds-based blockout operations with preview-before-commit.
- Draw Brush mode (`B` or toolbar pencil) places snapped floor points; `Enter` locks the outline, - Draw Brush mode (`B` or toolbar pencil) places snapped floor points; `Enter` locks the outline,
mouse up/down adjusts height, and `Enter` or left-click commits additive prism brushes. Simple mouse up/down adjusts height, and `Enter` or left-click commits additive prism brushes. Simple
concave outlines decompose into convex brush parts, while self-intersections are blocked with concave outlines decompose into convex brush parts, while self-intersections are blocked with
@ -207,8 +216,8 @@ Viewport shortcut keys require the pointer to be in the viewport and are suspend
1. Check the **mode badge** (bottom-right of the viewport). **Collider** hides meshes — click the **sun** toolbar icon for **Lit** shading. 1. Check the **mode badge** (bottom-right of the viewport). **Collider** hides meshes — click the **sun** toolbar icon for **Lit** shading.
2. **Window → Rendering → Active Camera**: check **Runtime lights** (directional/point/spot counts), scene vs project sun status, and contributing post-process volumes. Scene directionals override the project sun; use **Scene → Lighting → Use project sun** to restore project defaults. 2. **Window → Rendering → Active Camera**: check **Runtime lights** (directional/point/spot counts), scene vs project sun status, and contributing post-process volumes. Scene directionals override the project sun; use **Scene → Lighting → Use project sun** to restore project defaults.
3. **Solari requested but not visually changing**: check **Window → Rendering → Active Camera** for requested/effective GI, fallback reason, tagged meshes, Solari-compatible mesh assets, render instances, bind-group readiness, and compatible lights. Effective Solari forces an HDR camera target because Bevy Solari writes through a storage texture; imported/custom meshes need TriangleList geometry, POSITION/NORMAL/UV_0/TANGENT attributes, and U32 indices for Bevy 0.18 Solari. 3. **Solari requested but not visually changing**: check **Window → Rendering → Active Camera** for requested/effective GI, fallback reason, tagged meshes, Solari-compatible mesh assets, render instances, bind-group readiness, and compatible lights. Effective Solari forces an HDR camera target because Bevy Solari writes through a storage texture; imported/custom meshes need TriangleList geometry, POSITION/NORMAL/UV_0/TANGENT attributes, and U32 indices for Bevy 0.19 Solari.
4. **Lighting changes do nothing in Solari**: Bevy 0.18 Solari samples directional lights and emissive meshes, not point/spot lights. Use Directional lights or emissive materials in Solari; switch to Forward PBR for point/spot light authoring. 4. **Lighting changes do nothing in Solari**: Bevy 0.19 Solari samples directional lights and emissive meshes, not point/spot lights. Use Directional lights or emissive materials in Solari; switch to Forward PBR for point/spot light authoring.
5. **GiMode Auto shows Forward**: Solari RT wgpu features are unavailable on this GPU. Dev RT override: `BEVY_FPS_FORCE_SOLARI=1`. 5. **GiMode Auto shows Forward**: Solari RT wgpu features are unavailable on this GPU. Dev RT override: `BEVY_FPS_FORCE_SOLARI=1`.
6. **Volume overrides ignored**: confirm camera is inside volume AABB; check priority in Rendering → Volumes tab. 6. **Volume overrides ignored**: confirm camera is inside volume AABB; check priority in Rendering → Volumes tab.
7. **Custom post FX**: RON under `assets/post_fx/`; assign path in volume inspector (see [docs/editor/rendering.md](docs/editor/rendering.md)). 7. **Custom post FX**: RON under `assets/post_fx/`; assign path in volume inspector (see [docs/editor/rendering.md](docs/editor/rendering.md)).
@ -297,7 +306,8 @@ crates/
- [x] Command palette: reset lighting, group selection, focus selection - [x] Command palette: reset lighting, group selection, focus selection
- [x] CI: `cargo test -p shared`, scene authoring-only check on repo level, `cargo check -p editor --features dev` - [x] CI: `cargo test -p shared`, scene authoring-only check on repo level, `cargo check -p editor --features dev`
- [x] `scene` crate schema stamp/migrate/validate on save/load + CI `validate-levels` - [x] `scene` crate schema stamp/migrate/validate on save/load + CI `validate-levels`
- [x] Project Settings draft + Apply (HDR/swapchain safe); File → Project New/Open - [x] Project Settings draft + Apply (HDR/swapchain safe)
- [ ] Project launcher/scaffolding with startup-time asset-root selection; the unsafe in-process File → Project scaffold was removed pending BS-JD-001
- [x] Editor lib/bin split + `EditorPluginGroup`; game EditorPlugin dogfood panel - [x] Editor lib/bin split + `EditorPluginGroup`; game EditorPlugin dogfood panel
- [x] FBX/glTF model import + normalized `StaticMeshRenderer` placement; explicit scene-instance load via `bevy_ufbx` / `ModelRef` - [x] FBX/glTF model import + normalized `StaticMeshRenderer` placement; explicit scene-instance load via `bevy_ufbx` / `ModelRef`
- [x] Asset browser model thumbnails (unified `assets/thumbnails/` pipeline; `ThumbnailState` cache; FBX via `FbxThumbnailSource`) - [x] Asset browser model thumbnails (unified `assets/thumbnails/` pipeline; `ThumbnailState` cache; FBX via `FbxThumbnailSource`)
@ -307,6 +317,8 @@ crates/
- [x] Static mesh renderer component, generated normalized mesh artifacts, source/one-actor hierarchy placement, and inspector renderer slots ([ADR 0017](docs/adr/0017-normalized-static-mesh-assets.md)) - [x] Static mesh renderer component, generated normalized mesh artifacts, source/one-actor hierarchy placement, and inspector renderer slots ([ADR 0017](docs/adr/0017-normalized-static-mesh-assets.md))
- [x] Componentized actor inspector, unified component cards, thumbnail static mesh slots, imported asset Browse/Locate/Clear refs with inherited source defaults, collider split, shader-aware actor material data, and texture picker/drop refs ([ADR 0018](docs/adr/0018-componentized-actor-inspector-and-materials.md)) - [x] Componentized actor inspector, unified component cards, thumbnail static mesh slots, imported asset Browse/Locate/Clear refs with inherited source defaults, collider split, shader-aware actor material data, and texture picker/drop refs ([ADR 0018](docs/adr/0018-componentized-actor-inspector-and-materials.md))
- [x] Brush authoring schema MVP with `ActorKind::Brush`, cube `BrushDesc`, generated mesh hydration, scene migration, and inspector Add Component support ([ADR 0021](docs/adr/0021-brush-authoring-schema.md)) - [x] Brush authoring schema MVP with `ActorKind::Brush`, cube `BrushDesc`, generated mesh hydration, scene migration, and inspector Add Component support ([ADR 0021](docs/adr/0021-brush-authoring-schema.md))
- [x] Brush draw, vertex/edge/face gizmo editing, face material/UV authoring, clip, and bounds-based CSG preview/commit workflow ([brush guide](docs/editor/brushes.md))
- [x] Searchable command palette with human labels/stable IDs and a status bar that exposes scene I/O, tool, history, mode, and selection feedback
## Notes / Future Work ## Notes / Future Work
@ -319,7 +331,7 @@ crates/
Model assets generate normalized static mesh manifests under `assets/meshes/generated/`; drag/drop Model assets generate normalized static mesh manifests under `assets/meshes/generated/`; drag/drop
uses `StaticMeshRenderer` by default with imported asset refs and optional separate static mesh uses `StaticMeshRenderer` by default with imported asset refs and optional separate static mesh
collider components. Expanded mesh subassets generate independent thumbnails and can be placed independently. Asset details can switch collider components. Expanded mesh subassets generate independent thumbnails and can be placed independently. Asset details can switch
placement to **Scene Instance** for `ModelRef`/`SceneRoot` playback, shared material assets can be placement to **Scene Instance** for `ModelRef`/`WorldAssetRoot` playback, shared material assets can be
edited from the browser, and delete actions move files to `assets/.trash/`. Skeletal animation edited from the browser, and delete actions move files to `assets/.trash/`. Skeletal animation
playback is not supported by the static mesh path yet. playback is not supported by the static mesh path yet.
- Prefab instances use stable asset IDs (`PrefabInstance` + registry) with serialized `PrefabOverrides` (transform, material, per-child visibility by name). Inspector **Apply/Revert** per field group; **Unpack** removes the instance link. - Prefab instances use stable asset IDs (`PrefabInstance` + registry) with serialized `PrefabOverrides` (transform, material, per-child visibility by name). Inspector **Apply/Revert** per field group; **Unpack** removes the instance link.

File diff suppressed because it is too large Load Diff

View File

@ -7,7 +7,7 @@
format: "gltf", format: "gltf",
fingerprint: ( fingerprint: (
byte_len: 2790, byte_len: 2790,
modified_unix_secs: 1780712587, modified_unix_secs: 1780734867,
), ),
dependencies: [ dependencies: [
"assets/models/metal_stool_01.bin", "assets/models/metal_stool_01.bin",

View File

@ -25,42 +25,27 @@ impl Default for AssetId {
} }
} }
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
pub enum ModelPlacementMode { pub enum ModelPlacementMode {
#[default]
StaticAsset, StaticAsset,
SceneInstance, SceneInstance,
} }
impl Default for ModelPlacementMode { #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
fn default() -> Self {
Self::StaticAsset
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum ModelHierarchyMode { pub enum ModelHierarchyMode {
#[default]
SingleActor, SingleActor,
SourceHierarchy, SourceHierarchy,
} }
impl Default for ModelHierarchyMode { #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
fn default() -> Self {
Self::SingleActor
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
pub enum MaterialImportPolicy { pub enum MaterialImportPolicy {
#[default]
SourceMaterials, SourceMaterials,
AuthoringOverride, AuthoringOverride,
} }
impl Default for MaterialImportPolicy {
fn default() -> Self {
Self::SourceMaterials
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct ImportSettings { pub struct ImportSettings {
pub scale: f32, pub scale: f32,

View File

@ -59,14 +59,13 @@ pub fn apply_texture_operator(
) -> bool { ) -> bool {
let label = format!("Apply Texture {}", asset.label); let label = format!("Apply Texture {}", asset.label);
let availability = selection_availability(world, selected); let availability = selection_availability(world, selected);
let selected = selected;
run_immediate_operator( run_immediate_operator(
world, world,
"assets.apply_texture", "assets.apply_texture",
&label, &label,
availability, availability,
move |world| { move |world| {
apply_texture_to_selection(world, &asset, &selected); apply_texture_to_selection(world, &asset, selected);
Ok(()) Ok(())
}, },
) )
@ -79,14 +78,13 @@ pub fn apply_material_operator(
) -> bool { ) -> bool {
let label = format!("Apply Material {}", asset.label); let label = format!("Apply Material {}", asset.label);
let availability = selection_availability(world, selected); let availability = selection_availability(world, selected);
let selected = selected;
run_immediate_operator( run_immediate_operator(
world, world,
"assets.apply_material", "assets.apply_material",
&label, &label,
availability, availability,
move |world| { move |world| {
apply_material_asset_to_selection(world, &asset, &selected); apply_material_asset_to_selection(world, &asset, selected);
Ok(()) Ok(())
}, },
) )

View File

@ -206,9 +206,9 @@ fn build_static_mesh_manifest(record: &AssetRecord) -> Result<StaticMeshManifest
scale: record.import_settings.scale, scale: record.import_settings.scale,
generate_collider: record.import_settings.generate_collider, generate_collider: record.import_settings.generate_collider,
lod0_only: record.import_settings.lod0_only, lod0_only: record.import_settings.lod0_only,
placement_mode: record.import_settings.placement_mode.clone(), placement_mode: record.import_settings.placement_mode,
hierarchy_mode: record.import_settings.hierarchy_mode.clone(), hierarchy_mode: record.import_settings.hierarchy_mode,
material_policy: record.import_settings.material_policy.clone(), material_policy: record.import_settings.material_policy,
}; };
let mut manifest = match format.as_str() { let mut manifest = match format.as_str() {

View File

@ -199,7 +199,7 @@ impl AssetThumbnailCache {
key.clone(), key.clone(),
ThumbnailJobSource::MaterialAsset { ThumbnailJobSource::MaterialAsset {
label: material.label.clone(), label: material.label.clone(),
material: material.material, material: Box::new(material.material),
}, },
) { ) {
self.studio_pending.insert(key); self.studio_pending.insert(key);

View File

@ -24,7 +24,7 @@ pub enum ThumbnailJobSource {
}, },
MaterialAsset { MaterialAsset {
label: String, label: String,
material: MaterialDesc, material: Box<MaterialDesc>,
}, },
} }

View File

@ -10,7 +10,6 @@ use bevy::math::bounding::Aabb3d;
use bevy::mesh::VertexAttributeValues; use bevy::mesh::VertexAttributeValues;
use bevy::prelude::*; use bevy::prelude::*;
use bevy::render::render_resource::TextureFormat; use bevy::render::render_resource::TextureFormat;
use bevy::scene::SceneRoot;
use bevy_egui::EguiUserTextures; use bevy_egui::EguiUserTextures;
use shared::{material_from_desc, ModelRef}; use shared::{material_from_desc, ModelRef};
@ -134,6 +133,7 @@ fn setup_thumbnail_studio(mut commands: Commands, mut images: ResMut<Assets<Imag
ThumbnailStudioLayer, ThumbnailStudioLayer,
RenderLayers::layer(THUMBNAIL_LAYER), RenderLayers::layer(THUMBNAIL_LAYER),
Camera3d::default(), Camera3d::default(),
Msaa::Off,
Camera { Camera {
is_active: false, is_active: false,
order: -50, order: -50,
@ -157,7 +157,7 @@ fn setup_thumbnail_studio(mut commands: Commands, mut images: ResMut<Assets<Imag
RenderLayers::layer(THUMBNAIL_LAYER), RenderLayers::layer(THUMBNAIL_LAYER),
DirectionalLight { DirectionalLight {
illuminance: 12_000.0, illuminance: 12_000.0,
shadows_enabled: false, shadow_maps_enabled: false,
..default() ..default()
}, },
Transform::from_rotation(Quat::from_euler(EulerRot::XYZ, -0.8, 0.9, 0.0)), Transform::from_rotation(Quat::from_euler(EulerRot::XYZ, -0.8, 0.9, 0.0)),
@ -172,7 +172,7 @@ fn setup_thumbnail_studio(mut commands: Commands, mut images: ResMut<Assets<Imag
RenderLayers::layer(THUMBNAIL_LAYER), RenderLayers::layer(THUMBNAIL_LAYER),
DirectionalLight { DirectionalLight {
illuminance: 3_500.0, illuminance: 3_500.0,
shadows_enabled: false, shadow_maps_enabled: false,
..default() ..default()
}, },
Transform::from_rotation(Quat::from_euler(EulerRot::XYZ, -0.4, -2.2, 0.0)), Transform::from_rotation(Quat::from_euler(EulerRot::XYZ, -0.4, -2.2, 0.0)),
@ -380,7 +380,7 @@ fn spawn_thumbnail_job_content(
match source { match source {
ThumbnailJobSource::Model { model_path } => { ThumbnailJobSource::Model { model_path } => {
if uses_scene_root(model_path) { if uses_scene_root(model_path) {
commands.entity(root).insert(SceneRoot( commands.entity(root).insert(WorldAssetRoot(
asset_server.load(model_scene_asset_path(model_path, 0)), asset_server.load(model_scene_asset_path(model_path, 0)),
)); ));
} }
@ -494,6 +494,10 @@ fn finish_active_thumbnail(
studio.cooldown_frames = COOLDOWN_FRAMES; studio.cooldown_frames = COOLDOWN_FRAMES;
} }
#[expect(
clippy::too_many_arguments,
reason = "thumbnail failure coordinates the job, cache, render target, and camera state"
)]
fn fail_active_thumbnail( fn fail_active_thumbnail(
commands: &mut Commands, commands: &mut Commands,
studio: &mut ThumbnailStudio, studio: &mut ThumbnailStudio,

View File

@ -35,6 +35,12 @@ pub struct EditorCommandRegistry {
commands: Vec<Box<dyn EditorCommand>>, commands: Vec<Box<dyn EditorCommand>>,
} }
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EditorCommandEntry {
pub name: String,
pub label: String,
}
impl EditorCommandRegistry { impl EditorCommandRegistry {
pub fn register(&mut self, command: Box<dyn EditorCommand>) { pub fn register(&mut self, command: Box<dyn EditorCommand>) {
self.commands.push(command); self.commands.push(command);
@ -47,6 +53,16 @@ impl EditorCommandRegistry {
.collect() .collect()
} }
pub fn entries(&self) -> Vec<EditorCommandEntry> {
self.commands
.iter()
.map(|command| EditorCommandEntry {
name: command.name().to_string(),
label: command.label().to_string(),
})
.collect()
}
pub fn command_state(&self, world: &World, name: &str) -> Option<(String, Option<String>)> { pub fn command_state(&self, world: &World, name: &str) -> Option<(String, Option<String>)> {
self.commands self.commands
.iter() .iter()
@ -249,6 +265,10 @@ impl EditorCommand for TogglePlayCommand {
"play.toggle" "play.toggle"
} }
fn label(&self) -> &str {
"Toggle Play / Edit"
}
fn execute(&self, world: &mut World) { fn execute(&self, world: &mut World) {
toggle_play_mode(world); toggle_play_mode(world);
} }
@ -261,6 +281,15 @@ impl EditorCommand for TogglePlayPausedCommand {
"play.toggle_pause" "play.toggle_pause"
} }
fn label(&self) -> &str {
"Pause / Resume Simulation"
}
fn disabled_reason(&self, world: &World) -> Option<String> {
(*world.resource::<State<EditorMode>>().get() != EditorMode::Playing)
.then(|| "Enter Play mode before pausing the simulation".to_string())
}
fn execute(&self, world: &mut World) { fn execute(&self, world: &mut World) {
toggle_play_paused(world); toggle_play_paused(world);
} }
@ -273,6 +302,10 @@ impl EditorCommand for TogglePossessionCommand {
"play.toggle_possession" "play.toggle_possession"
} }
fn label(&self) -> &str {
"Possess / Eject Player"
}
fn disabled_reason(&self, world: &World) -> Option<String> { fn disabled_reason(&self, world: &World) -> Option<String> {
(*world.resource::<State<EditorMode>>().get() != EditorMode::Playing) (*world.resource::<State<EditorMode>>().get() != EditorMode::Playing)
.then(|| "Enter Play mode before toggling possession".to_string()) .then(|| "Enter Play mode before toggling possession".to_string())
@ -294,6 +327,10 @@ impl EditorCommand for ResetLightingCommand {
"scene.reset_lighting" "scene.reset_lighting"
} }
fn label(&self) -> &str {
"Reset Scene Lighting"
}
fn execute(&self, world: &mut World) { fn execute(&self, world: &mut World) {
reset_scene_lighting_to_project_defaults(world); reset_scene_lighting_to_project_defaults(world);
} }
@ -306,6 +343,10 @@ impl EditorCommand for GroupSelectionCommand {
"selection.group" "selection.group"
} }
fn label(&self) -> &str {
"Group Selection"
}
fn execute(&self, world: &mut World) { fn execute(&self, world: &mut World) {
let selected: Vec<Entity> = world let selected: Vec<Entity> = world
.resource::<UiState>() .resource::<UiState>()
@ -323,6 +364,10 @@ impl EditorCommand for FocusSelectionCommand {
"selection.focus" "selection.focus"
} }
fn label(&self) -> &str {
"Focus Selection"
}
fn execute(&self, world: &mut World) { fn execute(&self, world: &mut World) {
focus_editor_camera_on_selection(world); focus_editor_camera_on_selection(world);
} }
@ -335,6 +380,10 @@ impl EditorCommand for ResetSelectionTransformCommand {
"selection.reset_transform" "selection.reset_transform"
} }
fn label(&self) -> &str {
"Reset Selection Transform"
}
fn execute(&self, world: &mut World) { fn execute(&self, world: &mut World) {
reset_selected_transforms(world); reset_selected_transforms(world);
} }
@ -428,6 +477,10 @@ impl EditorCommand for CreatePostProcessVolumeCommand {
"rendering.create_volume" "rendering.create_volume"
} }
fn label(&self) -> &str {
"Create Post-process Volume"
}
fn execute(&self, world: &mut World) { fn execute(&self, world: &mut World) {
crate::rendering_diagnostics::spawn_post_process_volume_at_camera(world); crate::rendering_diagnostics::spawn_post_process_volume_at_camera(world);
} }
@ -440,6 +493,10 @@ impl EditorCommand for FocusActiveVolumesCommand {
"rendering.focus_active_volumes" "rendering.focus_active_volumes"
} }
fn label(&self) -> &str {
"Focus Active Post-process Volumes"
}
fn execute(&self, world: &mut World) { fn execute(&self, world: &mut World) {
crate::rendering_diagnostics::select_volumes_at_camera(world); crate::rendering_diagnostics::select_volumes_at_camera(world);
focus_editor_camera_on_selection(world); focus_editor_camera_on_selection(world);
@ -453,6 +510,10 @@ impl EditorCommand for SelectVolumesAtCameraCommand {
"rendering.select_volumes_at_camera" "rendering.select_volumes_at_camera"
} }
fn label(&self) -> &str {
"Select Volumes at Camera"
}
fn execute(&self, world: &mut World) { fn execute(&self, world: &mut World) {
crate::rendering_diagnostics::select_volumes_at_camera(world); crate::rendering_diagnostics::select_volumes_at_camera(world);
} }
@ -471,6 +532,7 @@ fn command_palette_ui(
if ctx.input(|input| input.modifiers.command && input.key_pressed(egui::Key::P)) { if ctx.input(|input| input.modifiers.command && input.key_pressed(egui::Key::P)) {
palette.open = true; palette.open = true;
palette.filter.clear();
palette.focus_filter = true; palette.focus_filter = true;
palette.selected_index = 0; palette.selected_index = 0;
} }
@ -480,19 +542,20 @@ fn command_palette_ui(
} }
let mut open = palette.open; let mut open = palette.open;
let palette_width = (ctx.content_rect().width() - 32.0).clamp(320.0, 520.0);
egui::Window::new("Command Palette") egui::Window::new("Command Palette")
.open(&mut open) .open(&mut open)
.default_width(400.0) .anchor(egui::Align2::CENTER_TOP, egui::vec2(0.0, 64.0))
.frame(egui::Frame::window(&ctx.style()).fill(PANEL_BG)) .collapsible(false)
.resizable(false)
.default_width(palette_width)
.min_width(palette_width)
.max_width(palette_width)
.frame(egui::Frame::window(&ctx.global_style()).fill(PANEL_BG))
.show(ctx, |ui| { .show(ctx, |ui| {
ui.label(
egui::RichText::new("Ctrl+P — filter and run commands")
.color(TEXT_DIM)
.small(),
);
let filter_response = ui.add( let filter_response = ui.add(
egui::TextEdit::singleline(&mut palette.filter) egui::TextEdit::singleline(&mut palette.filter)
.hint_text("Type to filter...") .hint_text("Search commands...")
.desired_width(f32::INFINITY), .desired_width(f32::INFINITY),
); );
if palette.focus_filter { if palette.focus_filter {
@ -503,35 +566,30 @@ fn command_palette_ui(
palette.selected_index = 0; palette.selected_index = 0;
} }
let filter = palette.filter.to_lowercase(); let filtered_entries = filtered_command_entries(&registry, &palette.filter);
let filtered_names: Vec<String> = registry if !filtered_entries.is_empty() {
.names() palette.selected_index = palette.selected_index.min(filtered_entries.len() - 1);
.into_iter()
.filter(|name| filter.is_empty() || name.to_lowercase().contains(&filter))
.collect();
if !filtered_names.is_empty() {
palette.selected_index = palette.selected_index.min(filtered_names.len() - 1);
} else { } else {
palette.selected_index = 0; palette.selected_index = 0;
} }
if ui.input_mut(|input| input.consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown)) if ui.input_mut(|input| input.consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown))
&& !filtered_names.is_empty() && !filtered_entries.is_empty()
{ {
palette.selected_index = (palette.selected_index + 1) % filtered_names.len(); palette.selected_index = (palette.selected_index + 1) % filtered_entries.len();
} }
if ui.input_mut(|input| input.consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp)) if ui.input_mut(|input| input.consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp))
&& !filtered_names.is_empty() && !filtered_entries.is_empty()
{ {
palette.selected_index = if palette.selected_index == 0 { palette.selected_index = if palette.selected_index == 0 {
filtered_names.len() - 1 filtered_entries.len() - 1
} else { } else {
palette.selected_index - 1 palette.selected_index - 1
}; };
} }
if ui.input_mut(|input| input.consume_key(egui::Modifiers::NONE, egui::Key::Enter)) { if ui.input_mut(|input| input.consume_key(egui::Modifiers::NONE, egui::Key::Enter)) {
if let Some(name) = filtered_names.get(palette.selected_index) { if let Some(entry) = filtered_entries.get(palette.selected_index) {
palette.pending_run = Some(name.clone()); palette.pending_run = Some(entry.name.clone());
palette.open = false; palette.open = false;
ui.close(); ui.close();
} }
@ -543,18 +601,23 @@ fn command_palette_ui(
ui.separator(); ui.separator();
egui::ScrollArea::vertical() egui::ScrollArea::vertical()
.max_height(280.0) .max_height(320.0)
.show(ui, |ui| { .show(ui, |ui| {
for (index, name) in filtered_names.iter().enumerate() { if filtered_entries.is_empty() {
ui.weak("No matching commands");
}
for (index, entry) in filtered_entries.iter().enumerate() {
let selected = index == palette.selected_index; let selected = index == palette.selected_index;
let response = let response = ui.add_sized(
ui.selectable_label(selected, egui::RichText::new(name).color(TEXT)); [ui.available_width(), 40.0],
egui::Button::selectable(selected, command_palette_row(entry)),
);
if selected { if selected {
response.scroll_to_me(Some(egui::Align::Center)); response.scroll_to_me(Some(egui::Align::Center));
} }
if response.clicked() { if response.clicked() {
palette.selected_index = index; palette.selected_index = index;
palette.pending_run = Some(name.clone()); palette.pending_run = Some(entry.name.clone());
palette.open = false; palette.open = false;
ui.close(); ui.close();
} }
@ -565,3 +628,73 @@ fn command_palette_ui(
Ok(()) Ok(())
} }
fn filtered_command_entries(
registry: &EditorCommandRegistry,
filter: &str,
) -> Vec<EditorCommandEntry> {
let filter = filter.trim().to_lowercase();
registry
.entries()
.into_iter()
.filter(|entry| {
filter.is_empty()
|| entry.name.to_lowercase().contains(&filter)
|| entry.label.to_lowercase().contains(&filter)
})
.collect()
}
fn command_palette_row(entry: &EditorCommandEntry) -> egui::WidgetText {
let mut job = egui::text::LayoutJob::default();
job.append(
&entry.label,
0.0,
egui::TextFormat {
font_id: egui::FontId::new(13.0, egui::FontFamily::Proportional),
color: TEXT,
..Default::default()
},
);
job.append(
&format!("\n{}", entry.name),
0.0,
egui::TextFormat {
font_id: egui::FontId::new(11.0, egui::FontFamily::Monospace),
color: TEXT_DIM,
..Default::default()
},
);
job.into()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn command_filter_matches_human_label_and_stable_id() {
let mut registry = EditorCommandRegistry::default();
registry.register(Box::new(TogglePlayCommand));
registry.register(Box::new(ResetLightingCommand));
let by_label = filtered_command_entries(&registry, "scene lighting");
assert_eq!(by_label.len(), 1);
assert_eq!(by_label[0].name, "scene.reset_lighting");
let by_id = filtered_command_entries(&registry, "play.toggle");
assert_eq!(by_id.len(), 1);
assert_eq!(by_id[0].label, "Toggle Play / Edit");
}
#[test]
fn empty_command_filter_preserves_registration_order() {
let mut registry = EditorCommandRegistry::default();
registry.register(Box::new(TogglePlayCommand));
registry.register(Box::new(ResetLightingCommand));
let entries = filtered_command_entries(&registry, " ");
assert_eq!(entries[0].name, "play.toggle");
assert_eq!(entries[1].name, "scene.reset_lighting");
}
}

View File

@ -282,10 +282,8 @@ pub fn spawn_many_with_history(
if snapshots.is_empty() { if snapshots.is_empty() {
return Vec::new(); return Vec::new();
} }
let mut sibling_index = next_sibling_index(world, None); for (sibling_index, snapshot) in (next_sibling_index(world, None)..).zip(snapshots.iter_mut()) {
for snapshot in &mut snapshots {
snapshot.hierarchy_sibling_index = sibling_index; snapshot.hierarchy_sibling_index = sibling_index;
sibling_index += 1;
} }
let entities: Vec<Entity> = snapshots let entities: Vec<Entity> = snapshots
.iter() .iter()
@ -1489,11 +1487,14 @@ fn capture_gizmo_transform_edits(world: &mut World) {
Entity, Entity,
&Transform, &Transform,
&transform_gizmo_bevy::prelude::GizmoTarget, &transform_gizmo_bevy::prelude::GizmoTarget,
Option<&crate::viewport::brush_edit::BrushElementGizmo>,
)>(); )>();
let active = query let active = query
.iter(world) .iter(world)
.find(|(_, _, target)| target.is_active()) .find(|(_, _, target, brush_element_gizmo)| {
.map(|(entity, transform, _)| (entity, *transform)); brush_element_gizmo.is_none() && target.is_active()
})
.map(|(entity, transform, _, _)| (entity, *transform));
let start_group = let start_group =
active.map(|(entity, transform)| selected_transforms(world, &selection, entity, transform)); active.map(|(entity, transform)| selected_transforms(world, &selection, entity, transform));

View File

@ -24,7 +24,6 @@ pub use play::net_editor;
pub use play::state; pub use play::state;
pub use project::project_io; pub use project::project_io;
pub use project::settings_ui; pub use project::settings_ui;
pub use project::workspace;
pub use scene::scene_io; pub use scene::scene_io;
pub use scene::scene_schema; pub use scene::scene_schema;
pub use scene::scene_view; pub use scene::scene_view;
@ -81,7 +80,6 @@ impl PluginGroup for EditorPluginGroup {
let group = PluginGroupBuilder::start::<Self>() let group = PluginGroupBuilder::start::<Self>()
.add(EditorInfraPlugin) .add(EditorInfraPlugin)
.add(ProjectIoPlugin) .add(ProjectIoPlugin)
.add(workspace::WorkspacePlugin)
.add(scene_schema::SceneSchemaPlugin) .add(scene_schema::SceneSchemaPlugin)
.add(net_editor::NetEditorPlugin) .add(net_editor::NetEditorPlugin)
.add(AssetDbPlugin) .add(AssetDbPlugin)

View File

@ -2,4 +2,3 @@
pub mod project_io; pub mod project_io;
pub mod settings_ui; pub mod settings_ui;
pub mod workspace;

View File

@ -1,150 +0,0 @@
//! Project workspace New/Open and recent scene switcher (H2).
use std::path::{Path, PathBuf};
use bevy::prelude::*;
use settings::{
load_project_settings_from_path, ProjectSettings, ProjectSettingsIo, DEFAULT_PROJECT_PATH,
};
use crate::history::EditorHistory;
use crate::project_io::{push_recent, ProjectWorkspace, UserPreferences};
use crate::scene_io::{SceneIo, SceneIoRequest};
pub struct WorkspacePlugin;
impl Plugin for WorkspacePlugin {
fn build(&self, app: &mut App) {
app.init_resource::<WorkspaceState>()
.init_resource::<WorkspaceIoRequest>()
.add_systems(Update, process_workspace_requests);
}
}
#[derive(Resource, Default)]
pub struct WorkspaceState {
pub project_name: String,
}
#[derive(Resource, Default, Debug)]
pub struct WorkspaceIoRequest {
pub new_project: bool,
pub open_project: bool,
}
pub fn request_new_project(world: &mut World) {
world.resource_mut::<WorkspaceIoRequest>().new_project = true;
}
pub fn request_open_project(world: &mut World) {
world.resource_mut::<WorkspaceIoRequest>().open_project = true;
}
pub fn request_open_recent_scene(world: &mut World, index: usize) {
world.resource_mut::<SceneIo>().request = Some(SceneIoRequest::OpenRecent(index));
}
pub fn sync_recent_levels_to_scene_io(world: &mut World) {
let recent: Vec<PathBuf> = world
.resource::<UserPreferences>()
.recent_levels
.iter()
.map(PathBuf::from)
.collect();
world.resource_mut::<SceneIo>().recent_paths = recent;
}
pub fn remember_opened_level(world: &mut World, path: String) {
if let Some(mut prefs) = world.get_resource_mut::<UserPreferences>() {
push_recent(&mut prefs.recent_levels, path, 8);
let _ = crate::project_io::write_user_preferences(&prefs);
}
}
fn process_workspace_requests(world: &mut World) {
let (new_project, open_project) = {
let mut req = world.resource_mut::<WorkspaceIoRequest>();
let new_project = req.new_project;
let open_project = req.open_project;
req.new_project = false;
req.open_project = false;
(new_project, open_project)
};
if new_project {
world.resource_mut::<SceneIo>().status = apply_new_project(world);
} else if open_project {
world.resource_mut::<SceneIo>().status = apply_open_project(world);
}
}
fn apply_new_project(world: &mut World) -> String {
let default_settings = ProjectSettings::default();
let settings_path = DEFAULT_PROJECT_PATH.to_string();
*world.resource_mut::<ProjectSettings>() = default_settings.clone();
world.resource_mut::<ProjectSettingsIo>().path = settings_path.clone();
world.resource_mut::<ProjectSettingsIo>().dirty = false;
if let Some(mut workspace) = world.get_resource_mut::<ProjectWorkspace>() {
workspace.root = std::env::current_dir()
.ok()
.and_then(|p| p.into_os_string().into_string().ok())
.unwrap_or_else(|| ".".into());
workspace.settings_path = settings_path;
workspace.settings_dirty = false;
}
world.resource_mut::<WorkspaceState>().project_name = default_settings.name.clone();
world.resource_mut::<SceneIo>().request = Some(SceneIoRequest::New);
world.resource_mut::<EditorHistory>().clear();
sync_recent_levels_to_scene_io(world);
format!("New project: {}", default_settings.name)
}
fn apply_open_project(world: &mut World) -> String {
let Some(root) = rfd::FileDialog::new()
.set_title("Open Project Folder")
.pick_folder()
else {
return "Open project cancelled".to_string();
};
let settings_path = root.join("assets/project.ron");
if !settings_path.exists() {
return format!(
"No assets/project.ron in {} — pick a project root folder",
root.display()
);
}
let settings_path_label = settings_path.display().to_string();
let settings = load_project_settings_from_path(&settings_path_label);
*world.resource_mut::<ProjectSettings>() = settings.clone();
world.resource_mut::<ProjectSettingsIo>().path = settings_path_label.clone();
world.resource_mut::<ProjectSettingsIo>().dirty = false;
if let Some(mut workspace) = world.get_resource_mut::<ProjectWorkspace>() {
workspace.root = root.display().to_string();
workspace.settings_path = settings_path_label;
workspace.settings_dirty = false;
}
if let Some(mut prefs) = world.get_resource_mut::<UserPreferences>() {
push_recent(&mut prefs.recent_projects, workspace_root_label(&root), 8);
prefs.last_project_root = Some(root.display().to_string());
let _ = crate::project_io::write_user_preferences(&prefs);
}
world.resource_mut::<WorkspaceState>().project_name = settings.name.clone();
sync_recent_levels_to_scene_io(world);
format!("Opened project: {}", settings.name)
}
fn workspace_root_label(path: &Path) -> String {
path.file_name()
.and_then(|name| name.to_str())
.map(str::to_string)
.unwrap_or_else(|| path.display().to_string())
}

View File

@ -3,10 +3,9 @@ use std::path::{Path, PathBuf};
use bevy::ecs::entity::EntityHashMap; use bevy::ecs::entity::EntityHashMap;
use bevy::ecs::system::SystemState; use bevy::ecs::system::SystemState;
use bevy::prelude::*; use bevy::prelude::*;
use bevy::scene::serde::SceneDeserializer;
use bevy::scene::DynamicScene;
use bevy::scene::DynamicSceneBuilder;
use bevy::window::PrimaryWindow; use bevy::window::PrimaryWindow;
use bevy::world_serialization::serde::WorldDeserializer;
use bevy::world_serialization::{DynamicWorld, DynamicWorldBuilder};
use scene::{document::SceneDocument, strip_schema_version, validate_level_text}; use scene::{document::SceneDocument, strip_schema_version, validate_level_text};
use serde::de::DeserializeSeed; use serde::de::DeserializeSeed;
use shared::{ use shared::{
@ -469,42 +468,42 @@ fn save_entities(world: &mut World, path: &Path, entities: Vec<Entity>) -> Resul
} }
} }
let scene = DynamicSceneBuilder::from_world(world)
.deny_all()
.allow_component::<Name>()
.allow_component::<Transform>()
.allow_component::<ChildOf>()
.allow_component::<LevelObject>()
.allow_component::<ActorId>()
.allow_component::<ActorName>()
.allow_component::<ActorKind>()
.allow_component::<InspectorOrder>()
.allow_component::<Primitive>()
.allow_component::<BrushDesc>()
.allow_component::<StaticMeshRenderer>()
.allow_component::<MaterialDesc>()
.allow_component::<MaterialOverride>()
.allow_component::<RigidBodyDesc>()
.allow_component::<ColliderDesc>()
.allow_component::<PhysicsBody>()
.allow_component::<LightDesc>()
.allow_component::<PlayerSpawn>()
.allow_component::<ModelRef>()
.allow_component::<PrefabRef>()
.allow_component::<PrefabInstance>()
.allow_component::<WeaponSpawn>()
.allow_component::<TriggerVolume>()
.allow_component::<PostProcessVolumeDesc>()
.allow_component::<TeamSpawn>()
.allow_component::<ObjectiveMarker>()
.allow_component::<HierarchySiblingIndex>()
.allow_component::<EditorVisibility>()
.extract_entities(entities.into_iter())
.remove_empty_entities()
.build();
let ron = { let ron = {
let registry = world.resource::<AppTypeRegistry>().read(); let registry = world.resource::<AppTypeRegistry>().read();
let scene = DynamicWorldBuilder::from_world(world, &registry)
.deny_all()
.allow_component::<Name>()
.allow_component::<Transform>()
.allow_component::<ChildOf>()
.allow_component::<LevelObject>()
.allow_component::<ActorId>()
.allow_component::<ActorName>()
.allow_component::<ActorKind>()
.allow_component::<InspectorOrder>()
.allow_component::<Primitive>()
.allow_component::<BrushDesc>()
.allow_component::<StaticMeshRenderer>()
.allow_component::<MaterialDesc>()
.allow_component::<MaterialOverride>()
.allow_component::<RigidBodyDesc>()
.allow_component::<ColliderDesc>()
.allow_component::<PhysicsBody>()
.allow_component::<LightDesc>()
.allow_component::<PlayerSpawn>()
.allow_component::<ModelRef>()
.allow_component::<PrefabRef>()
.allow_component::<PrefabInstance>()
.allow_component::<WeaponSpawn>()
.allow_component::<TriggerVolume>()
.allow_component::<PostProcessVolumeDesc>()
.allow_component::<TeamSpawn>()
.allow_component::<ObjectiveMarker>()
.allow_component::<HierarchySiblingIndex>()
.allow_component::<EditorVisibility>()
.extract_entities(entities.into_iter())
.remove_empty_entities()
.build();
scene scene
.serialize(&registry) .serialize(&registry)
.map_err(|err| format!("could not serialize scene: {err}"))? .map_err(|err| format!("could not serialize scene: {err}"))?
@ -536,10 +535,12 @@ fn load_level(world: &mut World, path: &Path) -> Result<(), String> {
clear_loaded_scene_roots(world); clear_loaded_scene_roots(world);
clear_level_objects(world); clear_level_objects(world);
let dynamic_scene: DynamicScene = { let dynamic_scene: DynamicWorld = {
let registry = world.resource::<AppTypeRegistry>().read(); let registry = world.resource::<AppTypeRegistry>().read();
let scene_deserializer = SceneDeserializer { let mut asset_server = world.resource::<AssetServer>().clone();
let scene_deserializer = WorldDeserializer {
type_registry: &registry, type_registry: &registry,
load_from_path: &mut asset_server,
}; };
let mut deserializer = let mut deserializer =
ron::de::Deserializer::from_str(&bevy_ron).map_err(|err| err.to_string())?; ron::de::Deserializer::from_str(&bevy_ron).map_err(|err| err.to_string())?;
@ -590,7 +591,9 @@ fn finalize_scene_load(world: &mut World) {
)> = SystemState::new(world); )> = SystemState::new(world);
{ {
let (settings, scene_suns, project_suns) = state.get_mut(world); let (settings, scene_suns, project_suns) = state
.get_mut(world)
.expect("finalize_scene_load system params should be valid");
game_hot::sync_project_sun_from_settings(settings, scene_suns, project_suns); game_hot::sync_project_sun_from_settings(settings, scene_suns, project_suns);
} }
state.apply(world); state.apply(world);

View File

@ -897,6 +897,10 @@ fn visible_assets(
rows rows
} }
#[expect(
clippy::too_many_arguments,
reason = "asset grid rendering keeps immediate-mode UI inputs explicit"
)]
fn asset_grid( fn asset_grid(
world: &mut World, world: &mut World,
ui: &mut egui::Ui, ui: &mut egui::Ui,
@ -1169,6 +1173,10 @@ fn prefetch_embedded_thumbnails(
}); });
} }
#[expect(
clippy::too_many_arguments,
reason = "embedded asset rendering keeps immediate-mode UI inputs explicit"
)]
fn draw_embedded_asset_cell( fn draw_embedded_asset_cell(
world: &mut World, world: &mut World,
ui: &mut egui::Ui, ui: &mut egui::Ui,

View File

@ -1,6 +1,6 @@
//! Dock tab open/focus helpers and default panel node placement. //! Dock tab open/focus helpers and default panel node placement.
use egui_dock::{DockState, Node, NodeIndex, SurfaceIndex, TabIndex, Tree}; use egui_dock::{DockState, Node, NodeIndex, NodePath, SurfaceIndex, TabIndex, TabPath, Tree};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use super::EditorTab; use super::EditorTab;
@ -43,15 +43,15 @@ impl PanelNodes {
/// Scan the main surface for open tabs and refresh node indices (handles user drag/split). /// Scan the main surface for open tabs and refresh node indices (handles user drag/split).
pub fn discover(dock: &DockState<EditorTab>, fallback: Self) -> Self { pub fn discover(dock: &DockState<EditorTab>, fallback: Self) -> Self {
let mut nodes = fallback; let mut nodes = fallback;
for ((surface, node_idx), tab) in dock.iter_all_tabs() { for (path, tab) in dock.iter_all_tabs() {
if surface != SurfaceIndex::main() { if path.surface != SurfaceIndex::main() {
continue; continue;
} }
match tab { match tab {
EditorTab::Viewport | EditorTab::GameView => nodes.main_view = node_idx.0, EditorTab::Viewport | EditorTab::GameView => nodes.main_view = path.node.0,
EditorTab::Hierarchy => nodes.hierarchy = node_idx.0, EditorTab::Hierarchy => nodes.hierarchy = path.node.0,
EditorTab::Inspector => nodes.inspector = node_idx.0, EditorTab::Inspector => nodes.inspector = path.node.0,
EditorTab::AssetBrowser | EditorTab::Toolbar => nodes.bottom = node_idx.0, EditorTab::AssetBrowser | EditorTab::Toolbar => nodes.bottom = path.node.0,
} }
} }
nodes nodes
@ -93,8 +93,9 @@ pub fn open_and_focus_tab(
let tab_idx = push_tab_to_node(tree, node, tab); let tab_idx = push_tab_to_node(tree, node, tab);
(node, tab_idx) (node, tab_idx)
}; };
dock.set_active_tab((SurfaceIndex::main(), node, tab_idx)); let node_path = NodePath::new(SurfaceIndex::main(), node);
dock.set_focused_node_and_surface((SurfaceIndex::main(), node)); let _ = dock.set_active_tab(TabPath::new(SurfaceIndex::main(), node, tab_idx));
dock.set_focused_node_and_surface(node_path);
(node, tab_idx) (node, tab_idx)
} }

View File

@ -17,5 +17,26 @@ fn configure_editor_fonts(mut contexts: Query<&mut EguiContext, Added<PrimaryEgu
}; };
let mut fonts = bevy_egui::egui::FontDefinitions::default(); let mut fonts = bevy_egui::egui::FontDefinitions::default();
egui_phosphor_icons::add_fonts(&mut fonts); egui_phosphor_icons::add_fonts(&mut fonts);
// Phosphor families contain icon glyphs only. Text fallbacks give egui a
// replacement glyph for malformed/missing icons without changing icon lookup.
let text_fallbacks = fonts
.families
.get(&bevy_egui::egui::FontFamily::Proportional)
.cloned()
.unwrap_or_default();
for (family, font_names) in &mut fonts.families {
let bevy_egui::egui::FontFamily::Name(name) = family else {
continue;
};
if !name.starts_with("phosphor-") {
continue;
}
for fallback in &text_fallbacks {
if !font_names.contains(fallback) {
font_names.push(fallback.clone());
}
}
}
ctx.get_mut().set_fonts(fonts); ctx.get_mut().set_fonts(fonts);
} }

View File

@ -20,10 +20,10 @@ use crate::ui::helpers::{
use crate::ui::hierarchy_ops::{ use crate::ui::hierarchy_ops::{
authored_children, editor_visibility, entity_hierarchy_path, entity_matches_filter, authored_children, editor_visibility, entity_hierarchy_path, entity_matches_filter,
hierarchy_label_for_entity, is_entity_locked, level_object_roots, sort_siblings, hierarchy_label_for_entity, is_entity_locked, level_object_roots, sort_siblings,
would_create_cycle, would_create_cycle, HierarchyLabel,
}; };
use crate::ui::hierarchy_state::{HierarchyPanelState, HierarchySort}; use crate::ui::hierarchy_state::{HierarchyPanelState, HierarchySort};
use crate::ui::theme::panel_heading; use crate::ui::theme::{panel_heading, TEXT, TEXT_DIM, TEXT_SELECTED};
#[derive(Clone)] #[derive(Clone)]
struct HierarchyDragPayload { struct HierarchyDragPayload {
@ -138,6 +138,7 @@ pub fn hierarchy_panel_ui(
.is_some(); .is_some();
egui::ScrollArea::vertical() egui::ScrollArea::vertical()
.id_salt("hierarchy_tree")
.scroll_source(ScrollSource { .scroll_source(ScrollSource {
scroll_bar: true, scroll_bar: true,
drag: !dragging, drag: !dragging,
@ -230,6 +231,10 @@ fn draw_root_drop_zone(ui: &mut egui::Ui, drop_target: &mut Option<DropTarget>)
} }
} }
#[expect(
clippy::too_many_arguments,
reason = "recursive hierarchy rendering threads interaction state explicitly"
)]
fn draw_authored_node( fn draw_authored_node(
world: &mut World, world: &mut World,
ui: &mut egui::Ui, ui: &mut egui::Ui,
@ -278,7 +283,7 @@ fn draw_authored_node(
} else { } else {
icons::CARET_RIGHT icons::CARET_RIGHT
}; };
if ui.small_button(twistie.as_str()).clicked() { if ui.small_button(twistie.regular()).clicked() {
let path = path.clone(); let path = path.clone();
let expanding = !world let expanding = !world
.resource::<HierarchyPanelState>() .resource::<HierarchyPanelState>()
@ -353,7 +358,8 @@ fn draw_authored_node(
} else { } else {
let selected = selected_entities.contains(entity); let selected = selected_entities.contains(entity);
let label = hierarchy_label_for_entity(world, entity, HierarchyNodeKind::Authored); let label = hierarchy_label_for_entity(world, entity, HierarchyNodeKind::Authored);
let response = ui.selectable_label(selected, label); let color = if selected { TEXT_SELECTED } else { TEXT };
let response = ui.selectable_label(selected, hierarchy_label_text(label, color));
if response.clicked() && !is_locked { if response.clicked() && !is_locked {
let additive = ui.input(|input| { let additive = ui.input(|input| {
input.modifiers.shift || input.modifiers.command || input.modifiers.ctrl input.modifiers.shift || input.modifiers.command || input.modifiers.ctrl
@ -512,7 +518,7 @@ fn draw_generated_node(
} else { } else {
icons::CARET_RIGHT icons::CARET_RIGHT
}; };
if ui.small_button(twistie.as_str()).clicked() { if ui.small_button(twistie.regular()).clicked() {
is_expanded = !is_expanded; is_expanded = !is_expanded;
} }
} else { } else {
@ -520,14 +526,10 @@ fn draw_generated_node(
} }
ui.add_enabled( ui.add_enabled(
false, false,
egui::Label::new( egui::Label::new(hierarchy_label_text(
egui::RichText::new(hierarchy_label_for_entity( hierarchy_label_for_entity(world, entity, HierarchyNodeKind::Generated),
world, TEXT_DIM,
entity, )),
HierarchyNodeKind::Generated,
))
.weak(),
),
); );
}); });
if !is_expanded { if !is_expanded {
@ -555,7 +557,9 @@ fn draw_runtime_node(
ui.add_space(depth as f32 * 14.0); ui.add_space(depth as f32 * 14.0);
ui.add_space(18.0); ui.add_space(18.0);
let label = hierarchy_label_for_entity(world, entity, HierarchyNodeKind::Runtime); let label = hierarchy_label_for_entity(world, entity, HierarchyNodeKind::Runtime);
let response = ui.selectable_label(selected_entities.contains(entity), label); let selected = selected_entities.contains(entity);
let color = if selected { TEXT_SELECTED } else { TEXT_DIM };
let response = ui.selectable_label(selected, hierarchy_label_text(label, color));
if response.clicked() { if response.clicked() {
selected_entities.select_replace(entity); selected_entities.select_replace(entity);
} }
@ -575,6 +579,29 @@ fn draw_runtime_node(
} }
} }
fn hierarchy_label_text(label: HierarchyLabel, color: egui::Color32) -> egui::WidgetText {
let mut job = egui::text::LayoutJob::default();
job.append(
label.icon,
0.0,
egui::TextFormat {
font_id: egui::FontId::new(13.0, egui::FontFamily::Name("phosphor-regular".into())),
color,
..Default::default()
},
);
job.append(
&format!(" {}", label.text),
0.0,
egui::TextFormat {
font_id: egui::FontId::new(13.0, egui::FontFamily::Proportional),
color,
..Default::default()
},
);
job.into()
}
fn authored_context_menu( fn authored_context_menu(
world: &mut World, world: &mut World,
ui: &mut egui::Ui, ui: &mut egui::Ui,
@ -596,15 +623,16 @@ fn authored_context_menu(
delete_entities_with_history(world, &[entity]); delete_entities_with_history(world, &[entity]);
ui.close(); ui.close();
} }
if selected_entities.len() >= 2 && selected_entities.contains(entity) { if selected_entities.len() >= 2
if ui.button("Group").clicked() { && selected_entities.contains(entity)
let entities: Vec<Entity> = selected_entities && ui.button("Group").clicked()
.iter() {
.filter(|e| is_level_object(world, *e)) let entities: Vec<Entity> = selected_entities
.collect(); .iter()
group_selection_with_history(world, &entities); .filter(|e| is_level_object(world, *e))
ui.close(); .collect();
} group_selection_with_history(world, &entities);
ui.close();
} }
if ui.button("Unparent").clicked() { if ui.button("Unparent").clicked() {
reparent_with_history(world, entity, None); reparent_with_history(world, entity, None);
@ -701,7 +729,7 @@ pub(crate) fn is_hierarchy_generated_child(world: &World, entity: Entity) -> boo
&& (entity_ref.contains::<Transform>() || entity_ref.contains::<GlobalTransform>()) && (entity_ref.contains::<Transform>() || entity_ref.contains::<GlobalTransform>())
&& (entity_ref.contains::<Name>() && (entity_ref.contains::<Name>()
|| entity_ref.contains::<Mesh3d>() || entity_ref.contains::<Mesh3d>()
|| entity_ref.contains::<SceneRoot>()) || entity_ref.contains::<WorldAssetRoot>())
}) })
} }

View File

@ -350,11 +350,16 @@ pub fn actor_kind_icon(kind: ActorKind) -> &'static str {
} }
} }
pub struct HierarchyLabel {
pub icon: &'static str,
pub text: String,
}
pub fn hierarchy_label_for_entity( pub fn hierarchy_label_for_entity(
world: &World, world: &World,
entity: Entity, entity: Entity,
kind: HierarchyNodeKind, kind: HierarchyNodeKind,
) -> String { ) -> HierarchyLabel {
use egui_phosphor_icons::icons; use egui_phosphor_icons::icons;
let icon = world let icon = world
.get::<ActorKind>(entity) .get::<ActorKind>(entity)
@ -365,18 +370,18 @@ pub fn hierarchy_label_for_entity(
HierarchyNodeKind::Generated => icons::STACK.as_str(), HierarchyNodeKind::Generated => icons::STACK.as_str(),
HierarchyNodeKind::Runtime => icons::CPU.as_str(), HierarchyNodeKind::Runtime => icons::CPU.as_str(),
}); });
let mut label = format!("{} {}", icon, entity_name(world, entity)); let mut text = entity_name(world, entity);
if let Some(light) = world.get::<LightDesc>(entity) { if let Some(light) = world.get::<LightDesc>(entity) {
if matches!(light.kind, AuthoringLightKind::Directional) { if matches!(light.kind, AuthoringLightKind::Directional) {
label.push_str(""); text.push_str(" (sun)");
} }
} }
match kind { match kind {
HierarchyNodeKind::Authored => {} HierarchyNodeKind::Authored => {}
HierarchyNodeKind::Generated => label.push_str(" (generated)"), HierarchyNodeKind::Generated => text.push_str(" (generated)"),
HierarchyNodeKind::Runtime => label.push_str(" (runtime)"), HierarchyNodeKind::Runtime => text.push_str(" (runtime)"),
} }
label HierarchyLabel { icon, text }
} }
pub fn entity_matches_filter(world: &World, entity: Entity, filter_lower: &str) -> bool { pub fn entity_matches_filter(world: &World, entity: Entity, filter_lower: &str) -> bool {

View File

@ -48,12 +48,13 @@ impl Default for HierarchyPanelState {
impl HierarchyPanelState { impl HierarchyPanelState {
pub fn from_prefs(prefs: &UserPreferences) -> Self { pub fn from_prefs(prefs: &UserPreferences) -> Self {
let mut state = Self::default(); Self {
state.filter = prefs.hierarchy_filter.clone(); filter: prefs.hierarchy_filter.clone(),
state.show_generated = prefs.hierarchy_show_generated; show_generated: prefs.hierarchy_show_generated,
state.show_runtime = prefs.hierarchy_show_runtime; show_runtime: prefs.hierarchy_show_runtime,
state.sort_mode = prefs.hierarchy_sort_mode; sort_mode: prefs.hierarchy_sort_mode,
state ..Self::default()
}
} }
pub fn mark_dirty(&mut self) { pub fn mark_dirty(&mut self) {

View File

@ -764,7 +764,7 @@ fn add_component_picker_shelf(
target: Entity, target: Entity,
anchor: egui::Rect, anchor: egui::Rect,
) { ) {
if !world.get_entity(target).is_ok() { if world.get_entity(target).is_err() {
if let Some(mut state) = world.get_resource_mut::<InspectorPanelState>() { if let Some(mut state) = world.get_resource_mut::<InspectorPanelState>() {
state.add_component_open = false; state.add_component_open = false;
state.add_component_target = None; state.add_component_target = None;
@ -777,7 +777,7 @@ fn add_component_picker_shelf(
.descriptors .descriptors
.clone(); .clone();
let visible = ui.clip_rect().intersect(ui.ctx().available_rect()); let visible = ui.clip_rect().intersect(ui.ctx().content_rect());
let space_above = (anchor.min.y - visible.top()).max(0.0); let space_above = (anchor.min.y - visible.top()).max(0.0);
let space_below = (visible.bottom() - anchor.max.y).max(0.0); let space_below = (visible.bottom() - anchor.max.y).max(0.0);
let direction = if space_below >= space_above { let direction = if space_below >= space_above {
@ -865,7 +865,7 @@ fn add_component_picker_shelf_contents(
} }
let search = search_input.to_lowercase(); let search = search_input.to_lowercase();
let filtered = filtered_component_descriptors(&descriptors, &search); let filtered = filtered_component_descriptors(descriptors, &search);
{ {
let mut state = world.resource_mut::<InspectorPanelState>(); let mut state = world.resource_mut::<InspectorPanelState>();
if !filtered.is_empty() { if !filtered.is_empty() {
@ -905,7 +905,7 @@ fn add_component_picker_shelf_contents(
.resource::<InspectorPanelState>() .resource::<InspectorPanelState>()
.add_component_selected_index; .add_component_selected_index;
if let Some(descriptor) = filtered.get(selected_index) { if let Some(descriptor) = filtered.get(selected_index) {
let add_state = component_add_state(world, target, descriptor, &descriptors); let add_state = component_add_state(world, target, descriptor, descriptors);
if add_state.addable { if add_state.addable {
insert_registered_component(world, target, descriptor.type_name); insert_registered_component(world, target, descriptor.type_name);
let mut state = world.resource_mut::<InspectorPanelState>(); let mut state = world.resource_mut::<InspectorPanelState>();
@ -941,8 +941,7 @@ fn add_component_picker_shelf_contents(
last_category = Some(descriptor.category); last_category = Some(descriptor.category);
} }
let add_state = let add_state = component_add_state(world, target, descriptor, descriptors);
component_add_state(world, target, descriptor, &descriptors);
let selected = index == selected_index; let selected = index == selected_index;
let row = ui let row = ui
.horizontal(|ui| { .horizontal(|ui| {
@ -1893,8 +1892,10 @@ fn static_mesh_renderer_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity)
.clicked(); .clicked();
if add_slot { if add_slot {
let index = renderer.slots.len(); let index = renderer.slots.len();
let mut entry = StaticMeshRendererEntry::default(); let entry = StaticMeshRendererEntry {
entry.id = ComponentInstanceId::new(format!("slot:{index}")); id: ComponentInstanceId::new(format!("slot:{index}")),
..Default::default()
};
renderer.slots.push(entry); renderer.slots.push(entry);
changed = true; changed = true;
} }
@ -2026,6 +2027,10 @@ fn thumbnail_for_mesh(
.and_then(|candidate| candidate.texture_id) .and_then(|candidate| candidate.texture_id)
} }
#[expect(
clippy::too_many_arguments,
reason = "asset selector rows keep immediate-mode UI inputs explicit"
)]
fn asset_selector_row( fn asset_selector_row(
ui: &mut egui::Ui, ui: &mut egui::Ui,
label: &str, label: &str,
@ -2075,6 +2080,10 @@ fn asset_selector_row(
response response
} }
#[expect(
clippy::too_many_arguments,
reason = "asset selector controls keep immediate-mode UI inputs explicit"
)]
fn asset_selector_control( fn asset_selector_control(
ui: &mut egui::Ui, ui: &mut egui::Ui,
icon: Icon, icon: Icon,

View File

@ -9,7 +9,6 @@ use crate::history::{apply_command_redo, apply_command_undo, EditorHistory};
use crate::scene_io::{SceneIo, SceneIoRequest}; use crate::scene_io::{SceneIo, SceneIoRequest};
use crate::settings_ui::{open_project_settings_panel, ProjectSettingsPanel}; use crate::settings_ui::{open_project_settings_panel, ProjectSettingsPanel};
use crate::state::{EditorMode, PlayPaused}; use crate::state::{EditorMode, PlayPaused};
use crate::workspace::{request_new_project, request_open_project};
use super::diagnostics::{BrushDiagnosticsPanel, DiagnosticsPanel}; use super::diagnostics::{BrushDiagnosticsPanel, DiagnosticsPanel};
use super::dock_tabs::{open_and_focus_tab, tab_is_open, tab_label, PanelNodes, PANEL_TABS}; use super::dock_tabs::{open_and_focus_tab, tab_is_open, tab_label, PanelNodes, PANEL_TABS};
@ -22,25 +21,14 @@ use super::EditorTab;
pub fn top_menu_bar( pub fn top_menu_bar(
world: &mut World, world: &mut World,
ctx: &egui::Context, root_ui: &mut egui::Ui,
selected: &SelectedEntities, selected: &SelectedEntities,
dock_state: &mut DockState<EditorTab>, dock_state: &mut DockState<EditorTab>,
panel_nodes: &mut PanelNodes, panel_nodes: &mut PanelNodes,
) { ) {
egui::TopBottomPanel::top("editor_menu_bar").show(ctx, |ui| { egui::Panel::top("editor_menu_bar").show_inside(root_ui, |ui| {
egui::MenuBar::new().ui(ui, |ui| { egui::MenuBar::new().ui(ui, |ui| {
ui.menu_button("File", |ui| { ui.menu_button("File", |ui| {
ui.menu_button("Project", |ui| {
if menu_item(ui, "New Project", None, true).clicked() {
request_new_project(world);
ui.close();
}
if menu_item(ui, "Open Project...", None, true).clicked() {
request_open_project(world);
ui.close();
}
});
ui.separator();
if menu_item(ui, "New Scene", None, true).clicked() { if menu_item(ui, "New Scene", None, true).clicked() {
world.resource_mut::<SceneIo>().request = Some(SceneIoRequest::New); world.resource_mut::<SceneIo>().request = Some(SceneIoRequest::New);
ui.close(); ui.close();

View File

@ -66,7 +66,7 @@ pub struct UiState {
} }
pub fn egui_captures_keyboard(ctx: &egui::Context) -> bool { pub fn egui_captures_keyboard(ctx: &egui::Context) -> bool {
ctx.wants_keyboard_input() ctx.egui_wants_keyboard_input()
} }
pub fn egui_captures_keyboard_from_world(world: &mut World) -> bool { pub fn egui_captures_keyboard_from_world(world: &mut World) -> bool {
@ -118,6 +118,10 @@ impl UiState {
} }
} }
#[expect(
deprecated,
reason = "bevy_egui exposes a Context during EguiPrimaryContextPass, so one top-level panel must bridge to the new Ui-based panel API"
)]
fn ui(&mut self, world: &mut World, ctx: &mut egui::Context, dt: f32) { fn ui(&mut self, world: &mut World, ctx: &mut egui::Context, dt: f32) {
apply_editor_theme(ctx); apply_editor_theme(ctx);
@ -129,17 +133,6 @@ impl UiState {
} }
} }
top_menu_bar(
world,
ctx,
&self.selected_entities,
&mut self.dock_state,
&mut self.panel_nodes,
);
editor_toolbar_panel(world, ctx);
status_bar_ui(world, ctx, &self.selected_entities, mode);
self.pointer_in_viewport = false; self.pointer_in_viewport = false;
self.viewport_pointer_pos = None; self.viewport_pointer_pos = None;
@ -154,21 +147,34 @@ impl UiState {
}); });
} }
let mut viewer = TabViewer { egui::CentralPanel::no_frame().show(ctx, |root_ui| {
world, top_menu_bar(
viewport_rect: &mut self.viewport_rect, world,
pointer_in_viewport: &mut self.pointer_in_viewport, root_ui,
viewport_pointer_pos: &mut self.viewport_pointer_pos, &self.selected_entities,
playing, &mut self.dock_state,
cursor_grabbed, &mut self.panel_nodes,
selected_entities: &mut self.selected_entities, );
renaming_entity: &mut self.renaming_entity,
rename_buffer: &mut self.rename_buffer,
};
DockArea::new(&mut self.dock_state) editor_toolbar_panel(world, root_ui);
.style(editor_dock_style(ctx)) status_bar_ui(world, root_ui, &self.selected_entities, mode);
.show(ctx, &mut viewer);
let mut viewer = TabViewer {
world,
viewport_rect: &mut self.viewport_rect,
pointer_in_viewport: &mut self.pointer_in_viewport,
viewport_pointer_pos: &mut self.viewport_pointer_pos,
playing,
cursor_grabbed,
selected_entities: &mut self.selected_entities,
renaming_entity: &mut self.renaming_entity,
rename_buffer: &mut self.rename_buffer,
};
DockArea::new(&mut self.dock_state)
.style(editor_dock_style(ctx))
.show_inside(root_ui, &mut viewer);
});
self.panel_nodes = PanelNodes::discover(&self.dock_state, self.panel_nodes); self.panel_nodes = PanelNodes::discover(&self.dock_state, self.panel_nodes);

View File

@ -13,59 +13,87 @@ use super::theme::{status_bar_frame, TEXT};
pub fn status_bar_ui( pub fn status_bar_ui(
world: &World, world: &World,
ctx: &egui::Context, root_ui: &mut egui::Ui,
selected: &SelectedEntities, selected: &SelectedEntities,
mode: EditorMode, mode: EditorMode,
) { ) {
egui::TopBottomPanel::bottom("editor_status_bar") egui::Panel::bottom("editor_status_bar")
.exact_height(24.0) .exact_size(24.0)
.frame(status_bar_frame()) .frame(status_bar_frame())
.show(ctx, |ui| { .show_inside(root_ui, |ui| {
ui.horizontal_centered(|ui| { ui.spacing_mut().item_spacing.x = 12.0;
ui.spacing_mut().item_spacing.x = 16.0; egui::containers::Sides::new()
ui.label(egui::RichText::new(scene_line(world)).color(TEXT)); .shrink_left()
.truncate()
.show(
ui,
|ui| {
let status = primary_status_line(world);
ui.add(
egui::Label::new(egui::RichText::new(&status).color(TEXT)).truncate(),
)
.on_hover_text(status);
},
|ui| {
let mode_label = match mode {
EditorMode::Editing => "Edit".to_string(),
EditorMode::Playing => {
if world.resource::<PlayPaused>().0 {
"Play (paused)".to_string()
} else {
"Play".to_string()
}
}
};
ui.label(egui::RichText::new(mode_label).color(TEXT));
let mode_label = match mode { let count = selected.len();
EditorMode::Editing => "Edit".to_string(), let selection = match count {
EditorMode::Playing => { 0 => "None".to_string(),
if world.resource::<PlayPaused>().0 { 1 => "1 selected".to_string(),
"Play (paused)".to_string() _ => format!("{count} selected"),
} else { };
"Play".to_string() ui.label(egui::RichText::new(selection).color(TEXT));
ui.label(
egui::RichText::new(world.resource::<EditorHistory>().status.clone())
.color(TEXT),
);
if let Some(operator_label) = world.resource::<ActiveOperator>().label() {
ui.label(egui::RichText::new(operator_label).color(TEXT));
} }
}
};
ui.label(egui::RichText::new(mode_label).color(TEXT));
let count = selected.len(); #[cfg(feature = "hot-reload")]
let sel = if count == 0 { if let Some(hot) = world.get_resource::<crate::hot_reload::HotReloadState>()
"None".to_string() {
} else if count == 1 { ui.label(egui::RichText::new(hot.label.clone()).color(TEXT));
"1 selected".to_string() }
} else { },
format!("{count} selected")
};
ui.label(egui::RichText::new(sel).color(TEXT));
ui.label(
egui::RichText::new(world.resource::<EditorHistory>().status.clone())
.color(TEXT),
); );
if let Some(operator_label) = world.resource::<ActiveOperator>().label() {
ui.label(egui::RichText::new(operator_label).color(TEXT));
}
#[cfg(feature = "hot-reload")]
if let Some(hot) = world.get_resource::<crate::hot_reload::HotReloadState>() {
ui.label(egui::RichText::new(hot.label.clone()).color(TEXT));
}
});
}); });
} }
fn scene_line(world: &World) -> String { fn scene_line(scene_io: &SceneIo) -> String {
let scene_io = world.resource::<SceneIo>();
let dirty = if scene_io.dirty { " *" } else { "" }; let dirty = if scene_io.dirty { " *" } else { "" };
format!("Scene: {}{dirty}", scene_io.active_path_label()) format!("Scene: {}{dirty}", scene_io.active_path_label())
} }
fn primary_status_line(world: &World) -> String {
let scene_io = world.resource::<SceneIo>();
format!("{} | {}", scene_io.status, scene_line(scene_io))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scene_line_marks_unsaved_changes() {
let mut scene_io = SceneIo::default();
assert!(!scene_line(&scene_io).ends_with('*'));
scene_io.mark_dirty();
assert!(scene_line(&scene_io).ends_with('*'));
}
}

View File

@ -52,7 +52,7 @@ pub fn apply_editor_theme(ctx: &egui::Context) {
visuals.window_stroke = Stroke::new(1.0, BORDER); visuals.window_stroke = Stroke::new(1.0, BORDER);
visuals.window_corner_radius = CornerRadius::same(4); visuals.window_corner_radius = CornerRadius::same(4);
let mut style = (*ctx.style()).clone(); let mut style = (*ctx.global_style()).clone();
style.visuals = visuals; style.visuals = visuals;
style.spacing.item_spacing = egui::vec2(8.0, 6.0); style.spacing.item_spacing = egui::vec2(8.0, 6.0);
style.spacing.button_padding = egui::vec2(8.0, 4.0); style.spacing.button_padding = egui::vec2(8.0, 4.0);
@ -69,11 +69,11 @@ pub fn apply_editor_theme(ctx: &egui::Context) {
egui::TextStyle::Heading, egui::TextStyle::Heading,
FontId::new(15.0, egui::FontFamily::Proportional), FontId::new(15.0, egui::FontFamily::Proportional),
); );
ctx.set_style(style); ctx.set_global_style(style);
} }
pub fn editor_dock_style(ctx: &egui::Context) -> DockStyle { pub fn editor_dock_style(ctx: &egui::Context) -> DockStyle {
let mut style = DockStyle::from_egui(ctx.style().as_ref()); let mut style = DockStyle::from_egui(ctx.global_style().as_ref());
style.dock_area_padding = Some(egui::Margin::same(2)); style.dock_area_padding = Some(egui::Margin::same(2));
style.main_surface_border_stroke = Stroke::new(1.0, BORDER); style.main_surface_border_stroke = Stroke::new(1.0, BORDER);
style.separator.width = 2.0; style.separator.width = 2.0;

View File

@ -19,11 +19,13 @@ use super::widgets::{icon_button, panel_toolbar_row, toolbar_separator, transpor
/// Toolbar strip height (menu bar is separate). /// Toolbar strip height (menu bar is separate).
const TOOLBAR_HEIGHT: f32 = 56.0; const TOOLBAR_HEIGHT: f32 = 56.0;
const TRANSPORT_WIDTH: f32 = 152.0;
const TRANSPORT_SLOT_STEP: f32 = 54.0;
pub fn editor_toolbar_panel(world: &mut World, ctx: &egui::Context) { pub fn editor_toolbar_panel(world: &mut World, root_ui: &mut egui::Ui) {
egui::TopBottomPanel::top("editor_toolbar") egui::Panel::top("editor_toolbar")
.exact_height(TOOLBAR_HEIGHT) .exact_size(TOOLBAR_HEIGHT)
.show(ctx, |ui| { .show_inside(root_ui, |ui| {
toolbar_ui(world, ui); toolbar_ui(world, ui);
}); });
} }
@ -41,7 +43,7 @@ pub fn toolbar_ui(world: &mut World, ui: &mut egui::Ui) {
); );
let transport_rect = egui::Rect::from_center_size( let transport_rect = egui::Rect::from_center_size(
egui::pos2(toolbar_rect.center().x, center_y), egui::pos2(toolbar_rect.center().x, center_y),
egui::vec2(transport_width(world), 44.0), egui::vec2(TRANSPORT_WIDTH, 44.0),
); );
let mut left_ui = ui.new_child( let mut left_ui = ui.new_child(
@ -63,16 +65,6 @@ pub fn toolbar_ui(world: &mut World, ui: &mut egui::Ui) {
transport_controls(world, &mut transport_ui); transport_controls(world, &mut transport_ui);
} }
fn transport_width(world: &World) -> f32 {
let button_count = match *world.resource::<State<EditorMode>>().get() {
EditorMode::Editing => 1.0,
EditorMode::Playing => 3.0,
};
let gap_count = button_count - 1.0;
button_count * 44.0 + gap_count * 10.0
}
fn left_toolbar(world: &mut World, ui: &mut egui::Ui) { fn left_toolbar(world: &mut World, ui: &mut egui::Ui) {
panel_toolbar_row(ui, |ui| { panel_toolbar_row(ui, |ui| {
if icon_button(ui, icons::CUBE, "Spawn cube").clicked() { if icon_button(ui, icons::CUBE, "Spawn cube").clicked() {
@ -113,7 +105,13 @@ fn transport_controls(world: &mut World, ui: &mut egui::Ui) {
let mode = *world.resource::<State<EditorMode>>().get(); let mode = *world.resource::<State<EditorMode>>().get();
match mode { match mode {
EditorMode::Editing => { EditorMode::Editing => {
if transport_button_large(ui, icons::PLAY, "Play (F5)", true).clicked() { ui.add_space(TRANSPORT_SLOT_STEP);
let play_clicked = ui
.push_id("transport_center", |ui| {
transport_button_large(ui, icons::PLAY, "Play (F5)", true).clicked()
})
.inner;
if play_clicked {
toggle_play_mode(world); toggle_play_mode(world);
} }
} }
@ -125,12 +123,21 @@ fn transport_controls(world: &mut World, ui: &mut egui::Ui) {
} else { } else {
"Pause simulation (F6)" "Pause simulation (F6)"
}; };
if transport_button_large(ui, pause_icon, pause_tip, false).clicked() { let pause_clicked = ui
.push_id("transport_left", |ui| {
transport_button_large(ui, pause_icon, pause_tip, false).clicked()
})
.inner;
if pause_clicked {
toggle_play_paused(world); toggle_play_paused(world);
} }
if transport_button_large(ui, icons::STOP, "Stop and return to Edit (F5)", false) let stop_clicked = ui
.clicked() .push_id("transport_center", |ui| {
{ transport_button_large(ui, icons::STOP, "Stop and return to Edit (F5)", false)
.clicked()
})
.inner;
if stop_clicked {
toggle_play_mode(world); toggle_play_mode(world);
} }
@ -139,7 +146,12 @@ fn transport_controls(world: &mut World, ui: &mut egui::Ui) {
PlayPossession::Possessed => (icons::SIGN_OUT, "Eject from player (F8)"), PlayPossession::Possessed => (icons::SIGN_OUT, "Eject from player (F8)"),
PlayPossession::Ejected => (icons::USER, "Possess player (F8)"), PlayPossession::Ejected => (icons::USER, "Possess player (F8)"),
}; };
if transport_button_large(ui, eject_icon, eject_tip, false).clicked() { let possession_clicked = ui
.push_id("transport_right", |ui| {
transport_button_large(ui, eject_icon, eject_tip, false).clicked()
})
.inner;
if possession_clicked {
toggle_possession(world); toggle_possession(world);
} }
} }

View File

@ -15,7 +15,7 @@ use crate::render_target::ViewportRenderTarget;
use crate::selection::ViewportClick; use crate::selection::ViewportClick;
use crate::state::PlayPossession; use crate::state::PlayPossession;
use crate::viewport::actor_icons::ActorIconSettings; use crate::viewport::actor_icons::ActorIconSettings;
use crate::viewport::brush_edit::BrushEditMode; use crate::viewport::brush_edit::{BrushEditMode, BrushElementSelection};
use crate::viewport::brush_tool::{BrushToolPhase, BrushToolState}; use crate::viewport::brush_tool::{BrushToolPhase, BrushToolState};
use crate::viewport::{ use crate::viewport::{
snap_translation, viewport_ground_position, EditorViewportMode, ViewportDisplayMode, snap_translation, viewport_ground_position, EditorViewportMode, ViewportDisplayMode,
@ -36,6 +36,10 @@ pub struct ViewportUiState {
const VIEWPORT_TOOLTIP: &str = "RMB + WASD/QE: fly | MMB: pan | Scroll: dolly\nW/E/R: gizmo | X: world/local | F: focus | G: game view | Ctrl+G: grid\nTab: cycle overlapping picks | Play/Pause/Stop: main toolbar (F5 / F6)"; const VIEWPORT_TOOLTIP: &str = "RMB + WASD/QE: fly | MMB: pan | Scroll: dolly\nW/E/R: gizmo | X: world/local | F: focus | G: game view | Ctrl+G: grid\nTab: cycle overlapping picks | Play/Pause/Stop: main toolbar (F5 / F6)";
#[expect(
clippy::too_many_arguments,
reason = "viewport tab rendering keeps immediate-mode UI inputs explicit"
)]
pub fn viewport_tab_ui( pub fn viewport_tab_ui(
world: &mut World, world: &mut World,
ui: &mut egui::Ui, ui: &mut egui::Ui,
@ -161,20 +165,58 @@ fn scene_view_brush_mode_badge(world: &World, ctx: &egui::Context, scene_rect: e
if !mode.is_element_mode() { if !mode.is_element_mode() {
return; return;
} }
let selected_count = world
.get_resource::<BrushElementSelection>()
.map(|selection| selection.elements.len())
.unwrap_or(0);
egui::Area::new(egui::Id::new("scene_view_brush_mode_badge")) egui::Area::new(egui::Id::new("scene_view_brush_mode_badge"))
.fixed_pos(scene_rect.right_bottom() + egui::vec2(-150.0, -22.0)) .fixed_pos(scene_rect.left_top() + egui::vec2(8.0, 52.0))
.interactable(false) .interactable(false)
.show(ctx, |ui| { .show(ctx, |ui| {
egui::Frame::new() egui::Frame::new()
.fill(egui::Color32::from_rgba_unmultiplied(35, 44, 50, 220)) .fill(egui::Color32::from_rgba_unmultiplied(20, 18, 28, 230))
.corner_radius(egui::CornerRadius::same(3)) .stroke(egui::Stroke::new(
.inner_margin(egui::Margin::symmetric(6, 2)) 1.0,
egui::Color32::from_rgba_unmultiplied(210, 140, 255, 210),
))
.corner_radius(egui::CornerRadius::same(4))
.inner_margin(egui::Margin::symmetric(8, 6))
.show(ui, |ui| { .show(ui, |ui| {
ui.label( ui.horizontal(|ui| {
egui::RichText::new(format!("Brush: {}", mode.label())) ui.label(
.color(egui::Color32::from_rgb(170, 220, 255)) egui::RichText::new(format!("Brush {}", mode.label()))
.small(), .color(egui::Color32::from_rgb(230, 190, 255))
); .strong(),
);
ui.label(
egui::RichText::new(format!("{selected_count} selected"))
.color(egui::Color32::from_rgb(205, 200, 215))
.small(),
);
ui.label(
egui::RichText::new("Element gizmo")
.color(egui::Color32::from_rgb(255, 190, 120))
.small(),
);
});
ui.add_space(4.0);
ui.horizontal_wrapped(|ui| {
match mode {
BrushEditMode::Vertex => key_hint(ui, "LMB", "Select vertex"),
BrushEditMode::Edge => key_hint(ui, "LMB", "Select edge"),
BrushEditMode::Face => key_hint(ui, "LMB", "Select face"),
BrushEditMode::Clip => key_hint(ui, "LMB", "Select face"),
BrushEditMode::Object => {}
}
if !matches!(mode, BrushEditMode::Clip) {
key_hint(ui, "W/E/R", "Move/rotate/scale");
}
if matches!(mode, BrushEditMode::Clip) {
key_hint(ui, "Enter", "Clip");
}
key_hint(ui, "Shift+LMB", "Multi");
key_hint(ui, "Esc", "Object");
});
}); });
}); });
} }

View File

@ -141,8 +141,8 @@ impl Material for ActorIconOverlayMaterial {
) -> Result<(), bevy::render::render_resource::SpecializedMeshPipelineError> { ) -> Result<(), bevy::render::render_resource::SpecializedMeshPipelineError> {
descriptor.primitive.cull_mode = None; descriptor.primitive.cull_mode = None;
if let Some(depth) = &mut descriptor.depth_stencil { if let Some(depth) = &mut descriptor.depth_stencil {
depth.depth_write_enabled = false; depth.depth_write_enabled = Some(false);
depth.depth_compare = CompareFunction::Always; depth.depth_compare = Some(CompareFunction::Always);
} }
if let Some(fragment) = &mut descriptor.fragment { if let Some(fragment) = &mut descriptor.fragment {
if let Some(target) = fragment if let Some(target) = fragment

View File

@ -9,12 +9,14 @@ use shared::{
use crate::camera::EditorCamera; use crate::camera::EditorCamera;
use crate::history::{push_command, set_brush_transform_with_history, EditorCommand}; use crate::history::{push_command, set_brush_transform_with_history, EditorCommand};
use crate::infra::EditorOnly;
use crate::operators::{ActiveOperator, OperatorPhase, OperatorStatus}; use crate::operators::{ActiveOperator, OperatorPhase, OperatorStatus};
use crate::scene_io::SceneIo; use crate::scene_io::SceneIo;
use crate::selection::ViewportClick; use crate::selection::ViewportClick;
use crate::state::scene_tools_active; use crate::state::scene_tools_active;
use crate::ui::{viewport_keyboard_shortcuts_active, UiState}; use crate::ui::{viewport_keyboard_shortcuts_active, UiState};
use crate::viewport::{scene_view_ray, ViewportDisplayMode}; use crate::viewport::{scene_view_ray, ViewportDisplayMode};
use transform_gizmo_bevy::prelude::GizmoTarget;
#[derive(Resource, Debug, Clone, Copy, Default, PartialEq, Eq)] #[derive(Resource, Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum BrushEditMode { pub enum BrushEditMode {
@ -98,27 +100,33 @@ impl Plugin for BrushEditPlugin {
fn build(&self, app: &mut App) { fn build(&self, app: &mut App) {
app.init_resource::<BrushEditMode>() app.init_resource::<BrushEditMode>()
.init_resource::<BrushElementSelection>() .init_resource::<BrushElementSelection>()
.init_resource::<BrushElementDrag>() .init_resource::<BrushElementGizmoState>()
.add_systems( .add_systems(
Update, Update,
( (
brush_edit_hotkeys, brush_edit_hotkeys,
brush_element_pick, brush_element_pick,
brush_element_drag, sync_brush_element_gizmo,
brush_clip_commit, brush_clip_commit,
draw_brush_edit_overlays, draw_brush_edit_overlays,
) )
.chain() .chain()
.run_if(scene_tools_active), .run_if(scene_tools_active),
); )
.add_systems(Last, apply_brush_element_gizmo.run_if(scene_tools_active));
} }
} }
#[derive(Component, Debug, Clone, Copy)]
pub struct BrushElementGizmo;
#[derive(Resource, Default, Debug, Clone)] #[derive(Resource, Default, Debug, Clone)]
struct BrushElementDrag { struct BrushElementGizmoState {
entity: Option<Entity>,
brush: Option<Entity>, brush: Option<Entity>,
old: Option<BrushDesc>, old_brush: Option<BrushDesc>,
last_floor: Option<Vec3>, old_gizmo: Option<Transform>,
last_gizmo: Option<Transform>,
changed: bool, changed: bool,
} }
@ -139,7 +147,9 @@ fn brush_edit_hotkeys(
return Ok(()); return Ok(());
} }
let ctx = contexts.ctx_mut()?; let ctx = contexts.ctx_mut()?;
if !viewport_keyboard_shortcuts_active(&ui_state, ctx, &buttons) || ctx.wants_keyboard_input() { if !viewport_keyboard_shortcuts_active(&ui_state, ctx, &buttons)
|| ctx.egui_wants_keyboard_input()
{
return Ok(()); return Ok(());
} }
if keys.just_pressed(KeyCode::Escape) && mode.is_element_mode() { if keys.just_pressed(KeyCode::Escape) && mode.is_element_mode() {
@ -233,121 +243,272 @@ fn brush_element_pick(
} }
} }
#[allow(clippy::too_many_arguments)] fn sync_brush_element_gizmo(
fn brush_element_drag(
mut commands: Commands, mut commands: Commands,
mode: Res<BrushEditMode>, mode: Res<BrushEditMode>,
selection: Res<BrushElementSelection>, selection: Res<BrushElementSelection>,
mut drag: ResMut<BrushElementDrag>, mut state: ResMut<BrushElementGizmoState>,
mut active_operator: ResMut<ActiveOperator>, brushes: Query<(&BrushDesc, &GlobalTransform)>,
buttons: Res<ButtonInput<MouseButton>>, mut gizmos: Query<&mut Transform, With<BrushElementGizmo>>,
ui_state: Res<UiState>,
cameras: Query<(&Camera, &GlobalTransform), With<EditorCamera>>,
mut brushes: Query<(&mut BrushDesc, &GlobalTransform)>,
) { ) {
if !mode.is_element_mode() || matches!(*mode, BrushEditMode::Clip) { if !mode.is_element_mode() || matches!(*mode, BrushEditMode::Clip) {
clear_drag(&mut drag); clear_brush_element_gizmo(&mut commands, &mut state);
return; return;
} }
let Some(brush_entity) = selection.brush else { let Some(brush_entity) = selection.brush else {
clear_drag(&mut drag); clear_brush_element_gizmo(&mut commands, &mut state);
return; return;
}; };
if selection.elements.is_empty() { if selection.elements.is_empty() {
clear_drag(&mut drag); clear_brush_element_gizmo(&mut commands, &mut state);
return; return;
} }
let Some(pointer_pos) = ui_state.viewport_pointer_pos else { let Ok((brush, brush_transform)) = brushes.get(brush_entity) else {
clear_brush_element_gizmo(&mut commands, &mut state);
return; return;
}; };
let Some(current_floor) = pointer_floor_position(&cameras, pointer_pos, ui_state.viewport_rect) let Some(pivot) = selected_element_world_pivot(brush, brush_transform, &selection.elements)
else { else {
clear_brush_element_gizmo(&mut commands, &mut state);
return; return;
}; };
if buttons.just_pressed(MouseButton::Left) && drag.brush.is_none() { let rotation = brush_transform.to_scale_rotation_translation().1;
if let Ok((brush, _)) = brushes.get(brush_entity) { let desired = Transform::from_translation(pivot).with_rotation(rotation);
drag.brush = Some(brush_entity); let needs_new_entity = state.brush != Some(brush_entity)
drag.old = Some(brush.clone()); || state
drag.last_floor = Some(current_floor); .entity
drag.changed = false; .is_none_or(|entity| gizmos.get(entity).is_err());
} if needs_new_entity {
clear_brush_element_gizmo(&mut commands, &mut state);
let entity = commands
.spawn((
BrushElementGizmo,
EditorOnly,
GizmoTarget::default(),
desired,
GlobalTransform::default(),
Visibility::Hidden,
InheritedVisibility::default(),
))
.id();
state.entity = Some(entity);
state.brush = Some(brush_entity);
state.old_brush = None;
state.old_gizmo = Some(desired);
state.last_gizmo = Some(desired);
state.changed = false;
return; return;
} }
if buttons.pressed(MouseButton::Left) && drag.brush == Some(brush_entity) { let Some(entity) = state.entity else {
let Some(last_floor) = drag.last_floor else { return;
drag.last_floor = Some(current_floor); };
let Ok(mut transform) = gizmos.get_mut(entity) else {
clear_brush_element_gizmo(&mut commands, &mut state);
return;
};
if state.old_brush.is_none() {
let current = *transform;
if !gizmo_transform_is_active(&state, current) {
*transform = desired;
state.old_gizmo = Some(desired);
state.last_gizmo = Some(desired);
}
}
}
#[allow(clippy::too_many_arguments)]
fn apply_brush_element_gizmo(
mut commands: Commands,
mode: Res<BrushEditMode>,
selection: Res<BrushElementSelection>,
mut state: ResMut<BrushElementGizmoState>,
mut active_operator: ResMut<ActiveOperator>,
mut brushes: Query<(&mut BrushDesc, &GlobalTransform)>,
gizmos: Query<(&Transform, &GizmoTarget), With<BrushElementGizmo>>,
) {
if !mode.is_element_mode() || matches!(*mode, BrushEditMode::Clip) {
clear_brush_element_gizmo(&mut commands, &mut state);
return;
}
let (Some(gizmo_entity), Some(brush_entity)) = (state.entity, selection.brush) else {
return;
};
if selection.elements.is_empty() || state.brush != Some(brush_entity) {
clear_brush_element_gizmo(&mut commands, &mut state);
return;
}
let Ok((gizmo_transform, target)) = gizmos.get(gizmo_entity) else {
clear_brush_element_gizmo(&mut commands, &mut state);
return;
};
if target.is_active() {
let Ok((mut brush, brush_transform)) = brushes.get_mut(brush_entity) else {
clear_brush_element_gizmo(&mut commands, &mut state);
return; return;
}; };
let world_delta = current_floor - last_floor; if state.old_brush.is_none() {
if world_delta.length_squared() <= 0.000001 { state.old_brush = Some(brush.clone());
state.old_gizmo = Some(*gizmo_transform);
state.last_gizmo = Some(*gizmo_transform);
state.changed = false;
return; return;
} }
if let Ok((mut brush, transform)) = brushes.get_mut(brush_entity) { let Some(last_gizmo) = state.last_gizmo else {
let local_delta = transform.affine().inverse().transform_vector3(world_delta); state.last_gizmo = Some(*gizmo_transform);
if move_selected_elements(&mut brush, &selection.elements, local_delta) { return;
drag.changed = true; };
drag.last_floor = Some(current_floor); if !transforms_nearly_equal(last_gizmo, *gizmo_transform)
&& transform_selected_elements_by_gizmo(
&mut brush,
&selection.elements,
brush_transform,
last_gizmo,
*gizmo_transform,
)
{
state.changed = true;
state.last_gizmo = Some(*gizmo_transform);
set_brush_edit_status(
&mut active_operator,
OperatorPhase::Preview,
format!(
"Gizmo editing {} brush element(s)",
selection.elements.len()
),
);
}
return;
}
if let (Some(old), Some(_)) = (state.old_brush.take(), state.old_gizmo.take()) {
if state.changed {
let Ok((mut brush, _)) = brushes.get_mut(brush_entity) else {
clear_brush_element_gizmo(&mut commands, &mut state);
return;
};
let new = brush.clone();
let validation = validate_brush(&new);
if validation.is_valid() {
commands.queue(move |world: &mut World| {
push_command(
world,
EditorCommand::SetBrush {
entity: brush_entity,
old: Some(old),
new,
},
);
});
set_brush_edit_status( set_brush_edit_status(
&mut active_operator, &mut active_operator,
OperatorPhase::Preview, OperatorPhase::Committed,
format!("Dragging {} element(s)", selection.elements.len()), "Committed brush element gizmo edit",
);
} else {
*brush = old;
let message = validation
.diagnostics
.iter()
.find(|diagnostic| diagnostic.severity == BrushDiagnosticSeverity::Error)
.map(|diagnostic| diagnostic.message.as_str())
.unwrap_or("Brush edit produced invalid geometry");
set_brush_edit_status(
&mut active_operator,
OperatorPhase::Blocked,
format!("Rejected brush edit: {message}"),
); );
} }
} }
} }
state.last_gizmo = Some(*gizmo_transform);
state.changed = false;
}
if buttons.just_released(MouseButton::Left) { fn clear_brush_element_gizmo(commands: &mut Commands, state: &mut BrushElementGizmoState) {
if let (Some(brush), Some(old)) = (drag.brush, drag.old.take()) { if let Some(entity) = state.entity.take() {
if drag.changed { if let Ok(mut entity_mut) = commands.get_entity(entity) {
if let Ok((mut brush_desc, _)) = brushes.get_mut(brush) { entity_mut.despawn();
let new = brush_desc.clone(); }
let validation = validate_brush(&new); }
if validation.is_valid() { state.brush = None;
commands.queue(move |world: &mut World| { state.old_brush = None;
push_command( state.old_gizmo = None;
world, state.last_gizmo = None;
EditorCommand::SetBrush { state.changed = false;
entity: brush, }
old: Some(old),
new, fn selected_element_world_pivot(
}, brush: &BrushDesc,
); transform: &GlobalTransform,
}); selected: &[BrushElementKey],
set_brush_edit_status( ) -> Option<Vec3> {
&mut active_operator, let anchors = selected_anchor_positions(brush, selected);
OperatorPhase::Committed, if anchors.is_empty() {
"Committed brush element edit", return None;
); }
} else { let affine = transform.affine();
*brush_desc = old; Some(
let message = validation anchors
.diagnostics .iter()
.iter() .map(|anchor| affine.transform_point3(*anchor))
.find(|diagnostic| { .sum::<Vec3>()
diagnostic.severity == BrushDiagnosticSeverity::Error / anchors.len() as f32,
}) )
.map(|diagnostic| diagnostic.message.as_str()) }
.unwrap_or("Brush edit produced invalid geometry");
set_brush_edit_status( fn gizmo_transform_is_active(state: &BrushElementGizmoState, transform: Transform) -> bool {
&mut active_operator, state
OperatorPhase::Blocked, .last_gizmo
format!("Rejected brush edit: {message}"), .is_some_and(|last| !transforms_nearly_equal(last, transform))
); || state.old_brush.is_some()
} }
fn transform_selected_elements_by_gizmo(
brush: &mut BrushDesc,
selected: &[BrushElementKey],
brush_transform: &GlobalTransform,
old_gizmo: Transform,
new_gizmo: Transform,
) -> bool {
let anchors = selected_anchor_positions(brush, selected);
if anchors.is_empty() {
return false;
}
let old_world_from_gizmo = old_gizmo.compute_affine();
let new_world_from_gizmo = new_gizmo.compute_affine();
let world_delta = new_world_from_gizmo * old_world_from_gizmo.inverse();
let brush_from_world = brush_transform.affine().inverse();
let world_from_brush = brush_transform.affine();
let mut changed = false;
for face in &mut brush.faces {
for vertex in &mut face.vertices {
if anchors
.iter()
.any(|anchor| vertex.distance_squared(*anchor) <= 0.0001)
{
let world = world_from_brush.transform_point3(*vertex);
let transformed = world_delta.transform_point3(world);
let local = brush_from_world.transform_point3(transformed);
if vertex.distance_squared(local) > 0.000001 {
*vertex = local;
changed = true;
} }
} }
} }
clear_drag(&mut drag);
} }
if changed {
recompute_face_planes(brush);
}
changed
} }
fn clear_drag(drag: &mut BrushElementDrag) { fn transforms_nearly_equal(a: Transform, b: Transform) -> bool {
drag.brush = None; a.translation.distance_squared(b.translation) <= 0.000001
drag.old = None; && a.rotation.dot(b.rotation).abs() >= 0.99999
drag.last_floor = None; && a.scale.distance_squared(b.scale) <= 0.000001
drag.changed = false;
} }
fn brush_clip_commit( fn brush_clip_commit(
@ -677,50 +838,6 @@ fn pick_element(
} }
} }
fn pointer_floor_position(
cameras: &Query<(&Camera, &GlobalTransform), With<EditorCamera>>,
pointer_pos: egui::Pos2,
scene_rect: egui::Rect,
) -> Option<Vec3> {
let ray = viewport_ray(cameras, pointer_pos, scene_rect)?;
let direction = ray.direction.as_vec3();
if direction.y.abs() < 0.0001 {
return Some(ray.origin);
}
let t = -ray.origin.y / direction.y;
Some(ray.origin + direction * t.max(0.0))
}
fn move_selected_elements(
brush: &mut BrushDesc,
selected: &[BrushElementKey],
delta: Vec3,
) -> bool {
if delta.length_squared() <= 0.000001 {
return false;
}
let anchors = selected_anchor_positions(brush, selected);
if anchors.is_empty() {
return false;
}
let mut changed = false;
for face in &mut brush.faces {
for vertex in &mut face.vertices {
if anchors
.iter()
.any(|anchor| vertex.distance_squared(*anchor) <= 0.0001)
{
*vertex += delta;
changed = true;
}
}
}
if changed {
recompute_face_planes(brush);
}
changed
}
fn selected_anchor_positions(brush: &BrushDesc, selected: &[BrushElementKey]) -> Vec<Vec3> { fn selected_anchor_positions(brush: &BrushDesc, selected: &[BrushElementKey]) -> Vec<Vec3> {
let mut anchors = Vec::new(); let mut anchors = Vec::new();
for element in selected { for element in selected {
@ -986,10 +1103,12 @@ mod tests {
.count(); .count();
let face = brush.faces[0].id.clone(); let face = brush.faces[0].id.clone();
let changed = move_selected_elements( let changed = transform_selected_elements_by_gizmo(
&mut brush, &mut brush,
&[BrushElementKey::Vertex { face, index: 0 }], &[BrushElementKey::Vertex { face, index: 0 }],
Vec3::new(0.5, 0.0, 0.0), &GlobalTransform::default(),
Transform::default(),
Transform::from_translation(Vec3::new(0.5, 0.0, 0.0)),
); );
let after = brush let after = brush
@ -1006,10 +1125,12 @@ mod tests {
fn collapsed_face_move_produces_invalid_brush() { fn collapsed_face_move_produces_invalid_brush() {
let mut brush = BrushDesc::default(); let mut brush = BrushDesc::default();
let face = brush.faces[0].id.clone(); let face = brush.faces[0].id.clone();
assert!(move_selected_elements( assert!(transform_selected_elements_by_gizmo(
&mut brush, &mut brush,
&[BrushElementKey::Face { face }], &[BrushElementKey::Face { face }],
Vec3::new(-1.0, 0.0, 0.0), &GlobalTransform::default(),
Transform::default(),
Transform::from_translation(Vec3::new(-1.0, 0.0, 0.0)),
)); ));
assert!(!validate_brush(&brush).is_valid()); assert!(!validate_brush(&brush).is_valid());
} }

View File

@ -148,7 +148,7 @@ fn brush_tool_input(
} }
let ctx = contexts.ctx_mut()?; let ctx = contexts.ctx_mut()?;
let egui_keyboard_busy = ctx.wants_keyboard_input(); let egui_keyboard_busy = ctx.egui_wants_keyboard_input();
let keyboard_available = let keyboard_available =
viewport_keyboard_shortcuts_active(&ui_state, ctx, &buttons) && !egui_keyboard_busy; viewport_keyboard_shortcuts_active(&ui_state, ctx, &buttons) && !egui_keyboard_busy;
@ -591,13 +591,15 @@ mod tests {
#[test] #[test]
fn draw_brush_status_explains_invalid_shape() { fn draw_brush_status_explains_invalid_shape() {
let mut tool = BrushToolState::default(); let tool = BrushToolState {
tool.vertices = vec![ vertices: vec![
Vec3::new(-1.0, 0.0, -1.0), Vec3::new(-1.0, 0.0, -1.0),
Vec3::new(1.0, 0.0, -1.0), Vec3::new(1.0, 0.0, -1.0),
Vec3::new(-1.0, 0.0, 1.0), Vec3::new(-1.0, 0.0, 1.0),
Vec3::new(1.0, 0.0, 1.0), Vec3::new(1.0, 0.0, 1.0),
]; ],
..Default::default()
};
let status = draw_brush_status(&tool); let status = draw_brush_status(&tool);
assert!(status.contains("polygon edges cross")); assert!(status.contains("polygon edges cross"));

View File

@ -87,6 +87,7 @@ fn spawn_editor_camera(
RenderLayers::default(), RenderLayers::default(),
GizmoCamera, GizmoCamera,
Camera3d::default(), Camera3d::default(),
Msaa::Off,
Camera { Camera {
clear_color: ClearColorConfig::Custom(Color::srgba(0.0, 0.0, 0.0, 1.0)), clear_color: ClearColorConfig::Custom(Color::srgba(0.0, 0.0, 0.0, 1.0)),
..default() ..default()
@ -149,12 +150,12 @@ fn update_editor_camera(
if input_state.mode.is_none() && navigation_pressed { if input_state.mode.is_none() && navigation_pressed {
return Ok(()); return Ok(());
} }
if !in_scene && input_state.mode.is_none() && ctx.wants_pointer_input() { if !in_scene && input_state.mode.is_none() && ctx.egui_wants_pointer_input() {
return Ok(()); return Ok(());
} }
if in_scene if in_scene
&& input_state.mode.is_none() && input_state.mode.is_none()
&& ctx.wants_pointer_input() && ctx.egui_wants_pointer_input()
&& !buttons.pressed(MouseButton::Right) && !buttons.pressed(MouseButton::Right)
{ {
return Ok(()); return Ok(());
@ -162,7 +163,7 @@ fn update_editor_camera(
let dt = time.delta_secs(); let dt = time.delta_secs();
let raw_scroll_delta: f32 = scroll.read().map(|event| event.y).sum(); let raw_scroll_delta: f32 = scroll.read().map(|event| event.y).sum();
let scroll_delta = if in_scene && !ctx.wants_pointer_input() { let scroll_delta = if in_scene && !ctx.egui_wants_pointer_input() {
raw_scroll_delta raw_scroll_delta
} else { } else {
0.0 0.0

View File

@ -154,6 +154,10 @@ pub fn snap_translation(position: Vec3, settings: &ViewportSettings) -> Vec3 {
) )
} }
#[expect(
clippy::too_many_arguments,
reason = "Bevy system parameters represent independent editor input and scene state"
)]
fn focus_selection_hotkey( fn focus_selection_hotkey(
keys: Res<ButtonInput<KeyCode>>, keys: Res<ButtonInput<KeyCode>>,
buttons: Res<ButtonInput<MouseButton>>, buttons: Res<ButtonInput<MouseButton>>,

View File

@ -5,16 +5,17 @@
use std::collections::HashSet; use std::collections::HashSet;
use bevy::anti_alias::taa::TemporalAntiAliasing; use bevy::anti_alias::taa::TemporalAntiAliasing;
use bevy::camera::Hdr;
use bevy::core_pipeline::tonemapping::Tonemapping; use bevy::core_pipeline::tonemapping::Tonemapping;
use bevy::pbr::{Atmosphere, ScatteringMedium, ScreenSpaceAmbientOcclusion}; use bevy::light::atmosphere::ScatteringMedium;
use bevy::pbr::{AtmosphereSettings, ScreenSpaceAmbientOcclusion};
use bevy::post_process::auto_exposure::AutoExposure; use bevy::post_process::auto_exposure::AutoExposure;
use bevy::post_process::bloom::Bloom; use bevy::post_process::bloom::Bloom;
use bevy::prelude::*; use bevy::prelude::*;
use bevy::render::view::Hdr;
use game::rendering::{ use game::rendering::{
clear_viewport_camera_stack, has_local_shadow_lights, resolve_viewport_camera_owner, clear_viewport_camera_stack, has_local_shadow_lights, resolve_viewport_camera_owner,
sync_viewport_camera_stack, SolariRaytracingSceneStats, ViewportCameraOwner, sync_project_atmosphere, sync_viewport_camera_stack, ProjectAtmosphere,
ViewportFxSnapshot, ViewportStackApply, SolariRaytracingSceneStats, ViewportCameraOwner, ViewportFxSnapshot, ViewportStackApply,
}; };
use settings::{ use settings::{
ActiveCameraRenderProfile, EffectiveRenderStack, GiPath, ProjectRenderCamera, ProjectSettings, ActiveCameraRenderProfile, EffectiveRenderStack, GiPath, ProjectRenderCamera, ProjectSettings,
@ -65,7 +66,7 @@ type CameraFxState = (bool, bool, bool, bool, bool, bool, bool, bool);
fn fx_snapshot(state: CameraFxState) -> ViewportFxSnapshot { fn fx_snapshot(state: CameraFxState) -> ViewportFxSnapshot {
ViewportFxSnapshot { ViewportFxSnapshot {
has_stack: state.0, has_stack: state.0,
has_atmosphere: state.1, has_atmosphere_settings: state.1,
has_tonemapping: state.2, has_tonemapping: state.2,
has_bloom: state.3, has_bloom: state.3,
has_ssao: state.4, has_ssao: state.4,
@ -101,9 +102,10 @@ fn sync_project_render_view(
editor_cameras: Query<Entity, With<EditorCamera>>, editor_cameras: Query<Entity, With<EditorCamera>>,
player_cameras: Query<Entity, With<PlayerCamera>>, player_cameras: Query<Entity, With<PlayerCamera>>,
stacked: Query<Entity, With<ProjectRenderCamera>>, stacked: Query<Entity, With<ProjectRenderCamera>>,
atmospheres: Query<Entity, With<ProjectAtmosphere>>,
camera_fx: Query<( camera_fx: Query<(
Has<ProjectRenderCamera>, Has<ProjectRenderCamera>,
Has<Atmosphere>, Has<AtmosphereSettings>,
Has<Tonemapping>, Has<Tonemapping>,
Has<Bloom>, Has<Bloom>,
Has<ScreenSpaceAmbientOcclusion>, Has<ScreenSpaceAmbientOcclusion>,
@ -189,6 +191,12 @@ fn sync_project_render_view(
|| solari_readiness_changed || solari_readiness_changed
|| local_shadow_lights_changed || local_shadow_lights_changed
{ {
sync_project_atmosphere(
&mut commands,
&mut mediums,
profile.atmosphere,
atmospheres.iter(),
);
let force_full_apply = owner_changed || target_changed || solari_readiness_changed; let force_full_apply = owner_changed || target_changed || solari_readiness_changed;
for entity in &active { for entity in &active {
let apply = if force_full_apply { let apply = if force_full_apply {

View File

@ -99,7 +99,7 @@ fn active_camera_tab(world: &mut World, ui: &mut egui::Ui) {
{ {
ui.colored_label( ui.colored_label(
egui::Color32::from_rgb(255, 180, 100), egui::Color32::from_rgb(255, 180, 100),
"Solari deferred: directional lights and emissive meshes affect lighting; point/spot LightDesc components are disabled in Bevy 0.18.", "Solari deferred: directional lights and emissive meshes affect lighting; point/spot LightDesc components are disabled in Bevy 0.19.",
); );
} else if stack.fallback_reason == Some(RenderFallbackReason::RtUnsupported) } else if stack.fallback_reason == Some(RenderFallbackReason::RtUnsupported)
|| (!caps.rt_supported && profile.gi_path == GiPath::Forward) || (!caps.rt_supported && profile.gi_path == GiPath::Forward)
@ -221,17 +221,17 @@ fn runtime_lighting_summary(world: &mut World, ui: &mut egui::Ui) {
let shadowed_directional = world let shadowed_directional = world
.query::<&DirectionalLight>() .query::<&DirectionalLight>()
.iter(world) .iter(world)
.filter(|light| light.shadows_enabled) .filter(|light| light.shadow_maps_enabled)
.count(); .count();
let shadowed_point = world let shadowed_point = world
.query::<&PointLight>() .query::<&PointLight>()
.iter(world) .iter(world)
.filter(|light| light.shadows_enabled) .filter(|light| light.shadow_maps_enabled)
.count(); .count();
let shadowed_spot = world let shadowed_spot = world
.query::<&SpotLight>() .query::<&SpotLight>()
.iter(world) .iter(world)
.filter(|light| light.shadows_enabled) .filter(|light| light.shadow_maps_enabled)
.count(); .count();
ui.label(format!( ui.label(format!(
"Counts: {directional} directional, {point} point, {spot} spot" "Counts: {directional} directional, {point} point, {spot} spot"

View File

@ -14,7 +14,7 @@ use crate::ui::hierarchy_ops::is_entity_locked;
use crate::ui::hierarchy_state::HierarchyPanelState; use crate::ui::hierarchy_state::HierarchyPanelState;
use crate::ui::UiState; use crate::ui::UiState;
use crate::viewport::actor_icons::ActorIconProxy; use crate::viewport::actor_icons::ActorIconProxy;
use crate::viewport::brush_edit::BrushEditMode; use crate::viewport::brush_edit::{BrushEditMode, BrushElementGizmo};
use crate::viewport::brush_tool::BrushToolState; use crate::viewport::brush_tool::BrushToolState;
use crate::viewport::ViewportDisplayMode; use crate::viewport::ViewportDisplayMode;
use crate::visualizers::EditorVisualizerProxy; use crate::visualizers::EditorVisualizerProxy;
@ -238,6 +238,10 @@ fn cycle_overlapping_viewport_pick(
Ok(()) Ok(())
} }
#[expect(
clippy::too_many_arguments,
reason = "viewport picking combines explicit camera, ray-cast, selection, and input state"
)]
fn apply_viewport_pick( fn apply_viewport_pick(
hierarchy: Option<&HierarchyPanelState>, hierarchy: Option<&HierarchyPanelState>,
ui_state: &mut UiState, ui_state: &mut UiState,
@ -369,30 +373,40 @@ fn pick_target_for_entity(entity: Entity, pick_targets: &PickTargetQueries) -> O
None None
} }
#[expect(
clippy::too_many_arguments,
reason = "Bevy system parameters represent independent selection and gizmo state"
)]
fn sync_gizmo_targets( fn sync_gizmo_targets(
mut ui_state: ResMut<UiState>, mut ui_state: ResMut<UiState>,
mut selected: ResMut<SelectedEntity>, mut selected: ResMut<SelectedEntity>,
mut commands: Commands, mut commands: Commands,
targets: Query<Entity, With<GizmoTarget>>, targets: Query<(Entity, Option<&BrushElementGizmo>), With<GizmoTarget>>,
level_objects: Query<(), (With<LevelObject>, Without<EditorOnly>)>, level_objects: Query<(), (With<LevelObject>, Without<EditorOnly>)>,
editor_only: Query<(), With<EditorOnly>>, editor_only: Query<(), With<EditorOnly>>,
transforms: Query<(), With<Transform>>, transforms: Query<(), With<Transform>>,
hierarchy: Option<Res<HierarchyPanelState>>, hierarchy: Option<Res<HierarchyPanelState>>,
display: Res<ViewportDisplayMode>, display: Res<ViewportDisplayMode>,
brush_mode: Res<BrushEditMode>,
) { ) {
ui_state ui_state
.selected_entities .selected_entities
.retain(|entity| transforms.contains(entity) && !editor_only.contains(entity)); .retain(|entity| transforms.contains(entity) && !editor_only.contains(entity));
selected.0 = ui_state.selected_entities.as_slice().first().copied(); selected.0 = ui_state.selected_entities.as_slice().first().copied();
if display.clean_game_view { if display.clean_game_view || brush_mode.is_element_mode() {
for entity in &targets { for (entity, brush_element_gizmo) in &targets {
commands.entity(entity).remove::<GizmoTarget>(); if brush_element_gizmo.is_none() {
commands.entity(entity).remove::<GizmoTarget>();
}
} }
return; return;
} }
for entity in &targets { for (entity, brush_element_gizmo) in &targets {
if brush_element_gizmo.is_some() {
continue;
}
if !ui_state.selected_entities.contains(entity) { if !ui_state.selected_entities.contains(entity) {
commands.entity(entity).remove::<GizmoTarget>(); commands.entity(entity).remove::<GizmoTarget>();
} }

View File

@ -83,8 +83,8 @@ macro_rules! impl_outline_material {
) -> Result<(), bevy::render::render_resource::SpecializedMeshPipelineError> { ) -> Result<(), bevy::render::render_resource::SpecializedMeshPipelineError> {
descriptor.primitive.cull_mode = Some(Face::Front); descriptor.primitive.cull_mode = Some(Face::Front);
if let Some(depth) = &mut descriptor.depth_stencil { if let Some(depth) = &mut descriptor.depth_stencil {
depth.depth_write_enabled = false; depth.depth_write_enabled = Some(false);
depth.depth_compare = $depth; depth.depth_compare = Some($depth);
} }
if let Some(fragment) = &mut descriptor.fragment { if let Some(fragment) = &mut descriptor.fragment {
if let Some(target) = fragment.targets.first_mut().and_then(|t| t.as_mut()) { if let Some(target) = fragment.targets.first_mut().and_then(|t| t.as_mut()) {
@ -270,6 +270,10 @@ fn sync_selection_outline_meshes(
} }
} }
#[expect(
clippy::too_many_arguments,
reason = "selection bounds combine distinct authored geometry and physics sources"
)]
fn selection_bounds( fn selection_bounds(
root: Entity, root: Entity,
children: &Query<&Children>, children: &Query<&Children>,

View File

@ -44,6 +44,10 @@ impl Plugin for EditorViewportModePlugin {
} }
} }
#[expect(
clippy::too_many_arguments,
reason = "Bevy system parameters represent independent viewport render state"
)]
fn apply_viewport_mode_on_change( fn apply_viewport_mode_on_change(
mode: Res<EditorViewportMode>, mode: Res<EditorViewportMode>,
mut last: Local<Option<EditorViewportMode>>, mut last: Local<Option<EditorViewportMode>>,

View File

@ -16,9 +16,10 @@ pub mod rendering {
clear_viewport_camera_stack, effective_gi_path_for_camera, effective_viewport_gi_path, clear_viewport_camera_stack, effective_gi_path_for_camera, effective_viewport_gi_path,
effective_viewport_render_stack, has_local_shadow_lights, hdr_enabled_profile, effective_viewport_render_stack, has_local_shadow_lights, hdr_enabled_profile,
patch_camera_effects, resolve_viewport_camera_owner, strip_project_camera_fx, patch_camera_effects, resolve_viewport_camera_owner, strip_project_camera_fx,
sync_optional_rendering_fx, sync_viewport_camera_stack, world_has_local_shadow_lights, sync_optional_rendering_fx, sync_project_atmosphere, sync_viewport_camera_stack,
FullscreenEffectsPlugin, SolariRaytracingSceneStats, SolariRenderingPlugin, world_has_local_shadow_lights, FullscreenEffectsPlugin, ProjectAtmosphere,
ViewportCameraOwner, ViewportFxSnapshot, ViewportStackApply, HDR_ENV_VAR, SolariRaytracingSceneStats, SolariRenderingPlugin, ViewportCameraOwner, ViewportFxSnapshot,
ViewportStackApply, HDR_ENV_VAR,
}; };
} }
@ -66,7 +67,7 @@ mod hot {
use bevy::prelude::*; use bevy::prelude::*;
use bevy::time::Fixed; use bevy::time::Fixed;
use bevy::window::{CursorOptions, PrimaryWindow}; use bevy::window::{CursorOptions, PrimaryWindow};
use game_hot::SolariRaytracingSceneStats; use game_hot::{ProjectAtmosphere, SolariRaytracingSceneStats};
use protocol::PlayerInputIntent; use protocol::PlayerInputIntent;
use settings::{ use settings::{
ActiveCameraRenderProfile, ProjectRenderCamera, ProjectSettings, RenderingCapabilities, ActiveCameraRenderProfile, ProjectRenderCamera, ProjectSettings, RenderingCapabilities,

View File

@ -17,10 +17,10 @@ pub use rendering::{
patch_camera_effects, refresh_player_camera_fx, resolve_active_camera_render_profile_system, patch_camera_effects, refresh_player_camera_fx, resolve_active_camera_render_profile_system,
resolve_viewport_camera_owner, sample_volumes_at, setup_project_camera_effects, resolve_viewport_camera_owner, sample_volumes_at, setup_project_camera_effects,
snapshot_viewport_fx, strip_project_camera_fx, sync_optional_rendering_fx, snapshot_viewport_fx, strip_project_camera_fx, sync_optional_rendering_fx,
sync_viewport_camera_stack, tag_player_camera, world_has_local_shadow_lights, sync_project_atmosphere, sync_viewport_camera_stack, tag_player_camera,
AutoExposureRenderingPlugin, FullscreenEffectsPlugin, SolariRaytracingSceneStats, world_has_local_shadow_lights, AutoExposureRenderingPlugin, FullscreenEffectsPlugin,
SolariRenderingPlugin, ViewportCameraOwner, ViewportFxSnapshot, ViewportStackApply, ProjectAtmosphere, SolariRaytracingSceneStats, SolariRenderingPlugin, ViewportCameraOwner,
HDR_ENV_VAR, ViewportFxSnapshot, ViewportStackApply, HDR_ENV_VAR,
}; };
pub use sim_systems::{apply_player_intent, move_and_slide, sync_player_physics_position}; pub use sim_systems::{apply_player_intent, move_and_slide, sync_player_physics_position};
pub use world::{ pub use world::{

View File

@ -36,6 +36,7 @@ pub fn spawn_player(mut commands: Commands, existing: Query<(), With<Player>>) {
parent.spawn(( parent.spawn((
PlayerCamera, PlayerCamera,
Camera3d::default(), Camera3d::default(),
Msaa::Off,
Camera { Camera {
clear_color: ClearColorConfig::Custom(Color::srgba(0.0, 0.0, 0.0, 1.0)), clear_color: ClearColorConfig::Custom(Color::srgba(0.0, 0.0, 0.0, 1.0)),
..default() ..default()

View File

@ -1,13 +1,13 @@
//! Assembles the high-fidelity rendering stack on project cameras. //! Assembles the high-fidelity rendering stack on project cameras.
use bevy::anti_alias::taa::TemporalAntiAliasing; use bevy::anti_alias::taa::TemporalAntiAliasing;
use bevy::camera::Exposure; use bevy::camera::{Exposure, Hdr};
use bevy::core_pipeline::tonemapping::Tonemapping; use bevy::core_pipeline::tonemapping::Tonemapping;
use bevy::pbr::{Atmosphere, AtmosphereSettings, ScatteringMedium, ScreenSpaceAmbientOcclusion}; use bevy::light::{atmosphere::ScatteringMedium, Atmosphere};
use bevy::pbr::{AtmosphereSettings, ScreenSpaceAmbientOcclusion};
use bevy::post_process::auto_exposure::AutoExposure; use bevy::post_process::auto_exposure::AutoExposure;
use bevy::post_process::bloom::Bloom; use bevy::post_process::bloom::Bloom;
use bevy::prelude::*; use bevy::prelude::*;
use bevy::render::view::Hdr;
use settings::{ use settings::{
effective_auto_exposure, ActiveCameraRenderProfile, GiPath, ProjectRenderCamera, effective_auto_exposure, ActiveCameraRenderProfile, GiPath, ProjectRenderCamera,
ProjectSettings, RenderingCapabilities, RenderingSettings, ProjectSettings, RenderingCapabilities, RenderingSettings,
@ -20,6 +20,10 @@ use super::fullscreen_effects::{strip_fullscreen_effects, sync_fullscreen_effect
use super::solari::{strip_gi_path, sync_gi_path, SolariRaytracingSceneStats}; use super::solari::{strip_gi_path, sync_gi_path, SolariRaytracingSceneStats};
use super::viewport_camera::effective_viewport_gi_path; use super::viewport_camera::effective_viewport_gi_path;
/// Marker for the single project-owned atmosphere entity used by Bevy 0.19 cameras.
#[derive(Component, Debug, Clone, Copy)]
pub struct ProjectAtmosphere;
/// Environment variable name for HDR override (matches game launch). /// Environment variable name for HDR override (matches game launch).
pub const HDR_ENV_VAR: &str = "BEVY_FPS_HDR"; pub const HDR_ENV_VAR: &str = "BEVY_FPS_HDR";
@ -73,6 +77,10 @@ pub fn hdr_required_for_render_stack(
/// Applies the shared project rendering profile to all [`ProjectRenderCamera`] entities. /// Applies the shared project rendering profile to all [`ProjectRenderCamera`] entities.
#[unsafe(no_mangle)] #[unsafe(no_mangle)]
#[expect(
clippy::too_many_arguments,
reason = "Bevy systems expose independent render resources and light queries"
)]
pub fn setup_project_camera_effects( pub fn setup_project_camera_effects(
mut commands: Commands, mut commands: Commands,
mut mediums: ResMut<Assets<ScatteringMedium>>, mut mediums: ResMut<Assets<ScatteringMedium>>,
@ -81,6 +89,7 @@ pub fn setup_project_camera_effects(
caps: Option<Res<RenderingCapabilities>>, caps: Option<Res<RenderingCapabilities>>,
solari_stats: Option<Res<SolariRaytracingSceneStats>>, solari_stats: Option<Res<SolariRaytracingSceneStats>>,
cameras: Query<Entity, With<ProjectRenderCamera>>, cameras: Query<Entity, With<ProjectRenderCamera>>,
atmospheres: Query<Entity, With<ProjectAtmosphere>>,
points: Query<&PointLight>, points: Query<&PointLight>,
spots: Query<&SpotLight>, spots: Query<&SpotLight>,
) { ) {
@ -93,6 +102,12 @@ pub fn setup_project_camera_effects(
) )
}); });
let has_local_shadow_lights = super::viewport_camera::has_local_shadow_lights(&points, &spots); let has_local_shadow_lights = super::viewport_camera::has_local_shadow_lights(&points, &spots);
sync_project_atmosphere(
&mut commands,
&mut mediums,
active.atmosphere,
atmospheres.iter(),
);
for entity in &cameras { for entity in &cameras {
apply_camera_render_profile( apply_camera_render_profile(
&mut commands, &mut commands,
@ -106,6 +121,29 @@ pub fn setup_project_camera_effects(
} }
} }
/// Keeps the Bevy 0.19 atmosphere model stable: one world entity, camera settings per view.
pub fn sync_project_atmosphere(
commands: &mut Commands,
mediums: &mut Assets<ScatteringMedium>,
enabled: bool,
atmospheres: impl IntoIterator<Item = Entity>,
) {
let existing: Vec<Entity> = atmospheres.into_iter().collect();
if enabled {
if existing.is_empty() {
let medium = mediums.add(ScatteringMedium::default());
commands.spawn((ProjectAtmosphere, Atmosphere::earth(medium)));
}
for entity in existing.iter().skip(1) {
commands.entity(*entity).despawn();
}
} else {
for entity in existing {
commands.entity(entity).despawn();
}
}
}
/// Inserts or removes manual [`Exposure`] vs [`AutoExposure`] for the active profile. /// Inserts or removes manual [`Exposure`] vs [`AutoExposure`] for the active profile.
pub fn sync_exposure( pub fn sync_exposure(
commands: &mut Commands, commands: &mut Commands,
@ -135,7 +173,7 @@ pub fn apply_camera_render_profile(
profile: &ActiveCameraRenderProfile, profile: &ActiveCameraRenderProfile,
caps: &RenderingCapabilities, caps: &RenderingCapabilities,
solari_stats: Option<&SolariRaytracingSceneStats>, solari_stats: Option<&SolariRaytracingSceneStats>,
mediums: &mut Assets<ScatteringMedium>, _mediums: &mut Assets<ScatteringMedium>,
has_local_shadow_lights: bool, has_local_shadow_lights: bool,
) { ) {
commands.entity(entity).insert(Msaa::Off); commands.entity(entity).insert(Msaa::Off);
@ -155,13 +193,15 @@ pub fn apply_camera_render_profile(
}); });
if profile.atmosphere { if profile.atmosphere {
let medium = mediums.add(ScatteringMedium::default());
commands
.entity(entity)
.insert(Atmosphere::earthlike(medium));
commands commands
.entity(entity) .entity(entity)
.remove::<Atmosphere>()
.insert(AtmosphereSettings::default()); .insert(AtmosphereSettings::default());
} else {
commands
.entity(entity)
.remove::<Atmosphere>()
.remove::<AtmosphereSettings>();
} }
if profile.tonemapping_aces { if profile.tonemapping_aces {
commands.entity(entity).insert(Tonemapping::AcesFitted); commands.entity(entity).insert(Tonemapping::AcesFitted);
@ -245,8 +285,8 @@ pub fn sync_optional_rendering_fx(
profile: &ActiveCameraRenderProfile, profile: &ActiveCameraRenderProfile,
caps: &RenderingCapabilities, caps: &RenderingCapabilities,
solari_stats: Option<&SolariRaytracingSceneStats>, solari_stats: Option<&SolariRaytracingSceneStats>,
mediums: &mut Assets<ScatteringMedium>, _mediums: &mut Assets<ScatteringMedium>,
has_atmosphere: bool, has_atmosphere_settings: bool,
has_tonemapping: bool, has_tonemapping: bool,
has_bloom: bool, has_bloom: bool,
has_ssao: bool, has_ssao: bool,
@ -258,19 +298,18 @@ pub fn sync_optional_rendering_fx(
let effective_gi = let effective_gi =
effective_viewport_gi_path(profile.gi_path, caps, solari_stats, has_local_shadow_lights); effective_viewport_gi_path(profile.gi_path, caps, solari_stats, has_local_shadow_lights);
if profile.atmosphere && !has_atmosphere { if profile.atmosphere && !has_atmosphere_settings {
let medium = mediums.add(ScatteringMedium::default());
commands
.entity(entity)
.insert(Atmosphere::earthlike(medium));
commands commands
.entity(entity) .entity(entity)
.remove::<Atmosphere>()
.insert(AtmosphereSettings::default()); .insert(AtmosphereSettings::default());
} else if !profile.atmosphere && has_atmosphere { } else if !profile.atmosphere && has_atmosphere_settings {
commands commands
.entity(entity) .entity(entity)
.remove::<Atmosphere>() .remove::<Atmosphere>()
.remove::<AtmosphereSettings>(); .remove::<AtmosphereSettings>();
} else if profile.atmosphere {
commands.entity(entity).remove::<Atmosphere>();
} }
if profile.tonemapping_aces && !has_tonemapping { if profile.tonemapping_aces && !has_tonemapping {
@ -365,31 +404,6 @@ pub fn patch_camera_effects(
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn solari_forces_hdr_render_target() {
assert!(hdr_required_for_render_stack(
false,
false,
GiPath::SolariDeferred
));
assert!(hdr_required_for_render_stack(true, false, GiPath::Forward));
assert!(!hdr_required_for_render_stack(
false,
false,
GiPath::Forward
));
}
#[test]
fn atmosphere_forces_hdr_render_target() {
assert!(hdr_required_for_render_stack(false, true, GiPath::Forward));
}
}
/// Re-applies player camera FX tags after a hot reload. /// Re-applies player camera FX tags after a hot reload.
pub fn refresh_player_camera_fx(world: &mut World) { pub fn refresh_player_camera_fx(world: &mut World) {
let Some(settings) = world.get_resource::<ProjectSettings>().cloned() else { let Some(settings) = world.get_resource::<ProjectSettings>().cloned() else {
@ -419,6 +433,16 @@ pub fn refresh_player_camera_fx(world: &mut World) {
} }
world.resource_scope(|world, mut mediums: Mut<Assets<ScatteringMedium>>| { world.resource_scope(|world, mut mediums: Mut<Assets<ScatteringMedium>>| {
let atmospheres: Vec<Entity> = world
.query_filtered::<Entity, With<ProjectAtmosphere>>()
.iter(world)
.collect();
sync_project_atmosphere(
&mut world.commands(),
&mut mediums,
profile.atmosphere,
atmospheres,
);
for entity in cameras { for entity in cameras {
apply_camera_render_profile( apply_camera_render_profile(
&mut world.commands(), &mut world.commands(),
@ -432,3 +456,28 @@ pub fn refresh_player_camera_fx(world: &mut World) {
} }
}); });
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn solari_forces_hdr_render_target() {
assert!(hdr_required_for_render_stack(
false,
false,
GiPath::SolariDeferred
));
assert!(hdr_required_for_render_stack(true, false, GiPath::Forward));
assert!(!hdr_required_for_render_stack(
false,
false,
GiPath::Forward
));
}
#[test]
fn atmosphere_forces_hdr_render_target() {
assert!(hdr_required_for_render_stack(false, true, GiPath::Forward));
}
}

View File

@ -2,11 +2,11 @@
use bevy::prelude::*; use bevy::prelude::*;
use bevy::render::extract_component::ExtractComponent; use bevy::render::extract_component::ExtractComponent;
use bevy::render::render_graph::{InternedRenderLabel, RenderLabel, RenderSubGraph};
use bevy::render::render_resource::ShaderType; use bevy::render::render_resource::ShaderType;
use bevy::shader::ShaderRef; use bevy::shader::ShaderRef;
use bevy_core_pipeline::core_3d::graph::{Core3d, Node3d};
use bevy_core_pipeline::fullscreen_material::{FullscreenMaterial, FullscreenMaterialPlugin}; use bevy_core_pipeline::fullscreen_material::{FullscreenMaterial, FullscreenMaterialPlugin};
use bevy_core_pipeline::tonemapping::tonemapping;
use bevy_core_pipeline::Core3dSystems;
use shared::{PostProcessEffectAsset, PostProcessEffectKind}; use shared::{PostProcessEffectAsset, PostProcessEffectKind};
/// Vignette fullscreen pass. /// Vignette fullscreen pass.
@ -21,16 +21,10 @@ impl FullscreenMaterial for VignetteFx {
"post_fx/vignette.wgsl".into() "post_fx/vignette.wgsl".into()
} }
fn node_edges() -> Vec<InternedRenderLabel> { fn schedule_configs(
vec![ system: bevy::ecs::schedule::ScheduleConfigs<bevy::ecs::system::BoxedSystem>,
Node3d::Tonemapping.intern(), ) -> bevy::ecs::schedule::ScheduleConfigs<bevy::ecs::system::BoxedSystem> {
Self::node_label().intern(), system.in_set(Core3dSystems::PostProcess).after(tonemapping)
Node3d::EndMainPassPostProcessing.intern(),
]
}
fn sub_graph() -> Option<bevy::render::render_graph::InternedRenderSubGraph> {
Some(Core3d.intern())
} }
} }
@ -46,16 +40,10 @@ impl FullscreenMaterial for ChromaticAberrationFx {
"post_fx/chromatic_aberration.wgsl".into() "post_fx/chromatic_aberration.wgsl".into()
} }
fn node_edges() -> Vec<InternedRenderLabel> { fn schedule_configs(
vec![ system: bevy::ecs::schedule::ScheduleConfigs<bevy::ecs::system::BoxedSystem>,
Node3d::Tonemapping.intern(), ) -> bevy::ecs::schedule::ScheduleConfigs<bevy::ecs::system::BoxedSystem> {
Self::node_label().intern(), system.in_set(Core3dSystems::PostProcess).after(tonemapping)
Node3d::EndMainPassPostProcessing.intern(),
]
}
fn sub_graph() -> Option<bevy::render::render_graph::InternedRenderSubGraph> {
Some(Core3d.intern())
} }
} }

View File

@ -12,7 +12,7 @@ pub use camera_fx::{
apply_camera_render_profile, apply_project_camera_fx, camera_auto_exposure_active, apply_camera_render_profile, apply_project_camera_fx, camera_auto_exposure_active,
hdr_enabled_profile, patch_camera_effects, refresh_player_camera_fx, hdr_enabled_profile, patch_camera_effects, refresh_player_camera_fx,
setup_project_camera_effects, strip_project_camera_fx, sync_optional_rendering_fx, setup_project_camera_effects, strip_project_camera_fx, sync_optional_rendering_fx,
tag_player_camera, HDR_ENV_VAR, sync_project_atmosphere, tag_player_camera, ProjectAtmosphere, HDR_ENV_VAR,
}; };
pub use fullscreen_effects::FullscreenEffectsPlugin; pub use fullscreen_effects::FullscreenEffectsPlugin;
pub use solari::{effective_gi_path_for_camera, SolariRaytracingSceneStats, SolariRenderingPlugin}; pub use solari::{effective_gi_path_for_camera, SolariRaytracingSceneStats, SolariRenderingPlugin};

View File

@ -204,6 +204,10 @@ fn sync_auxiliary_camera_deferred_prepass(
} }
} }
#[expect(
clippy::too_many_arguments,
reason = "Bevy systems expose independent render assets and scene queries"
)]
fn sync_hydrated_raytracing_meshes( fn sync_hydrated_raytracing_meshes(
caps: Res<RenderingCapabilities>, caps: Res<RenderingCapabilities>,
mut commands: Commands, mut commands: Commands,
@ -510,7 +514,9 @@ mod tests {
let mut state: SystemState<(Query<&ChildOf>, Query<(), With<LevelObject>>)> = let mut state: SystemState<(Query<&ChildOf>, Query<(), With<LevelObject>>)> =
SystemState::new(&mut world); SystemState::new(&mut world);
let (parents, level_roots) = state.get(&world); let (parents, level_roots) = state
.get(&world)
.expect("solari test system params should be valid");
assert!(has_level_object_ancestor(child, &parents, &level_roots)); assert!(has_level_object_ancestor(child, &parents, &level_roots));
assert!(has_level_object_ancestor(root, &parents, &level_roots)); assert!(has_level_object_ancestor(root, &parents, &level_roots));
@ -523,7 +529,9 @@ mod tests {
let mut state: SystemState<(Query<&ChildOf>, Query<(), With<LevelObject>>)> = let mut state: SystemState<(Query<&ChildOf>, Query<(), With<LevelObject>>)> =
SystemState::new(&mut world); SystemState::new(&mut world);
let (parents, level_roots) = state.get(&world); let (parents, level_roots) = state
.get(&world)
.expect("solari test system params should be valid");
assert!(!has_level_object_ancestor(orphan, &parents, &level_roots)); assert!(!has_level_object_ancestor(orphan, &parents, &level_roots));
} }

View File

@ -6,13 +6,13 @@ use super::camera_fx::{
}; };
use super::solari::SolariRaytracingSceneStats; use super::solari::SolariRaytracingSceneStats;
use bevy::anti_alias::taa::TemporalAntiAliasing; use bevy::anti_alias::taa::TemporalAntiAliasing;
use bevy::camera::Exposure; use bevy::camera::{Exposure, Hdr};
use bevy::core_pipeline::tonemapping::Tonemapping; use bevy::core_pipeline::tonemapping::Tonemapping;
use bevy::pbr::{Atmosphere, ScatteringMedium, ScreenSpaceAmbientOcclusion}; use bevy::light::atmosphere::ScatteringMedium;
use bevy::pbr::{AtmosphereSettings, ScreenSpaceAmbientOcclusion};
use bevy::post_process::auto_exposure::AutoExposure; use bevy::post_process::auto_exposure::AutoExposure;
use bevy::post_process::bloom::Bloom; use bevy::post_process::bloom::Bloom;
use bevy::prelude::*; use bevy::prelude::*;
use bevy::render::view::Hdr;
use settings::{ use settings::{
resolve_effective_render_stack, write_effective_render_stack, ActiveCameraRenderProfile, resolve_effective_render_stack, write_effective_render_stack, ActiveCameraRenderProfile,
EffectiveRenderStack, GiPath, ProjectRenderCamera, RenderingCapabilities, EffectiveRenderStack, GiPath, ProjectRenderCamera, RenderingCapabilities,
@ -39,18 +39,18 @@ pub fn resolve_viewport_camera_owner(
/// True when any point or spot light in the world casts shadows. /// True when any point or spot light in the world casts shadows.
pub fn has_local_shadow_lights(points: &Query<&PointLight>, spots: &Query<&SpotLight>) -> bool { pub fn has_local_shadow_lights(points: &Query<&PointLight>, spots: &Query<&SpotLight>) -> bool {
points.iter().any(|l| l.shadows_enabled) || spots.iter().any(|l| l.shadows_enabled) points.iter().any(|l| l.shadow_maps_enabled) || spots.iter().any(|l| l.shadow_maps_enabled)
} }
/// [`has_local_shadow_lights`] for exclusive [`World`] access (hot reload, diagnostics). /// [`has_local_shadow_lights`] for exclusive [`World`] access (hot reload, diagnostics).
pub fn world_has_local_shadow_lights(world: &mut World) -> bool { pub fn world_has_local_shadow_lights(world: &mut World) -> bool {
for pl in world.query::<&PointLight>().iter(world) { for pl in world.query::<&PointLight>().iter(world) {
if pl.shadows_enabled { if pl.shadow_maps_enabled {
return true; return true;
} }
} }
for sl in world.query::<&SpotLight>().iter(world) { for sl in world.query::<&SpotLight>().iter(world) {
if sl.shadows_enabled { if sl.shadow_maps_enabled {
return true; return true;
} }
} }
@ -92,7 +92,7 @@ pub fn clear_viewport_camera_stack(commands: &mut Commands, entity: Entity) {
#[derive(Debug, Clone, Copy, Default)] #[derive(Debug, Clone, Copy, Default)]
pub struct ViewportFxSnapshot { pub struct ViewportFxSnapshot {
pub has_stack: bool, pub has_stack: bool,
pub has_atmosphere: bool, pub has_atmosphere_settings: bool,
pub has_tonemapping: bool, pub has_tonemapping: bool,
pub has_bloom: bool, pub has_bloom: bool,
pub has_ssao: bool, pub has_ssao: bool,
@ -156,7 +156,7 @@ pub fn sync_viewport_camera_stack(
caps, caps,
solari_stats, solari_stats,
mediums, mediums,
snap.has_atmosphere, snap.has_atmosphere_settings,
snap.has_tonemapping, snap.has_tonemapping,
snap.has_bloom, snap.has_bloom,
snap.has_ssao, snap.has_ssao,
@ -203,6 +203,19 @@ pub fn sync_viewport_camera_stack(
} }
/// Builds a [`ViewportFxSnapshot`] from an entity's current components. /// Builds a [`ViewportFxSnapshot`] from an entity's current components.
pub fn snapshot_viewport_fx(entity: Entity, world: &World) -> ViewportFxSnapshot {
ViewportFxSnapshot {
has_stack: world.get::<ProjectRenderCamera>(entity).is_some(),
has_atmosphere_settings: world.get::<AtmosphereSettings>(entity).is_some(),
has_tonemapping: world.get::<Tonemapping>(entity).is_some(),
has_bloom: world.get::<Bloom>(entity).is_some(),
has_ssao: world.get::<ScreenSpaceAmbientOcclusion>(entity).is_some(),
has_taa: world.get::<TemporalAntiAliasing>(entity).is_some(),
has_hdr: world.get::<Hdr>(entity).is_some(),
has_auto_exposure: world.get::<AutoExposure>(entity).is_some(),
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -230,16 +243,3 @@ mod tests {
); );
} }
} }
pub fn snapshot_viewport_fx(entity: Entity, world: &World) -> ViewportFxSnapshot {
ViewportFxSnapshot {
has_stack: world.get::<ProjectRenderCamera>(entity).is_some(),
has_atmosphere: world.get::<Atmosphere>(entity).is_some(),
has_tonemapping: world.get::<Tonemapping>(entity).is_some(),
has_bloom: world.get::<Bloom>(entity).is_some(),
has_ssao: world.get::<ScreenSpaceAmbientOcclusion>(entity).is_some(),
has_taa: world.get::<TemporalAntiAliasing>(entity).is_some(),
has_hdr: world.get::<Hdr>(entity).is_some(),
has_auto_exposure: world.get::<AutoExposure>(entity).is_some(),
}
}

View File

@ -252,6 +252,10 @@ pub fn resolve_active_camera_render_profile(
} }
/// Bevy system: updates [`ActiveCameraRenderProfile`] and [`ActiveVolumeContribution`]. /// Bevy system: updates [`ActiveCameraRenderProfile`] and [`ActiveVolumeContribution`].
#[expect(
clippy::too_many_arguments,
reason = "Bevy systems expose independent render resources, queries, and local state"
)]
pub fn sync_active_camera_render_profile( pub fn sync_active_camera_render_profile(
mut commands: Commands, mut commands: Commands,
settings: Res<ProjectSettings>, settings: Res<ProjectSettings>,

View File

@ -28,7 +28,7 @@ pub fn spawn_sun(
ProjectSun, ProjectSun,
DirectionalLight { DirectionalLight {
illuminance: rendering.sun_illuminance, illuminance: rendering.sun_illuminance,
shadows_enabled: !has_scene_sun_override(&scene_suns), shadow_maps_enabled: !has_scene_sun_override(&scene_suns),
..default() ..default()
}, },
Visibility::default(), Visibility::default(),
@ -44,7 +44,7 @@ pub fn sync_project_sun_visibility(
) { ) {
let scene_override = has_scene_sun_override(&scene_suns); let scene_override = has_scene_sun_override(&scene_suns);
for (mut sun, mut visibility) in &mut project_suns { for (mut sun, mut visibility) in &mut project_suns {
sun.shadows_enabled = !scene_override; sun.shadow_maps_enabled = !scene_override;
*visibility = if scene_override { *visibility = if scene_override {
Visibility::Hidden Visibility::Hidden
} else { } else {
@ -106,7 +106,7 @@ pub fn sync_project_sun_from_settings(
let illuminance = settings.rendering.sun_illuminance; let illuminance = settings.rendering.sun_illuminance;
for (mut sun, mut visibility) in &mut project_suns { for (mut sun, mut visibility) in &mut project_suns {
sun.illuminance = illuminance; sun.illuminance = illuminance;
sun.shadows_enabled = !scene_override; sun.shadow_maps_enabled = !scene_override;
*visibility = if scene_override { *visibility = if scene_override {
Visibility::Hidden Visibility::Hidden
} else { } else {

View File

@ -107,7 +107,8 @@ pub const FORBIDDEN_SAVED_COMPONENT_MARKERS: &[&str] = &[
"bevy_mesh::MeshMaterial3d", "bevy_mesh::MeshMaterial3d",
"avian3d::RigidBody", "avian3d::RigidBody",
"avian3d::Collider", "avian3d::Collider",
"bevy_scene::SceneRoot", "bevy_world_serialization::components::WorldAssetRoot",
"bevy_world_serialization::components::DynamicWorldRoot",
"bevy_render::visibility::ComputedVisibility", "bevy_render::visibility::ComputedVisibility",
]; ];
@ -189,7 +190,7 @@ mod tests {
}, },
), ),
}))"; }))";
let migrated = migrate_scene_text(&v1).unwrap(); let migrated = migrate_scene_text(v1).unwrap();
assert!(migrated.contains("ActorKind")); assert!(migrated.contains("ActorKind"));
assert!(migrated.contains("StaticMesh")); assert!(migrated.contains("StaticMesh"));
} }

View File

@ -173,9 +173,7 @@ fn fix_directional_intensity(block: &str) -> String {
}; };
let after = &block[intensity_idx + "intensity:".len()..]; let after = &block[intensity_idx + "intensity:".len()..];
let trimmed = after.trim_start(); let trimmed = after.trim_start();
let end = trimmed let end = trimmed.find([',', ')']).unwrap_or(trimmed.len());
.find(|c: char| c == ',' || c == ')')
.unwrap_or(trimmed.len());
let value_str = trimmed[..end].trim(); let value_str = trimmed[..end].trim();
let Ok(value) = value_str.parse::<f32>() else { let Ok(value) = value_str.parse::<f32>() else {
return block.to_string(); return block.to_string();

View File

@ -245,9 +245,11 @@ mod tests {
#[test] #[test]
fn effective_auto_exposure_requires_hdr_and_compute() { fn effective_auto_exposure_requires_hdr_and_compute() {
let mut profile = ActiveCameraRenderProfile::default(); let mut profile = ActiveCameraRenderProfile {
profile.exposure_mode = ExposureMode::Auto; exposure_mode: ExposureMode::Auto,
profile.hdr = true; hdr: true,
..Default::default()
};
let caps = RenderingCapabilities { let caps = RenderingCapabilities {
auto_exposure_supported: true, auto_exposure_supported: true,
..Default::default() ..Default::default()

View File

@ -11,9 +11,7 @@ use crate::{
/// One-shot deterministic kind from authoring components (scene migration only). /// One-shot deterministic kind from authoring components (scene migration only).
pub fn infer_actor_kind(entity: EntityRef<'_>) -> Option<ActorKind> { pub fn infer_actor_kind(entity: EntityRef<'_>) -> Option<ActorKind> {
if entity.get::<LevelObject>().is_none() { entity.get::<LevelObject>()?;
return None;
}
if entity.get::<PlayerSpawn>().is_some() { if entity.get::<PlayerSpawn>().is_some() {
return Some(ActorKind::PlayerSpawn); return Some(ActorKind::PlayerSpawn);
} }

View File

@ -125,6 +125,20 @@ pub fn validate_brush(brush: &BrushDesc) -> BrushValidationReport {
"Face UV scale is zero or non-finite.", "Face UV scale is zero or non-finite.",
); );
} }
if !face.uv_offset.is_finite() {
report.push(
BrushDiagnosticSeverity::Warning,
Some(face.id.clone()),
"Face UV offset is non-finite.",
);
}
if !face.uv_rotation.is_finite() {
report.push(
BrushDiagnosticSeverity::Warning,
Some(face.id.clone()),
"Face UV rotation is non-finite.",
);
}
} }
let non_manifold_edges = count_non_manifold_edges(brush); let non_manifold_edges = count_non_manifold_edges(brush);
@ -547,12 +561,26 @@ mod tests {
fn reports_uv_warnings_without_invalidating_brush() { fn reports_uv_warnings_without_invalidating_brush() {
let mut brush = BrushDesc::default(); let mut brush = BrushDesc::default();
brush.faces[0].uv_scale = Vec2::ZERO; brush.faces[0].uv_scale = Vec2::ZERO;
brush.faces[1].uv_offset = Vec2::new(f32::NAN, 0.0);
brush.faces[2].uv_rotation = f32::INFINITY;
let report = validate_brush(&brush); let report = validate_brush(&brush);
assert!(report.is_valid()); assert!(report.is_valid());
assert_eq!(
report
.diagnostics
.iter()
.filter(|diagnostic| diagnostic.severity == BrushDiagnosticSeverity::Warning)
.count(),
3
);
assert!(report assert!(report
.diagnostics .diagnostics
.iter() .iter()
.any(|diagnostic| diagnostic.severity == BrushDiagnosticSeverity::Warning)); .any(|diagnostic| diagnostic.message.contains("UV offset")));
assert!(report
.diagnostics
.iter()
.any(|diagnostic| diagnostic.message.contains("UV rotation")));
} }
#[test] #[test]

View File

@ -765,7 +765,7 @@ impl Default for MaterialDesc {
} }
/// Reflectable reference to an imported 3D model scene (glTF/GLB or FBX). /// Reflectable reference to an imported 3D model scene (glTF/GLB or FBX).
/// Hydration turns this into a `SceneRoot` on the entity. /// Hydration turns this into a `WorldAssetRoot` on the entity.
#[derive(Component, Reflect, Default, Debug, Clone, Serialize, Deserialize)] #[derive(Component, Reflect, Default, Debug, Clone, Serialize, Deserialize)]
#[reflect(Component, Default, Debug, Serialize, Deserialize)] #[reflect(Component, Default, Debug, Serialize, Deserialize)]
pub struct ModelRef { pub struct ModelRef {
@ -790,7 +790,7 @@ impl ModelRef {
} }
/// Reflectable reference to a saved `.scn.ron` dynamic scene under `assets/`. /// Reflectable reference to a saved `.scn.ron` dynamic scene under `assets/`.
/// Hydration turns this into a `DynamicSceneRoot`. /// Hydration turns this into a `DynamicWorldRoot`.
#[derive(Component, Reflect, Default, Debug, Clone, Serialize, Deserialize)] #[derive(Component, Reflect, Default, Debug, Clone, Serialize, Deserialize)]
#[reflect(Component, Default, Debug, Serialize, Deserialize)] #[reflect(Component, Default, Debug, Serialize, Deserialize)]
pub struct PrefabRef { pub struct PrefabRef {
@ -1256,9 +1256,12 @@ mod tests {
#[test] #[test]
fn point_spot_lumen_max_covers_bevy_cinema_reference() { fn point_spot_lumen_max_covers_bevy_cinema_reference() {
assert!( const {
super::AUTHORING_POINT_SPOT_LUMENS_MAX >= light_consts::lumens::VERY_LARGE_CINEMA_LIGHT assert!(
); super::AUTHORING_POINT_SPOT_LUMENS_MAX
>= light_consts::lumens::VERY_LARGE_CINEMA_LIGHT
);
}
} }
#[test] #[test]

View File

@ -67,6 +67,10 @@ pub fn hydrate_brushes(
} }
} }
#[expect(
clippy::too_many_arguments,
reason = "hydration keeps Bevy asset stores and authored brush inputs explicit"
)]
pub fn spawn_brush_mesh( pub fn spawn_brush_mesh(
commands: &mut Commands, commands: &mut Commands,
asset_server: &AssetServer, asset_server: &AssetServer,
@ -243,7 +247,12 @@ fn append_face(
for vertex in &face.vertices { for vertex in &face.vertices {
positions.push([vertex.x, vertex.y, vertex.z]); positions.push([vertex.x, vertex.y, vertex.z]);
normals.push([normal.x, normal.y, normal.z]); normals.push([normal.x, normal.y, normal.z]);
let uv = Vec2::new(vertex.dot(u_axis), vertex.dot(v_axis)) * face.uv_scale + face.uv_offset; let uv = transform_face_uv(
Vec2::new(vertex.dot(u_axis), vertex.dot(v_axis)),
face.uv_scale,
face.uv_rotation,
face.uv_offset,
);
uvs.push([uv.x, uv.y]); uvs.push([uv.x, uv.y]);
} }
@ -262,6 +271,29 @@ fn append_face(
Some(()) Some(())
} }
fn transform_face_uv(base: Vec2, scale: Vec2, rotation_degrees: f32, offset: Vec2) -> Vec2 {
let scale = if scale.is_finite() { scale } else { Vec2::ONE };
let rotation_degrees = if rotation_degrees.is_finite() {
rotation_degrees
} else {
0.0
};
let offset = if offset.is_finite() {
offset
} else {
Vec2::ZERO
};
let scaled = base * scale;
if rotation_degrees == 0.0 {
return scaled + offset;
}
let (sin, cos) = rotation_degrees.to_radians().sin_cos();
Vec2::new(
scaled.x * cos - scaled.y * sin,
scaled.x * sin + scaled.y * cos,
) + offset
}
fn face_axes(normal: Vec3) -> (Vec3, Vec3) { fn face_axes(normal: Vec3) -> (Vec3, Vec3) {
let tangent_seed = if normal.y.abs() > 0.9 { let tangent_seed = if normal.y.abs() > 0.9 {
Vec3::X Vec3::X
@ -319,6 +351,27 @@ mod tests {
})); }));
} }
#[test]
fn brush_face_uv_transform_applies_scale_rotation_and_offset() {
let uv = transform_face_uv(
Vec2::new(2.0, 1.0),
Vec2::new(2.0, 3.0),
90.0,
Vec2::new(0.25, -0.5),
);
assert!((uv.x + 2.75).abs() < 0.0001);
assert!((uv.y - 3.5).abs() < 0.0001);
let sanitized = transform_face_uv(
Vec2::new(2.0, 1.0),
Vec2::new(f32::NAN, 3.0),
f32::INFINITY,
Vec2::new(0.25, f32::NAN),
);
assert_eq!(sanitized, Vec2::new(2.0, 1.0));
}
#[test] #[test]
fn invalid_brush_returns_none() { fn invalid_brush_returns_none() {
let mut brush = BrushDesc::default(); let mut brush = BrushDesc::default();

View File

@ -77,6 +77,10 @@ pub(crate) fn normalized_directional_lux(intensity: f32) -> f32 {
/// Re-applies runtime lights when authoring [`LightDesc`] exists but hydration was stripped. /// Re-applies runtime lights when authoring [`LightDesc`] exists but hydration was stripped.
#[allow(clippy::type_complexity)] #[allow(clippy::type_complexity)]
#[expect(
clippy::too_many_arguments,
reason = "Bevy systems expose independent resource and query parameters"
)]
pub fn reconcile_missing_runtime_lights( pub fn reconcile_missing_runtime_lights(
mut commands: Commands, mut commands: Commands,
settings: Res<settings::ProjectSettings>, settings: Res<settings::ProjectSettings>,
@ -140,7 +144,7 @@ pub(crate) fn insert_light_from_desc(
color, color,
intensity: light.intensity, intensity: light.intensity,
range: light.range, range: light.range,
shadows_enabled: light.shadows, shadow_maps_enabled: light.shadows,
..default() ..default()
}, },
Visibility::Visible, Visibility::Visible,
@ -152,7 +156,7 @@ pub(crate) fn insert_light_from_desc(
color, color,
intensity: light.intensity, intensity: light.intensity,
range: light.range, range: light.range,
shadows_enabled: light.shadows, shadow_maps_enabled: light.shadows,
outer_angle: light.outer_angle_deg.to_radians(), outer_angle: light.outer_angle_deg.to_radians(),
inner_angle: light.inner_angle_deg.to_radians(), inner_angle: light.inner_angle_deg.to_radians(),
..default() ..default()
@ -165,7 +169,7 @@ pub(crate) fn insert_light_from_desc(
DirectionalLight { DirectionalLight {
color, color,
illuminance: normalized_directional_lux(light.intensity), illuminance: normalized_directional_lux(light.intensity),
shadows_enabled: light.shadows, shadow_maps_enabled: light.shadows,
..default() ..default()
}, },
Visibility::Visible, Visibility::Visible,
@ -192,7 +196,7 @@ mod tests {
let rendering = RenderingSettings::default(); let rendering = RenderingSettings::default();
let spot = LightDesc::for_kind(AuthoringLightKind::Spot); let spot = LightDesc::for_kind(AuthoringLightKind::Spot);
{ {
let mut commands = Commands::new(&mut queue, &mut world); let mut commands = Commands::new(&mut queue, &world);
let mut entity_commands = commands.entity(entity); let mut entity_commands = commands.entity(entity);
insert_light_from_desc(&mut entity_commands, &spot, &rendering); insert_light_from_desc(&mut entity_commands, &spot, &rendering);
} }
@ -202,7 +206,7 @@ mod tests {
let point = LightDesc::for_kind(AuthoringLightKind::Point); let point = LightDesc::for_kind(AuthoringLightKind::Point);
queue = CommandQueue::default(); queue = CommandQueue::default();
{ {
let mut commands = Commands::new(&mut queue, &mut world); let mut commands = Commands::new(&mut queue, &world);
let mut entity_commands = commands.entity(entity); let mut entity_commands = commands.entity(entity);
strip_light_components(&mut entity_commands); strip_light_components(&mut entity_commands);
insert_light_from_desc(&mut entity_commands, &point, &rendering); insert_light_from_desc(&mut entity_commands, &point, &rendering);
@ -221,7 +225,7 @@ mod tests {
let mut light = LightDesc::for_kind(AuthoringLightKind::Directional); let mut light = LightDesc::for_kind(AuthoringLightKind::Directional);
light.shadows = true; light.shadows = true;
{ {
let mut commands = Commands::new(&mut queue, &mut world); let mut commands = Commands::new(&mut queue, &world);
let mut entity_commands = commands.entity(entity); let mut entity_commands = commands.entity(entity);
insert_light_from_desc(&mut entity_commands, &light, &rendering); insert_light_from_desc(&mut entity_commands, &light, &rendering);
} }
@ -239,10 +243,12 @@ mod tests {
#[test] #[test]
fn scene_directional_cascades_match_project_shadow_settings() { fn scene_directional_cascades_match_project_shadow_settings() {
let mut rendering = RenderingSettings::default(); let rendering = RenderingSettings {
rendering.shadow_cascades = 3; shadow_cascades: 3,
rendering.shadow_first_cascade = 12.0; shadow_first_cascade: 12.0,
rendering.shadow_max_distance = 400.0; shadow_max_distance: 400.0,
..Default::default()
};
let config = cascade_config_from_rendering(&rendering); let config = cascade_config_from_rendering(&rendering);
let expected = cascade_config_from_rendering(&rendering); let expected = cascade_config_from_rendering(&rendering);
assert_eq!(config.bounds, expected.bounds); assert_eq!(config.bounds, expected.bounds);
@ -257,7 +263,7 @@ mod tests {
let spot_entity = world.spawn(LevelObject).id(); let spot_entity = world.spawn(LevelObject).id();
let rendering = RenderingSettings::default(); let rendering = RenderingSettings::default();
{ {
let mut commands = Commands::new(&mut queue, &mut world); let mut commands = Commands::new(&mut queue, &world);
insert_light_from_desc( insert_light_from_desc(
&mut commands.entity(point_entity), &mut commands.entity(point_entity),
&LightDesc::for_kind(AuthoringLightKind::Point), &LightDesc::for_kind(AuthoringLightKind::Point),

View File

@ -79,9 +79,11 @@ mod tests {
let mut app = App::new(); let mut app = App::new();
app.add_plugins((MinimalPlugins, AssetPlugin::default())); app.add_plugins((MinimalPlugins, AssetPlugin::default()));
let asset_server = app.world().resource::<AssetServer>(); let asset_server = app.world().resource::<AssetServer>();
let mut desc = MaterialDesc::default(); let desc = MaterialDesc {
desc.emissive_color = ColorDesc::srgb(0.5, 0.25, 1.0); emissive_color: ColorDesc::srgb(0.5, 0.25, 1.0),
desc.emissive_intensity = 1000.0; emissive_intensity: 1000.0,
..Default::default()
};
let material = material_from_desc(asset_server, &desc); let material = material_from_desc(asset_server, &desc);

View File

@ -29,7 +29,10 @@ use static_meshes::{
hydrate_static_mesh_renderers, spawn_static_mesh_parts, HydratedStaticMeshPart, hydrate_static_mesh_renderers, spawn_static_mesh_parts, HydratedStaticMeshPart,
StaticMeshArtifactCache, StaticMeshArtifactCache,
}; };
use visibility::{init_editor_visibility_on_spawn, sync_editor_visibility}; use visibility::{
ensure_level_object_visibility_hierarchy, init_editor_visibility_on_spawn,
sync_editor_visibility, visibility_from_editor,
};
use crate::{ use crate::{
inspector_component_active, BrushDesc, ColliderDesc, EditorVisibility, InspectorOrder, inspector_component_active, BrushDesc, ColliderDesc, EditorVisibility, InspectorOrder,
@ -61,12 +64,21 @@ impl Plugin for HydrationPlugin {
) )
.add_systems( .add_systems(
Update, Update,
(sync_editor_visibility, init_editor_visibility_on_spawn).chain(), (
ensure_level_object_visibility_hierarchy,
sync_editor_visibility,
init_editor_visibility_on_spawn,
)
.chain(),
); );
} }
} }
/// Runs the full hydration chain immediately (e.g. after scene load in the editor). /// Runs the full hydration chain immediately (e.g. after scene load in the editor).
#[expect(
clippy::type_complexity,
reason = "exclusive hydration snapshots several authored component combinations"
)]
pub fn flush_level_object_hydration(world: &mut World) { pub fn flush_level_object_hydration(world: &mut World) {
if !world.contains_resource::<StaticMeshArtifactCache>() { if !world.contains_resource::<StaticMeshArtifactCache>() {
world.insert_resource(StaticMeshArtifactCache::default()); world.insert_resource(StaticMeshArtifactCache::default());
@ -227,8 +239,9 @@ pub fn flush_level_object_hydration(world: &mut World) {
)> = SystemState::new(world); )> = SystemState::new(world);
{ {
let (mut commands, asset_server, mut meshes, mut materials, mut artifact_cache) = let (mut commands, asset_server, mut meshes, mut materials, mut artifact_cache) = state
state.get_mut(world); .get_mut(world)
.expect("hydrate_level_objects system params should be valid");
for (entity, primitive) in primitives { for (entity, primitive) in primitives {
let mesh = meshes.add(primitive_mesh(&primitive)); let mesh = meshes.add(primitive_mesh(&primitive));
@ -285,12 +298,21 @@ pub fn flush_level_object_hydration(world: &mut World) {
state.apply(world); state.apply(world);
for (entity, editor) in visibility_targets { for (entity, editor) in visibility_targets {
let visibility = visibility_from_editor(editor);
if let Some(mut vis) = world.get_mut::<Visibility>(entity) { if let Some(mut vis) = world.get_mut::<Visibility>(entity) {
*vis = if editor.visible { *vis = visibility;
Visibility::Visible } else if let Ok(mut entity_mut) = world.get_entity_mut(entity) {
} else { entity_mut.insert(visibility);
Visibility::Hidden }
}; if world.get::<InheritedVisibility>(entity).is_none() {
if let Ok(mut entity_mut) = world.get_entity_mut(entity) {
entity_mut.insert(InheritedVisibility::default());
}
}
if world.get::<ViewVisibility>(entity).is_none() {
if let Ok(mut entity_mut) = world.get_entity_mut(entity) {
entity_mut.insert(ViewVisibility::default());
}
} }
} }
} }
@ -300,8 +322,8 @@ mod tests {
use super::flush_level_object_hydration; use super::flush_level_object_hydration;
use super::strip::strip_hydrated_entity; use super::strip::strip_hydrated_entity;
use crate::{ use crate::{
AuthoringLightKind, ColorDesc, InspectorOrder, LevelObject, LightDesc, MaterialDesc, AuthoringLightKind, ColorDesc, EditorVisibility, InspectorOrder, LevelObject, LightDesc,
Primitive, COMPONENT_LIGHT_DESC, MaterialDesc, Primitive, COMPONENT_LIGHT_DESC,
}; };
use avian3d::prelude::*; use avian3d::prelude::*;
use bevy::prelude::*; use bevy::prelude::*;
@ -324,6 +346,29 @@ mod tests {
assert!(app.world().get::<PointLight>(entity).is_some()); assert!(app.world().get::<PointLight>(entity).is_some());
} }
#[test]
fn flush_hydration_initializes_level_object_visibility_hierarchy() {
let mut app = App::new();
app.add_plugins(MinimalPlugins);
app.add_plugins(AssetPlugin::default());
app.add_plugins(bevy::pbr::MaterialPlugin::<StandardMaterial>::default());
app.init_asset::<Mesh>();
let entity = app
.world_mut()
.spawn((LevelObject, EditorVisibility { visible: false }))
.id();
flush_level_object_hydration(app.world_mut());
assert_eq!(
app.world().get::<Visibility>(entity),
Some(&Visibility::Hidden)
);
assert!(app.world().get::<InheritedVisibility>(entity).is_some());
assert!(app.world().get::<ViewVisibility>(entity).is_some());
}
#[test] #[test]
fn flush_hydration_skips_point_light_when_solari_is_active() { fn flush_hydration_skips_point_light_when_solari_is_active() {
let mut app = App::new(); let mut app = App::new();

View File

@ -1,8 +1,8 @@
//! Imported model scenes from [`ModelRef`] authoring data. //! Imported model scenes from [`ModelRef`] authoring data.
use bevy::gltf::GltfAssetLabel; use bevy::gltf::GltfAssetLabel;
use bevy::prelude::WorldAssetRoot;
use bevy::prelude::*; use bevy::prelude::*;
use bevy::scene::SceneRoot;
use crate::{asset_server_path, LevelObject, ModelRef}; use crate::{asset_server_path, LevelObject, ModelRef};
@ -23,7 +23,7 @@ pub fn hydrate_models(
despawn_generated_children(&mut commands, entity, child_list); despawn_generated_children(&mut commands, entity, child_list);
} }
commands.entity(entity).remove::<SceneRoot>(); commands.entity(entity).remove::<WorldAssetRoot>();
let path = asset_server_path(&model.path); let path = asset_server_path(&model.path);
let handle = if path.ends_with(".fbx") { let handle = if path.ends_with(".fbx") {
@ -31,6 +31,6 @@ pub fn hydrate_models(
} else { } else {
asset_server.load(GltfAssetLabel::Scene(model.scene_index).from_asset(path)) asset_server.load(GltfAssetLabel::Scene(model.scene_index).from_asset(path))
}; };
commands.entity(entity).insert(SceneRoot(handle)); commands.entity(entity).insert(WorldAssetRoot(handle));
} }
} }

View File

@ -1,7 +1,7 @@
//! Prefab scene roots from [`PrefabRef`] / [`PrefabInstance`] authoring data. //! Prefab scene roots from [`PrefabRef`] / [`PrefabInstance`] authoring data.
use bevy::prelude::DynamicWorldRoot;
use bevy::prelude::*; use bevy::prelude::*;
use bevy::scene::DynamicSceneRoot;
use crate::{LevelObject, PrefabInstance, PrefabRef}; use crate::{LevelObject, PrefabInstance, PrefabRef};
@ -28,7 +28,7 @@ pub fn hydrate_prefabs(
>, >,
) { ) {
for (entity, prefab) in &prefabs { for (entity, prefab) in &prefabs {
commands.entity(entity).insert(DynamicSceneRoot( commands.entity(entity).insert(DynamicWorldRoot(
asset_server.load(asset_server_path(&prefab.path)), asset_server.load(asset_server_path(&prefab.path)),
)); ));
} }
@ -37,7 +37,7 @@ pub fn hydrate_prefabs(
let path = asset_server_path(&instance.source_path); let path = asset_server_path(&instance.source_path);
commands.entity(entity).insert(( commands.entity(entity).insert((
PrefabRef::new(instance.source_path.clone()), PrefabRef::new(instance.source_path.clone()),
DynamicSceneRoot(asset_server.load(path)), DynamicWorldRoot(asset_server.load(path)),
)); ));
} }
} }

View File

@ -202,6 +202,10 @@ pub fn hydrate_static_mesh_renderers(
} }
} }
#[expect(
clippy::too_many_arguments,
reason = "hydration keeps Bevy asset stores and authored mesh inputs explicit"
)]
pub fn spawn_static_mesh_parts( pub fn spawn_static_mesh_parts(
commands: &mut Commands, commands: &mut Commands,
asset_server: &AssetServer, asset_server: &AssetServer,

View File

@ -2,7 +2,6 @@
use avian3d::prelude::*; use avian3d::prelude::*;
use bevy::prelude::*; use bevy::prelude::*;
use bevy::scene::{DynamicSceneRoot, SceneRoot};
/// Strips hydrated components via [`EntityWorldMut`] (e.g. before scene save). /// Strips hydrated components via [`EntityWorldMut`] (e.g. before scene save).
pub fn strip_hydrated_entity(entity: &mut EntityWorldMut<'_>) { pub fn strip_hydrated_entity(entity: &mut EntityWorldMut<'_>) {
@ -12,8 +11,8 @@ pub fn strip_hydrated_entity(entity: &mut EntityWorldMut<'_>) {
.remove::<PointLight>() .remove::<PointLight>()
.remove::<SpotLight>() .remove::<SpotLight>()
.remove::<DirectionalLight>() .remove::<DirectionalLight>()
.remove::<SceneRoot>() .remove::<WorldAssetRoot>()
.remove::<DynamicSceneRoot>() .remove::<DynamicWorldRoot>()
.remove::<RigidBody>() .remove::<RigidBody>()
.remove::<Collider>(); .remove::<Collider>();
} }
@ -26,13 +25,13 @@ pub fn strip_hydrated(entity_commands: &mut EntityCommands<'_>) {
.remove::<PointLight>() .remove::<PointLight>()
.remove::<SpotLight>() .remove::<SpotLight>()
.remove::<DirectionalLight>() .remove::<DirectionalLight>()
.remove::<SceneRoot>() .remove::<WorldAssetRoot>()
.remove::<DynamicSceneRoot>() .remove::<DynamicWorldRoot>()
.remove::<RigidBody>() .remove::<RigidBody>()
.remove::<Collider>(); .remove::<Collider>();
} }
/// Despawns all child entities (e.g. prior `SceneRoot` instance hierarchy). /// Despawns all child entities (e.g. prior `WorldAssetRoot` instance hierarchy).
pub fn despawn_generated_children(commands: &mut Commands, entity: Entity, children: &Children) { pub fn despawn_generated_children(commands: &mut Commands, entity: Entity, children: &Children) {
for child in children.iter() { for child in children.iter() {
commands.entity(child).despawn(); commands.entity(child).despawn();

View File

@ -3,6 +3,41 @@
use crate::{EditorVisibility, LevelObject}; use crate::{EditorVisibility, LevelObject};
use bevy::prelude::*; use bevy::prelude::*;
#[expect(
clippy::type_complexity,
reason = "the query mirrors Bevy's three visibility hierarchy components"
)]
pub fn ensure_level_object_visibility_hierarchy(
mut commands: Commands,
query: Query<
(
Entity,
&EditorVisibility,
Option<&Visibility>,
Option<&InheritedVisibility>,
Option<&ViewVisibility>,
),
With<LevelObject>,
>,
) {
for (entity, editor, visibility, inherited_visibility, view_visibility) in &query {
let mut entity_commands = commands.entity(entity);
if visibility.is_none() {
entity_commands.insert(visibility_from_editor(*editor));
}
if inherited_visibility.is_none() {
entity_commands.insert(InheritedVisibility::default());
}
if view_visibility.is_none() {
entity_commands.insert(ViewVisibility::default());
}
}
}
#[expect(
clippy::type_complexity,
reason = "the query expresses Bevy change detection at the system boundary"
)]
pub fn sync_editor_visibility( pub fn sync_editor_visibility(
mut query: Query< mut query: Query<
(&EditorVisibility, &mut Visibility), (&EditorVisibility, &mut Visibility),
@ -13,14 +48,14 @@ pub fn sync_editor_visibility(
>, >,
) { ) {
for (editor, mut visibility) in query.iter_mut() { for (editor, mut visibility) in query.iter_mut() {
*visibility = if editor.visible { *visibility = visibility_from_editor(*editor);
Visibility::Visible
} else {
Visibility::Hidden
};
} }
} }
#[expect(
clippy::type_complexity,
reason = "the query expresses Bevy spawn detection at the system boundary"
)]
pub fn init_editor_visibility_on_spawn( pub fn init_editor_visibility_on_spawn(
mut query: Query< mut query: Query<
(&EditorVisibility, &mut Visibility), (&EditorVisibility, &mut Visibility),
@ -32,10 +67,39 @@ pub fn init_editor_visibility_on_spawn(
>, >,
) { ) {
for (editor, mut visibility) in query.iter_mut() { for (editor, mut visibility) in query.iter_mut() {
*visibility = if editor.visible { *visibility = visibility_from_editor(*editor);
Visibility::Visible }
} else { }
Visibility::Hidden
}; pub fn visibility_from_editor(editor: EditorVisibility) -> Visibility {
if editor.visible {
Visibility::Visible
} else {
Visibility::Hidden
}
}
#[cfg(test)]
mod tests {
use super::ensure_level_object_visibility_hierarchy;
use crate::{EditorVisibility, LevelObject};
use bevy::prelude::*;
#[test]
fn level_object_visibility_hierarchy_is_inserted_for_generated_children() {
let mut app = App::new();
app.add_systems(Update, ensure_level_object_visibility_hierarchy);
let entity = app
.world_mut()
.spawn((LevelObject, EditorVisibility { visible: false }))
.id();
app.update();
let world = app.world();
assert_eq!(world.get::<Visibility>(entity), Some(&Visibility::Hidden));
assert!(world.get::<InheritedVisibility>(entity).is_some());
assert!(world.get::<ViewVisibility>(entity).is_some());
} }
} }

View File

@ -33,9 +33,10 @@ Immutable-style log of significant decisions. Add a new numbered ADR when changi
| [0016](adr/0016-unified-rendering-contract.md) | Requested/effective render stack, Solari eligibility, emissive materials | | [0016](adr/0016-unified-rendering-contract.md) | Requested/effective render stack, Solari eligibility, emissive materials |
| [0017](adr/0017-normalized-static-mesh-assets.md) | Normalized static mesh assets and renderer placement | | [0017](adr/0017-normalized-static-mesh-assets.md) | Normalized static mesh assets and renderer placement |
| [0018](adr/0018-componentized-actor-inspector-and-materials.md) | Componentized actor inspector, imported asset refs, collider split, material overrides | | [0018](adr/0018-componentized-actor-inspector-and-materials.md) | Componentized actor inspector, imported asset refs, collider split, material overrides |
| [0019](adr/0019-local-bevy-render-timeout-patch.md) | Local `bevy_render` patch for transient Linux swapchain timeouts | | [0019](adr/0019-local-bevy-render-timeout-patch.md) | Superseded local `bevy_render` patch for transient Linux swapchain timeouts |
| [0020](adr/0020-jackdaw-inspired-editor-roadmap.md) | Jackdaw-inspired roadmap source policy and attribution requirements | | [0020](adr/0020-jackdaw-inspired-editor-roadmap.md) | Jackdaw-inspired roadmap source policy and attribution requirements |
| [0021](adr/0021-brush-authoring-schema.md) | Brush authoring schema and hydration contract | | [0021](adr/0021-brush-authoring-schema.md) | Brush authoring schema and hydration contract |
| [0022](adr/0022-bevy-0-19-upgrade.md) | Bevy 0.19 / Avian 0.7 upgrade and compatibility patches |
## Editor framework ## Editor framework

View File

@ -6,7 +6,7 @@ Accepted
## Context ## Context
Bevy moves quickly, and the project depends on ecosystem crates that track Bevy on their own schedules. The current workspace targets Bevy 0.18 with Avian 0.6, bevy_egui, bevy-inspector-egui, and transform-gizmo-bevy. Bevy moves quickly, and the project depends on ecosystem crates that track Bevy on their own schedules. The current workspace targets Bevy 0.19 with Avian 0.7, bevy_egui, bevy-inspector-egui, and local compatibility patches for transform-gizmo-bevy and bevy_ufbx.
Unplanned upgrades can break editor work, rendering, scene serialization, physics, and later networking. Delayed upgrades can also make migrations larger and riskier. Unplanned upgrades can break editor work, rendering, scene serialization, physics, and later networking. Delayed upgrades can also make migrations larger and riskier.
@ -18,9 +18,11 @@ Before merging a Bevy upgrade:
- Confirm matching versions exist for editor, physics, and networking-related crates used by the active milestone. - Confirm matching versions exist for editor, physics, and networking-related crates used by the active milestone.
- Read Bevy migration guides for every skipped release. - Read Bevy migration guides for every skipped release.
- Preserve authored scene compatibility when Bevy renames runtime scene components or serialization types.
- Run `cargo fmt --check`, `cargo check --workspace`, `cargo clippy --workspace`, strict clippy on changed/foundation crates with `-D warnings`, focused tests for changed crates, and focused binary builds. - Run `cargo fmt --check`, `cargo check --workspace`, `cargo clippy --workspace`, strict clippy on changed/foundation crates with `-D warnings`, focused tests for changed crates, and focused binary builds.
- Smoke-test the game and editor manually after CI is green. - Smoke-test the game and editor manually after CI is green.
- Record any project-specific migration notes in docs or ADR follow-ups. - Record any project-specific migration notes in docs or ADR follow-ups.
- Keep the normal development profile fully debuggable. Use line-table-only debug information and no incremental cache for the test profile so Bevy upgrade matrices do not retain multi-gigabyte test images indefinitely; use the indexed target cleanup workflow in the root README after migration passes.
## Consequences ## Consequences

View File

@ -6,7 +6,7 @@ Accepted
## Context ## Context
All rendering configuration previously lived in `RenderingSettings` (`assets/project.ron`) and was applied globally to the single `ProjectRenderCamera`. Bevy 0.18 adds experimental Solari ray-traced GI and `FullscreenMaterial` for custom post passes, but no spatial post-process volumes. Game teams need local overrides (rooms, biomes) without forking Rust or duplicating levels. All rendering configuration previously lived in `RenderingSettings` (`assets/project.ron`) and was applied globally to the single `ProjectRenderCamera`. Bevy 0.19 provides experimental Solari ray-traced GI and `FullscreenMaterial` for custom post passes, but no spatial post-process volumes. Game teams need local overrides (rooms, biomes) without forking Rust or duplicating levels.
## Decision ## Decision
@ -21,7 +21,7 @@ All rendering configuration previously lived in `RenderingSettings` (`assets/pro
Runtime exposes `RenderingCapabilities { rt_supported, active_gi_path }` and a resolved `ActiveCameraRenderProfile` each frame. Runtime exposes `RenderingCapabilities { rt_supported, active_gi_path }` and a resolved `ActiveCameraRenderProfile` each frame.
ADR 0016 supersedes the ambiguous `active_gi_path` wording with explicit requested/effective GI fields and `EffectiveRenderStack` fallback reasons. ADR 0016 supersedes the ambiguous `active_gi_path` wording with explicit requested/effective GI fields and `EffectiveRenderStack` fallback reasons.
Bevy 0.18 Solari samples directional lights and emissive meshes, while point and spot lights require the normal PBR light pass that Solari cameras skip. The editor surfaces this limitation directly on point/spot `LightDesc` components and runtime-disables them while Solari is active; point/spot light authoring uses Forward PBR. Requested Solari tags project geometry for raytracing and switches camera plus opaque-renderer components to Solari as soon as RT support is available; render-world readiness counters report whether BLAS/TLAS/bind-group setup and compatible lights are actually ready. Bevy 0.19 Solari samples directional lights and emissive meshes, while point and spot lights require the normal PBR light pass that Solari cameras skip. The editor surfaces this limitation directly on point/spot `LightDesc` components and runtime-disables them while Solari is active; point/spot light authoring uses Forward PBR. Requested Solari tags project geometry for raytracing and switches camera plus opaque-renderer components to Solari as soon as RT support is available; render-world readiness counters report whether BLAS/TLAS/bind-group setup and compatible lights are actually ready.
### WYSIWYG ### WYSIWYG
@ -52,4 +52,4 @@ Authoring is saved to disk; BRP mutates authoring only ([ADR 0009](0009-authorin
- `camera_fx.rs` consumes `ActiveCameraRenderProfile`, not raw `RenderingSettings`. - `camera_fx.rs` consumes `ActiveCameraRenderProfile`, not raw `RenderingSettings`.
- Editor Rendering panel explains GI fallback, overlapping volumes, and active contributors. - Editor Rendering panel explains GI fallback, overlapping volumes, and active contributors.
- Game teams keep global look in `project.ron`, local looks in volumes, reusable bundles in `assets/rendering_profiles/`, custom shaders in `assets/post_fx/`. - Game teams keep global look in `project.ron`, local looks in volumes, reusable bundles in `assets/rendering_profiles/`, custom shaders in `assets/post_fx/`.
- Solari may break some debug draws and does not support point/spot lights in Bevy 0.18; `GiMode::Auto` remains the default startup path and the Rendering panel reports the limitation and render-scene readiness. - Solari may break some debug draws and does not support point/spot lights in Bevy 0.19; `GiMode::Auto` remains the default startup path and the Rendering panel reports the limitation and render-scene readiness.

View File

@ -21,7 +21,7 @@ used by `ProjectSun`.
## Decision ## Decision
1. **`game_hot::rendering::viewport_camera`** is the only module that adds/removes the viewport 1. **`game_hot::rendering::viewport_camera`** is the only module that adds/removes the viewport
render stack (`ProjectRenderCamera`, exposure, GI, atmosphere, bloom, fog, etc.). render stack (`ProjectRenderCamera`, exposure, GI, camera atmosphere settings, bloom, fog, etc.).
2. **Editor** `scene_view` and `play/session` set `RenderTarget`, `Camera::is_active`, and 2. **Editor** `scene_view` and `play/session` set `RenderTarget`, `Camera::is_active`, and
`viewport = None` only. **`render_view::sync_project_render_view`** calls `viewport = None` only. **`render_view::sync_project_render_view`** calls
`clear_viewport_camera_stack` on owner handoff and `sync_viewport_camera_stack` on the active `clear_viewport_camera_stack` on owner handoff and `sync_viewport_camera_stack` on the active
@ -41,3 +41,5 @@ used by `ProjectSun`.
- Thumbnail studio and multi-viewport remain out of scope; they do not use the viewport camera sync path. - Thumbnail studio and multi-viewport remain out of scope; they do not use the viewport camera sync path.
- `setup_project_camera_effects` no longer skips cameras that already have atmosphere; editor defers - `setup_project_camera_effects` no longer skips cameras that already have atmosphere; editor defers
player FX via `GameRenderBootstrap` and relies on `sync_project_render_view` once HDR RTT exists. player FX via `GameRenderBootstrap` and relies on `sync_project_render_view` once HDR RTT exists.
- On Bevy 0.19, the project owns one stable world-space `Atmosphere` entity and cameras receive
`AtmosphereSettings`; render-stack sync strips stale camera-side `Atmosphere` components.

View File

@ -10,7 +10,7 @@ Rendering state crossed settings, hydration, game runtime, Solari readiness, edi
ownership, and diagnostics. The project already had `ActiveCameraRenderProfile` for project and ownership, and diagnostics. The project already had `ActiveCameraRenderProfile` for project and
volume intent, but GI fallback decisions were repeated across camera sync, Solari systems, and UI. volume intent, but GI fallback decisions were repeated across camera sync, Solari systems, and UI.
Bevy 0.18 Solari is still experimental. It needs raytracing-capable hardware, eligible Bevy 0.19 Solari is still experimental. It needs raytracing-capable hardware, eligible
`Mesh3d + MeshMaterial3d<StandardMaterial>` scene geometry, Solari-compatible mesh assets `Mesh3d + MeshMaterial3d<StandardMaterial>` scene geometry, Solari-compatible mesh assets
(TriangleList, POSITION/NORMAL/UV_0/TANGENT, U32 indices), a render-scene bind group, and a (TriangleList, POSITION/NORMAL/UV_0/TANGENT, U32 indices), a render-scene bind group, and a
compatible directional or emissive light source. It also does not replace Forward PBR local compatible directional or emissive light source. It also does not replace Forward PBR local
@ -52,5 +52,5 @@ point/spot lighting.
required Solari mesh attributes or U32 indices. required Solari mesh attributes or U32 indices.
- Existing scenes and material assets remain compatible through serde defaults for new emissive - Existing scenes and material assets remain compatible through serde defaults for new emissive
fields. fields.
- Bevy 0.19+ rendering upgrades remain a separate migration track under ADR 0002, not part of this - Future Bevy rendering upgrades remain a separate migration track under ADR 0002, not part of this
contract change. contract change.

View File

@ -6,7 +6,7 @@ Accepted
## Context ## Context
glTF/GLB and FBX previously entered the editor as imported scenes through `ModelRef`, with runtime hydration loading a format-specific `SceneRoot`. That path is still useful for full scene playback, but it makes ordinary static mesh placement depend on source format behavior and prevents the inspector from exposing a stable renderer component. glTF/GLB and FBX previously entered the editor as imported scenes through `ModelRef`, with runtime hydration loading a format-specific `WorldAssetRoot`. That path is still useful for full scene playback, but it makes ordinary static mesh placement depend on source format behavior and prevents the inspector from exposing a stable renderer component.
The editor needs model drag/drop to create editable static mesh actors by default, while retaining an explicit scene-instance path for animation, skinning, cameras, and other full-scene data. The editor needs model drag/drop to create editable static mesh actors by default, while retaining an explicit scene-instance path for animation, skinning, cameras, and other full-scene data.

View File

@ -1,18 +1,18 @@
Status: Accepted Status: Superseded by [ADR 0022](0022-bevy-0-19-upgrade.md)
Context Context
======= =======
The editor targets Linux desktop sessions where transient swapchain acquire The editor targets Linux desktop sessions where transient swapchain acquire
timeouts can occur during heavy GPU work, compositor stalls, or driver hiccups. timeouts can occur during heavy GPU work, compositor stalls, or driver hiccups.
In upstream `bevy_render` 0.18.1, `prepare_windows` treats In upstream `bevy_render` 0.18.1, `prepare_windows` treated
`wgpu::SurfaceError::Timeout` as a fatal path. That can crash the editor even `wgpu::SurfaceError::Timeout` as a fatal path. That can crash the editor even
though the next frame can often recover. though the next frame can often recover.
Decision Decision
======== ========
Patch `bevy_render` locally through `[patch.crates-io]` and vendor the scoped For the Bevy 0.18 line, patch `bevy_render` locally through `[patch.crates-io]` and vendor the scoped
crate copy under `third_party/bevy_render`. The patch is limited to transient crate copy under `third_party/bevy_render`. The patch is limited to transient
surface acquire timeouts in `prepare_windows`: log the timeout and skip the surface acquire timeouts in `prepare_windows`: log the timeout and skip the
frame instead of panicking. Other surface errors remain on their existing paths. frame instead of panicking. Other surface errors remain on their existing paths.
@ -23,6 +23,7 @@ Consequences
The editor is more tolerant of transient Linux swapchain stalls while preserving The editor is more tolerant of transient Linux swapchain stalls while preserving
the current Bevy API surface for the rest of the workspace. the current Bevy API surface for the rest of the workspace.
The vendor patch must be reviewed during every Bevy upgrade and removed once The Bevy 0.19 upgrade removed the active `[patch.crates-io]` entry for `bevy_render`.
upstream behavior is sufficient. Keep the diff narrow; do not use the vendored The historical `third_party/bevy_render` copy was removed with the upgrade. Future
crate for unrelated render changes. swapchain timeout work should start from Bevy 0.19/wgpu behavior rather than carrying
this patch forward by default.

View File

@ -0,0 +1,33 @@
# ADR 0022: Bevy 0.19 Upgrade
## Status
Accepted
## Context
Avian 0.7 supports Bevy 0.19, which lets the workspace move off Bevy 0.18 while keeping the physics stack current. Bevy 0.19 also renamed the old dynamic scene/world serialization surface: runtime scene roots are `WorldAssetRoot`, saved dynamic scenes are `DynamicWorld`, and `.scn.ron` loading uses `WorldDeserializer`.
Two ecosystem crates used by the editor still need local compatibility shims during the upgrade:
- `transform-gizmo-bevy` 0.9 needs Bevy 0.19 render API updates.
- `bevy_ufbx` 0.18.1-rc.1 still targets Bevy 0.18 but is required for FBX import and scene-instance playback.
The old local `bevy_render` timeout patch was specific to Bevy 0.18 window preparation behavior.
Bevy 0.19 also changed the atmosphere model: `Atmosphere` is a world-space planet entity selected by nearby cameras, while cameras opt in with `AtmosphereSettings`.
## Decision
Upgrade the workspace to Bevy 0.19 and Avian 0.7. Keep FBX support by vendoring `bevy_ufbx` under `third_party/bevy_ufbx` and patching it to emit Bevy 0.19 `WorldAsset` scene labels. Keep the existing local `transform-gizmo-bevy` patch and update it for Bevy 0.19 render pipeline and phase APIs.
Remove the active `[patch.crates-io]` override for `bevy_render`. Runtime scene hydration now uses `WorldAssetRoot` for glTF/FBX model scenes and `DynamicWorldRoot` for saved `.scn.ron` prefabs. Scene save/load continues to write the projects schema-wrapped `.scn.ron` format through `DynamicWorld`.
Keep one stable project-owned `Atmosphere` entity for the active render profile. Editor and game cameras only receive `AtmosphereSettings`; camera-side `Atmosphere` components are stripped during render-stack sync to avoid unstable planet transforms and extraction jitter.
## Consequences
The dependency graph has one Bevy 0.19 line and one egui 0.34 line. FBX support remains available, but `third_party/bevy_ufbx` should be removed when upstream publishes a Bevy 0.19-compatible release.
Docs and tooling should use `WorldAssetRoot` / `DynamicWorldRoot` for runtime hydrated scene components. Existing authoring components (`ModelRef`, `PrefabRef`, `StaticMeshRenderer`) remain the stable scene-file surface.
Swapchain timeout behavior now follows upstream Bevy 0.19 and wgpu behavior. If Linux timeout crashes return, investigate current upstream code before reintroducing a local `bevy_render` patch.

View File

@ -45,7 +45,7 @@ Docs for the in-process egui editor (`crates/editor/`). Update this index when a
- **Static mesh renderer** — model drag/drop defaults to `ActorKind::StaticMesh + StaticMeshRenderer` using normalized artifacts under `assets/meshes/generated/`. Renderer slots store imported `EditorAssetRef` mesh/material references, not source FBX/glTF paths. `SceneInstance` placement keeps the legacy `ImportedModel + ModelRef` path for full scene playback. - **Static mesh renderer** — model drag/drop defaults to `ActorKind::StaticMesh + StaticMeshRenderer` using normalized artifacts under `assets/meshes/generated/`. Renderer slots store imported `EditorAssetRef` mesh/material references, not source FBX/glTF paths. `SceneInstance` placement keeps the legacy `ImportedModel + ModelRef` path for full scene playback.
- **Brush authoring**`ActorKind::Brush + BrushDesc` stores persisted convex blockout faces, validates authored geometry in the inspector and Window → Brush Diagnostics, and hydrates active valid brushes into generated mesh children. See [brushes.md](brushes.md). - **Brush authoring**`ActorKind::Brush + BrushDesc` stores persisted convex blockout faces, validates authored geometry in the inspector and Window → Brush Diagnostics, and hydrates active valid brushes into generated mesh children. See [brushes.md](brushes.md).
- **Draw Brush**`B`, toolbar pencil, or command `brush.draw` enters a floor-polygon draw mode. LMB places snapped points, Backspace removes the last point, Enter locks the outline for height editing, mouse up/down adjusts height, and Enter/LMB creates additive prism brushes through history. Esc/right-click cancels. Simple concave outlines decompose into convex brush parts; self-intersections remain blocked. - **Draw Brush**`B`, toolbar pencil, or command `brush.draw` enters a floor-polygon draw mode. LMB places snapped points, Backspace removes the last point, Enter locks the outline for height editing, mouse up/down adjusts height, and Enter/LMB creates additive prism brushes through history. Esc/right-click cancels. Simple concave outlines decompose into convex brush parts; self-intersections remain blocked.
- **Brush edit modes** — with a brush selected, `1`/`2`/`3`/`4` enter vertex, edge, face, and clip element modes. Element modes show brush handles in the viewport, own LMB picking, support Shift multi-select, show a mode badge, and Esc returns to object mode. Vertex/edge/face selections can be dragged on the viewport floor plane and commit undoable `SetBrush` edits; clip mode is a preview/selection mode until CSG split support lands. - **Brush edit modes** — with a brush selected, `1`/`2`/`3`/`4` enter vertex, edge, face, and clip element modes. Element modes show brush handles in the viewport, own LMB picking, support Shift multi-select, show a mode badge, and Esc returns to object mode. Vertex/edge/face selections use the standard `W`/`E`/`R` gizmo at the element pivot and commit undoable `SetBrush` edits. Clip previews a bounds-based half-brush and commits with Enter; command-palette intersect, convex merge, and subtract operations use the same preview/commit lifecycle for conservative cuboid/prism blockout.
- **Collider split** — imported mesh collision lives in a separate `ColliderDesc::StaticMesh` plus optional `RigidBodyDesc`; renderer slots own only render visibility, shadows, mesh, and material references. - **Collider split** — imported mesh collision lives in a separate `ColliderDesc::StaticMesh` plus optional `RigidBodyDesc`; renderer slots own only render visibility, shadows, mesh, and material references.
- **Material assets**`MaterialAsset` RON (`label` + `MaterialDesc`) is shader-schema aware. Actor material edits live on the actor `MaterialDesc`; static mesh slot material refs are source/default selectors, and content-browser asset inspectors are the path for editing shared material assets. - **Material assets**`MaterialAsset` RON (`label` + `MaterialDesc`) is shader-schema aware. Actor material edits live on the actor `MaterialDesc`; static mesh slot material refs are source/default selectors, and content-browser asset inspectors are the path for editing shared material assets.
- **Prefab overrides**`PrefabInstance.overrides_ron` stores transform/material/child-visibility overrides; inspector collapsible groups with **Apply** / **Revert** (replaces transform-only revert v1). - **Prefab overrides**`PrefabInstance.overrides_ron` stores transform/material/child-visibility overrides; inspector collapsible groups with **Apply** / **Revert** (replaces transform-only revert v1).
@ -55,7 +55,7 @@ Docs for the in-process egui editor (`crates/editor/`). Update this index when a
- **World lighting** has two layers: project default sun/ambient settings, and optional scene-authored directional `LightDesc` overrides. **Scene → Lighting** menu and **Rendering** panel (Window) restore project sun or bake a scene directional from project settings. **Apply** in Project Settings updates ambient and sun illuminance live. - **World lighting** has two layers: project default sun/ambient settings, and optional scene-authored directional `LightDesc` overrides. **Scene → Lighting** menu and **Rendering** panel (Window) restore project sun or bake a scene directional from project settings. **Apply** in Project Settings updates ambient and sun illuminance live.
- **Rendering**`GiMode` + post-process volumes; see [rendering.md](rendering.md). **Window → Rendering** shows requested/effective active-camera stack, fallback reason, Solari eligibility, viewport GI badge, and volume HUD. - **Rendering**`GiMode` + post-process volumes; see [rendering.md](rendering.md). **Window → Rendering** shows requested/effective active-camera stack, fallback reason, Solari eligibility, viewport GI badge, and volume HUD.
- **Inspector** shows a responsive header, **Transform**, registered authoring component cards, and an Add Component footer (`actor_inspector`) that expands an inline search shelf above the button; only the component body scrolls. Rows wrap or stack inside narrow docks instead of forcing the panel wider, and legacy authoring sections use the same component-card shell. Component card carets collapse/expand for the current editor session, status dots toggle persisted active state, and triple-dot menus provide reset, copy/paste values, move, remove, and documentation slots. Inactive authoring components remain in the scene but are skipped by hydration/runtime queries, stripping their generated meshes, lights, physics, or post-process effects. The Add Component shelf is registry-driven with persistent search, category groups, descriptions, hydration hints, duplicate/conflict disabled states, and recommendation hints. `StaticMeshRenderer` uses concept-style slot cards with model thumbnails, imported mesh/source-material selectors, visibility, and shadow flags. Actor material authoring lives in the `Authoring Material` component, whose texture refs use Asset Browser picker/drop controls. Empty source material refs display the inherited source material from the generated static mesh artifact as **Source default**; selector **Browse** assigns explicit normalized imported sub-assets, **Locate** focuses the owning asset in the Asset Browser, and **Clear** returns optional material slots to the inherited source default. `ColliderDesc` and `RigidBodyDesc` are separate component cards. Game crates add sections via `ActorInspectorSection` + `game::editor_ext::actor_inspector_section_ids()`. - **Inspector** shows a responsive header, **Transform**, registered authoring component cards, and an Add Component footer (`actor_inspector`) that expands an inline search shelf above the button; only the component body scrolls. Rows wrap or stack inside narrow docks instead of forcing the panel wider, and legacy authoring sections use the same component-card shell. Component card carets collapse/expand for the current editor session, status dots toggle persisted active state, and triple-dot menus provide reset, copy/paste values, move, remove, and documentation slots. Inactive authoring components remain in the scene but are skipped by hydration/runtime queries, stripping their generated meshes, lights, physics, or post-process effects. The Add Component shelf is registry-driven with persistent search, category groups, descriptions, hydration hints, duplicate/conflict disabled states, and recommendation hints. `StaticMeshRenderer` uses concept-style slot cards with model thumbnails, imported mesh/source-material selectors, visibility, and shadow flags. Actor material authoring lives in the `Authoring Material` component, whose texture refs use Asset Browser picker/drop controls. Empty source material refs display the inherited source material from the generated static mesh artifact as **Source default**; selector **Browse** assigns explicit normalized imported sub-assets, **Locate** focuses the owning asset in the Asset Browser, and **Clear** returns optional material slots to the inherited source default. `ColliderDesc` and `RigidBodyDesc` are separate component cards. Game crates add sections via `ActorInspectorSection` + `game::editor_ext::actor_inspector_section_ids()`.
- **Command palette** (`Ctrl+P`): focuses the filter on open, selects the first filtered command, supports arrow-key selection and Enter to run. Commands include `scene.reset_lighting`, `brush.draw`, `brush.subtract`, `brush.intersect`, `brush.merge_convex`, `rendering.create_volume`, `rendering.select_volumes_at_camera`, `rendering.focus_active_volumes`, `selection.group`, `selection.focus`, `selection.reset_transform`, and play commands. - **Command palette** (`Ctrl+P`): opens centered, clears and focuses the filter, selects the first result, searches both human-facing labels and stable command IDs, and supports arrow-key selection and Enter to run. Commands include `scene.reset_lighting`, `brush.draw`, `brush.subtract`, `brush.intersect`, `brush.merge_convex`, `rendering.create_volume`, `rendering.select_volumes_at_camera`, `rendering.focus_active_volumes`, `selection.group`, `selection.focus`, `selection.reset_transform`, and play commands.
- **Editor input routing** gives egui text fields first claim on keyboard input. Viewport shortcut keys require viewport pointer focus and are suspended while RMB/MMB camera navigation is active. - **Editor input routing** gives egui text fields first claim on keyboard input. Viewport shortcut keys require viewport pointer focus and are suspended while RMB/MMB camera navigation is active.
- **ActorKind** is required on saved level objects (schema v2). Save strips hydrated ECS before writing `.scn.ron`. - **ActorKind** is required on saved level objects (schema v2). Save strips hydrated ECS before writing `.scn.ron`.
- **Scene visualizers** expose actor root icons, colliders, lights, player spawns, prefab/model anchors, Project Sun, and runtime player/camera markers without changing saved scene data. Viewport options include actor icon size and transform gizmo size sliders. - **Scene visualizers** expose actor root icons, colliders, lights, player spawns, prefab/model anchors, Project Sun, and runtime player/camera markers without changing saved scene data. Viewport options include actor icon size and transform gizmo size sliders.

View File

@ -59,6 +59,12 @@ Workflow: run `cargo watch … build -p game_hot` in one terminal, `cargo run -p
Do not add egui or editor-only state to the `settings` crate. Do not add egui or editor-only state to the `settings` crate.
The active project root is a startup boundary because `AssetPlugin.file_path`, project settings,
the asset catalog, and the initial level must agree before editor systems initialize. The editor
currently starts in the repository project root. Cross-project selection and scaffolding belong in
the planned BS-JD-001 launcher before the main app is built; do not reintroduce in-process project
switching that only changes settings metadata while leaving the AssetServer on the old root.
## Camera and viewport model ## Camera and viewport model
See [ADR 0014](../adr/0014-unified-viewport-model.md) for the unified viewport decision. See [ADR 0014](../adr/0014-unified-viewport-model.md) for the unified viewport decision.
@ -115,7 +121,7 @@ The egui layer lives under `crates/editor/src/ui/`:
| `diagnostics.rs` | Detailed stats (Window → Diagnostics, Asset Browser footer) | | `diagnostics.rs` | Detailed stats (Window → Diagnostics, Asset Browser footer) |
| `layout.rs` | Dock layout RON persistence in `editor_prefs.ron` | | `layout.rs` | Dock layout RON persistence in `editor_prefs.ron` |
The Viewport fills the tab (no inline help text). Gizmo/grid/play controls sit on a semi-transparent overlay, and `G` hides editor-only overlays for a clean game view. A blue status bar shows scene path, mode, selection count, and history. Dock layout restores from `~/.config/bevy-fps/editor_prefs.ron` on startup; **View → Reset Layout** restores defaults. The Viewport fills the tab (no inline help text). Gizmo/grid/play controls sit on a semi-transparent overlay, and `G` hides editor-only overlays for a clean game view. A blue status bar prioritizes `SceneIo.status` feedback and also shows scene path/dirty state, mode, selection count, history, and the active operator. Long feedback is truncated responsively and available in a hover tooltip. Dock layout restores from `~/.config/bevy-fps/editor_prefs.ron` on startup; **View → Reset Layout** restores defaults.
**Follow-up (Phase 4b):** hierarchy expand/collapse persistence, inspector search, and richer per-asset previews. **Follow-up (Phase 4b):** hierarchy expand/collapse persistence, inspector search, and richer per-asset previews.
@ -146,7 +152,7 @@ On play enter/exit the editor snapshots **player sim state only** (transform, ve
2. `EditorOnly` entities (cameras, helpers) are filtered from hierarchy and save; visualizer proxies can be picked but resolve back to source entities. 2. `EditorOnly` entities (cameras, helpers) are filtered from hierarchy and save; visualizer proxies can be picked but resolve back to source entities.
3. Player placement is stored as `PlayerSpawn`; the runtime `Player` is never serialized. 3. Player placement is stored as `PlayerSpawn`; the runtime `Player` is never serialized.
4. `SceneIo` writes native Bevy dynamic scenes under `assets/levels/`, routed through the Bevy-free `scene::document::SceneDocument` seam. The document layer preserves the current RON storage while exposing stable actor/component document concepts and patch vocabulary for tools. 4. `SceneIo` writes native Bevy dynamic scenes under `assets/levels/`, routed through the Bevy-free `scene::document::SceneDocument` seam. The document layer preserves the current RON storage while exposing stable actor/component document concepts and patch vocabulary for tools.
5. Hydration systems in `game` spawn meshes, colliders, lights, and static mesh renderer parts at runtime. `ActorId` and `ComponentInstanceId` are the persisted identities; raw Bevy `Entity` IDs are runtime-only and must not become tool-facing document IDs. 5. Hydration systems in `game` spawn meshes, colliders, lights, and static mesh renderer parts at runtime. Level-object roots are initialized with Bevy visibility hierarchy components before generated children are attached, so authoring `EditorVisibility` participates in normal visibility propagation. `ActorId` and `ComponentInstanceId` are the persisted identities; raw Bevy `Entity` IDs are runtime-only and must not become tool-facing document IDs.
6. Brush actors store authored convex geometry as `BrushDesc`; hydration rebuilds generated mesh children from those faces and strips the children before save. 6. Brush actors store authored convex geometry as `BrushDesc`; hydration rebuilds generated mesh children from those faces and strips the children before save.
## Model import (glTF + FBX) ## Model import (glTF + FBX)
@ -156,7 +162,7 @@ On play enter/exit the editor snapshots **player sim state only** (transform, ve
- **Browser subassets:** model rows can expand into a content shelf backed by the generated manifest. Mesh subassets can be selected or dragged into the viewport as `StaticMeshRenderer` actors, material subassets expose source defaults, and texture dependencies can be applied to selected actors without treating glTF and FBX differently. The thumbnail studio renders each generated model/subasset/material preview into its own render target before registering it in the cache so later thumbnails cannot overwrite earlier cache entries. - **Browser subassets:** model rows can expand into a content shelf backed by the generated manifest. Mesh subassets can be selected or dragged into the viewport as `StaticMeshRenderer` actors, material subassets expose source defaults, and texture dependencies can be applied to selected actors without treating glTF and FBX differently. The thumbnail studio renders each generated model/subasset/material preview into its own render target before registering it in the cache so later thumbnails cannot overwrite earlier cache entries.
- **Default placement:** model drag/drop creates `ActorKind::StaticMesh + StaticMeshRenderer`. Renderer slots reference imported `EditorAssetRef` values (`asset_id` + `sub_asset_id`) and hydration resolves those through the generated artifact. `SingleActor` hierarchy mode stores all parts in one renderer; `SourceHierarchy` creates a root with child static mesh actors. - **Default placement:** model drag/drop creates `ActorKind::StaticMesh + StaticMeshRenderer`. Renderer slots reference imported `EditorAssetRef` values (`asset_id` + `sub_asset_id`) and hydration resolves those through the generated artifact. `SingleActor` hierarchy mode stores all parts in one renderer; `SourceHierarchy` creates a root with child static mesh actors.
- **Collision:** when collider generation is enabled, placement adds `RigidBodyDesc` and `ColliderDesc::StaticMesh` using the same imported mesh refs. Renderer slots no longer own collider state. - **Collision:** when collider generation is enabled, placement adds `RigidBodyDesc` and `ColliderDesc::StaticMesh` using the same imported mesh refs. Renderer slots no longer own collider state.
- **Scene-instance placement:** asset details can switch placement to `SceneInstance`; that keeps `ImportedModel + ModelRef`, which hydrates to `SceneRoot`. glTF uses `GltfAssetLabel::Scene`; FBX uses `bevy_ufbx` (`path#SceneN`). `FbxPlugin` is registered in `GamePlugin`. - **Scene-instance placement:** asset details can switch placement to `SceneInstance`; that keeps `ImportedModel + ModelRef`, which hydrates to `WorldAssetRoot`. glTF uses `GltfAssetLabel::Scene`; FBX uses `bevy_ufbx` (`path#SceneN`). `FbxPlugin` is registered in `GamePlugin`.
- **Limitations:** Binary FBX only (not ASCII). Static mesh placement stores animation/skinning/camera/light metadata and warnings but does not play those features; use Scene Instance for full-scene playback. - **Limitations:** Binary FBX only (not ASCII). Static mesh placement stores animation/skinning/camera/light metadata and warnings but does not play those features; use Scene Instance for full-scene playback.
## Undo / history ## Undo / history
@ -181,7 +187,7 @@ PIE stop restores player simulation state only; authored `LevelObject` edits mad
| `viewport/` | Camera, selection, gizmos, render views, panel settings | | `viewport/` | Camera, selection, gizmos, render views, panel settings |
| `play/` | PIE session, editor mode, net editor profiles | | `play/` | PIE session, editor mode, net editor profiles |
| `assets/` | Catalog, asset DB, static mesh artifacts, prefab overrides | | `assets/` | Catalog, asset DB, static mesh artifacts, prefab overrides |
| `project/` | Workspace, project I/O, settings UI | | `project/` | Project I/O and settings UI |
| `ext/` | Command palette, BRP, game panel adapters | | `ext/` | Command palette, BRP, game panel adapters |
| `history/` | Undo commands + plugin | | `history/` | Undo commands + plugin |
| `ui/` | egui dock shell | | `ui/` | egui dock shell |
@ -192,7 +198,7 @@ Shared scene schema lives in `crates/scene` (stamp/migrate/validate on save, loa
The unified viewport renders through an HDR offscreen target (`render_target.rs`). When the panel target is missing (egui reflow), cameras must strip atmosphere/post-FX before targeting the swapchain. User-authored HDR may be disabled, but atmosphere and effective Solari still force `Hdr` on the active viewport camera because Bevy sky/Solari pipelines are not safe to carry across SDR targets. `render_view::sync_project_render_view` runs in **PostUpdate** after RTT target assignment and reapplies the project FX stack whenever the active panel target handle changes (startup, resize), not only on settings Apply. Project Settings uses a **draft buffer**; **Apply** commits after render targets resync. The unified viewport renders through an HDR offscreen target (`render_target.rs`). When the panel target is missing (egui reflow), cameras must strip atmosphere/post-FX before targeting the swapchain. User-authored HDR may be disabled, but atmosphere and effective Solari still force `Hdr` on the active viewport camera because Bevy sky/Solari pipelines are not safe to carry across SDR targets. `render_view::sync_project_render_view` runs in **PostUpdate** after RTT target assignment and reapplies the project FX stack whenever the active panel target handle changes (startup, resize), not only on settings Apply. Project Settings uses a **draft buffer**; **Apply** commits after render targets resync.
The workspace patches `bevy_render` under `third_party/bevy_render` so Linux `wgpu::SurfaceError::Timeout` during swapchain texture acquisition logs and skips the frame instead of panicking in `prepare_windows`. Keep this patch scoped to transient surface acquire timeouts and re-evaluate it during Bevy upgrades. Bevy 0.19 removed the workspace's prior local `bevy_render` swapchain-timeout patch. The only active Bevy-adjacent patches are compatibility shims for ecosystem crates: `transform-gizmo-bevy` and `bevy_ufbx` under `third_party/`.
## Extensibility (phase 6) ## Extensibility (phase 6)

View File

@ -15,17 +15,17 @@ Launch the editor with dev features as usual:
cargo run -p editor --features dev cargo run -p editor --features dev
``` ```
Default HTTP endpoint follows Bevy 0.18 remote defaults (port **15702**, routes under `/remote/`). Default HTTP endpoint follows Bevy 0.19 remote defaults (port **15702**, routes under `/remote/`).
## Authoring-only mutation policy ## Authoring-only mutation policy
**BRP and automation must mutate authoring components only** — the same allowlist used on scene save in `scene_io.rs`. Hydration rebuilds runtime ECS (`Mesh3d`, Bevy lights, physics, `SceneRoot`, etc.) on the next frame. **BRP and automation must mutate authoring components only** — the same allowlist used on scene save in `scene_io.rs`. Hydration rebuilds runtime ECS (`Mesh3d`, Bevy lights, physics, `WorldAssetRoot`, etc.) on the next frame.
| Allowed (examples) | Forbidden on level objects | | Allowed (examples) | Forbidden on level objects |
|--------------------|----------------------------| |--------------------|----------------------------|
| `Transform`, `Name`, `ActorKind` | `PointLight`, `SpotLight`, `DirectionalLight` | | `Transform`, `Name`, `ActorKind` | `PointLight`, `SpotLight`, `DirectionalLight` |
| `Primitive`, `StaticMeshRenderer`, `MaterialDesc`, `MaterialOverride`, `LightDesc` | `Mesh3d`, `MeshMaterial3d`, `RigidBody`, `Collider` | | `Primitive`, `StaticMeshRenderer`, `MaterialDesc`, `MaterialOverride`, `LightDesc` | `Mesh3d`, `MeshMaterial3d`, `RigidBody`, `Collider` |
| `ModelRef`, `RigidBodyDesc`, `ColliderDesc`, legacy `PhysicsBody`, gameplay markers | `SceneRoot`, generated static mesh children, internal visibility types | | `ModelRef`, `RigidBodyDesc`, `ColliderDesc`, legacy `PhysicsBody`, gameplay markers | `WorldAssetRoot`, generated static mesh children, internal visibility types |
| `PostProcessVolumeDesc` | Runtime post-process components on cameras | | `PostProcessVolumeDesc` | Runtime post-process components on cameras |
Prefer: Prefer:
@ -107,7 +107,7 @@ External automation can enqueue the same names through the editor command queue
Validate committed levels without the editor UI: Validate committed levels without the editor UI:
```bash ```bash
cargo run -p xtask --bin validate-levels cargo validate-levels
``` ```
This runs `scene::validate_level_file` (schema migrate + **authoring-only** component check). This runs `scene::validate_level_file` (schema migrate + **authoring-only** component check).

View File

@ -16,13 +16,13 @@ Brushes are persisted blockout geometry stored as `ActorKind::Brush + BrushDesc`
## Element Modes ## Element Modes
With a brush actor selected, `1`/`2`/`3`/`4` enter vertex, edge, face, and clip modes. Element modes show viewport overlays for the selected brush, route LMB to element picking, support Shift+LMB multi-select, and display a `Brush: <mode>` badge. `Esc` returns to object mode. With a brush actor selected, `1`/`2`/`3`/`4` enter vertex, edge, face, and clip modes. Element modes show viewport overlays for the selected brush, route LMB to element picking, support Shift+LMB multi-select, and display a brush edit banner with the active mode, selected element count, and available controls. Actor transform gizmos are replaced by a brush element transform gizmo at the selected element pivot while a brush element mode is active. `Esc` returns to object mode.
Vertex, edge, and face selections can be dragged on the viewport floor plane. The editor moves matching duplicated corner vertices across all brush faces so simple cube/prism brushes stay welded, recomputes face planes, validates the result, and commits one undoable brush edit when the drag releases. Invalid drag results are rejected with operator status text and reverted to the pre-drag brush. Vertex, edge, and face selections are transformed through the standard viewport gizmo. `W`, `E`, and `R` choose translate, rotate, and scale for the selected brush elements. The editor transforms matching duplicated corner vertices across all brush faces so simple cube/prism brushes stay welded, recomputes face planes, validates the result, and commits one undoable brush edit when the gizmo releases. Invalid gizmo results are rejected with operator status text and reverted to the pre-edit brush.
Clip mode is a bounds-based MVP for cuboid/prism blockout. Select one face in Clip mode to preview the half-brush result along that face's dominant normal axis; the magenta wireframe shows the pending trimmed bounds. **Enter** commits the clip through history and **Esc** returns to Object mode. Clip mode is a bounds-based MVP for cuboid/prism blockout. Select one face in Clip mode to preview the half-brush result along that face's dominant normal axis; the magenta wireframe shows the pending trimmed bounds. **Enter** commits the clip through history and **Esc** returns to Object mode.
When one or more faces are selected in Face mode, the Brush inspector shows selected-face controls for UV offset, UV scale, UV rotation, material ref, and texture ref. Material and texture rows use Asset Browser-aware picker controls with clear, locate, browse, and compatible drag/drop assignment. These fields edit the persisted `BrushFaceDesc` data through undoable brush updates. Face refs cache loadable source paths when available; hydration groups faces by material/texture path and spawns separate generated mesh children for those batches. When one or more faces are selected in Face mode, the Brush inspector shows selected-face controls for UV offset, UV scale, UV rotation in degrees, material ref, and texture ref. Material and texture rows use Asset Browser-aware picker controls with clear, locate, browse, and compatible drag/drop assignment. These fields edit the persisted `BrushFaceDesc` data through undoable brush updates and update hydrated brush mesh UVs. Face refs cache loadable source paths when available; hydration groups faces by material/texture path and spawns separate generated mesh children for those batches. Non-finite UV offset/scale/rotation values are reported as brush diagnostics and sanitized during hydration so invalid authoring data does not emit NaN mesh UVs.
## Boolean Operations ## Boolean Operations

View File

@ -13,7 +13,7 @@
- PIE step while paused (F7) - PIE step while paused (F7)
- Net editor state + determinism HUD stub - Net editor state + determinism HUD stub
- `EditorPlugin` / `EditorCommand` extensibility surface - `EditorPlugin` / `EditorCommand` extensibility surface
- `cargo run -p validate-levels --manifest-path xtask/Cargo.toml` - `cargo validate-levels`
## Gates ## Gates

View File

@ -8,6 +8,8 @@ Step-by-step guide for game teams managing look development without Rust changes
2. **Post-process volumes** — scene actors with local overrides (rooms, biomes, boss arenas). 2. **Post-process volumes** — scene actors with local overrides (rooms, biomes, boss arenas).
3. **Requested profile** — the active viewport camera always uses `ActiveCameraRenderProfile` (project + blended volumes), whether it is the editor fly camera or the possessed player camera. 3. **Requested profile** — the active viewport camera always uses `ActiveCameraRenderProfile` (project + blended volumes), whether it is the editor fly camera or the possessed player camera.
4. **Effective stack**`EffectiveRenderStack` records requested GI, effective GI, and fallback reason for renderer sync and diagnostics. 4. **Effective stack**`EffectiveRenderStack` records requested GI, effective GI, and fallback reason for renderer sync and diagnostics.
5. **Atmosphere ownership** — Bevy 0.19 uses one stable project `Atmosphere` entity; active cameras only carry `AtmosphereSettings` when the profile enables sky rendering.
6. **Antialiasing ownership** — all 3D cameras spawn with MSAA disabled. The project rendering profile owns temporal antialiasing, and Bevy's deferred path does not support MSAA.
See [ADR 0013](../adr/0013-rendering-tiers-and-post-process-volumes.md) for tier policy and crate boundaries, and [ADR 0016](../adr/0016-unified-rendering-contract.md) for the requested/effective stack contract. See [ADR 0013](../adr/0013-rendering-tiers-and-post-process-volumes.md) for tier policy and crate boundaries, and [ADR 0016](../adr/0016-unified-rendering-contract.md) for the requested/effective stack contract.
@ -60,7 +62,7 @@ Viewport overlays: **GI badge** (Forward / Solari), **volume HUD** when inside a
| Forward | Bevy Forward PBR with HDR/TAA/SSAO/atmosphere/shadows | | Forward | Bevy Forward PBR with HDR/TAA/SSAO/atmosphere/shadows |
| Solari | Request Solari; the camera attaches Bevy Solari when RT support is available | | Solari | Request Solari; the camera attaches Bevy Solari when RT support is available |
Solari in Bevy 0.18 samples directional lights and emissive meshes. Point and spot lights require the normal PBR light pass, so `LightDesc` point/spot components are runtime-disabled while Solari is active. The component remains in the scene, the inspector shows an unsupported message, and hydration strips/skips the Bevy `PointLight` / `SpotLight` until authors switch the light to Directional or switch GI to Forward. The viewport resolves Hybrid Auto through **`EffectiveRenderStack`**: requested Solari keeps project meshes eligible for raytracing and attaches Bevy Solari when RT support is available. Effective GI falls back to **Forward** only when RT is unavailable. Effective Solari forces an HDR camera target even if project HDR is off, because Bevy Solari writes to the main texture through a storage binding and sRGB textures are invalid for that use. Primitive hydration generates tangents so built-in boxes/spheres can enter Solari BLAS/TLAS; imported/custom meshes still need TriangleList geometry, POSITION/NORMAL/UV_0/TANGENT attributes, and U32 indices. The Rendering panel reports the exact fallback reason, Solari-compatible asset counts, bind-group state, and compatible light counters. Set `BEVY_FPS_FORCE_SOLARI=1` to force RT feature probing in dev (see README troubleshooting). Solari in Bevy 0.19 samples directional lights and emissive meshes. Point and spot lights require the normal PBR light pass, so `LightDesc` point/spot components are runtime-disabled while Solari is active. The component remains in the scene, the inspector shows an unsupported message, and hydration strips/skips the Bevy `PointLight` / `SpotLight` until authors switch the light to Directional or switch GI to Forward. The viewport resolves Hybrid Auto through **`EffectiveRenderStack`**: requested Solari keeps project meshes eligible for raytracing and attaches Bevy Solari when RT support is available. Effective GI falls back to **Forward** only when RT is unavailable. Effective Solari forces an HDR camera target even if project HDR is off, because Bevy Solari writes to the main texture through a storage binding and sRGB textures are invalid for that use. Primitive hydration generates tangents so built-in boxes/spheres can enter Solari BLAS/TLAS; imported/custom meshes still need TriangleList geometry, POSITION/NORMAL/UV_0/TANGENT attributes, and U32 indices. The Rendering panel reports the exact fallback reason, Solari-compatible asset counts, bind-group state, and compatible light counters. Set `BEVY_FPS_FORCE_SOLARI=1` to force RT feature probing in dev (see README troubleshooting).
## Emissive lighting ## Emissive lighting
@ -103,5 +105,6 @@ Open `assets/levels/rendering_showcase.scn.ron` for fog, exposure, and vignette
| GI badge shows Forward unexpectedly | Solari RT wgpu features unavailable; Rendering → Active Camera tab | | GI badge shows Forward unexpectedly | Solari RT wgpu features unavailable; Rendering → Active Camera tab |
| Volume has no effect | Camera inside AABB? Priority vs other volumes? Overrides enabled? | | Volume has no effect | Camera inside AABB? Priority vs other volumes? Overrides enabled? |
| Custom FX missing | RON path in volume matches `assets/post_fx/`; shader path in RON | | Custom FX missing | RON path in volume matches `assets/post_fx/`; shader path in RON |
| Atmosphere flickers or artifacts | Confirm only the project `Atmosphere` entity exists and active cameras have `AtmosphereSettings`, not camera-side `Atmosphere` components. |
Command palette: `rendering.select_volumes_at_camera`, `rendering.focus_active_volumes`. Command palette: `rendering.select_volumes_at_camera`, `rendering.focus_active_volumes`.

View File

@ -39,7 +39,7 @@ Phased work for the in-process editor (`crates/editor`). Status reflects the cur
| `ProjectWorkspace` resource | Done | Root, settings path, dirty flags | | `ProjectWorkspace` resource | Done | Root, settings path, dirty flags |
| User prefs (`~/.config/bevy-fps/editor_prefs.ron`) | Done | Recent levels, load on startup | | User prefs (`~/.config/bevy-fps/editor_prefs.ron`) | Done | Recent levels, load on startup |
| Window title from project + scene | Done | `project_io::window_title` | | Window title from project + scene | Done | `project_io::window_title` |
| New/Open project menu | Done | File → Project; distinct from level New/Open | | Project launcher and scaffolding | Planned | BS-JD-001; project selection must happen before AssetServer startup, so the former in-process New/Open menu was removed |
| Dock layout persistence | Done | RON in `editor_prefs.ron`; View → Reset Layout | | Dock layout persistence | Done | RON in `editor_prefs.ron`; View → Reset Layout |
## Phase 2 — Viewport excellence ## Phase 2 — Viewport excellence
@ -101,7 +101,7 @@ Phased work for the in-process editor (`crates/editor`). Status reflects the cur
| Item | Status | Notes | | Item | Status | Notes |
|------|--------|-------| |------|--------|-------|
| Dark pro theme + Phosphor icons | Done | `ui/theme.rs`, `ui/fonts.rs`, high-contrast selection states, overlay toolbars | | Dark pro theme + Phosphor icons | Done | `ui/theme.rs`, `ui/fonts.rs`, high-contrast selection states, overlay toolbars |
| Fixed status bar | Done | Replaces Status dock tab | | Fixed status bar | Done | Replaces Status dock tab; exposes scene I/O and operator feedback alongside scene/mode/selection/history |
| Viewport overlay toolbar | Done | Gizmo/grid/focus/options/game-view toggle (play moved to main toolbar) | | Viewport overlay toolbar | Done | Gizmo/grid/focus/options/game-view toggle (play moved to main toolbar) |
| Main toolbar transport | Done | Centered Play/Pause/Stop/Eject; taller bar (~52px) | | Main toolbar transport | Done | Centered Play/Pause/Stop/Eject; taller bar (~52px) |
| Pause in PIE | Done | `PlayPaused` freezes sim while staying in Play (`F6`) | | Pause in PIE | Done | `PlayPaused` freezes sim while staying in Play (`F6`) |

View File

@ -1,296 +0,0 @@
# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO
#
# When uploading crates to the registry Cargo will automatically
# "normalize" Cargo.toml files for maximal compatibility
# with all versions of Cargo and also rewrite `path` dependencies
# to registry (e.g., crates.io) dependencies.
#
# If you are reading this file be aware that the original Cargo.toml
# will likely look very different (and much more reasonable).
# See Cargo.toml.orig for the original contents.
[package]
edition = "2024"
name = "bevy_render"
version = "0.18.1"
build = false
autolib = false
autobins = false
autoexamples = false
autotests = false
autobenches = false
description = "Provides rendering functionality for Bevy Engine"
homepage = "https://bevy.org"
readme = "README.md"
keywords = ["bevy"]
license = "MIT OR Apache-2.0"
repository = "https://github.com/bevyengine/bevy"
resolver = "2"
[package.metadata.docs.rs]
rustdoc-args = [
"-Zunstable-options",
"--generate-link-to-definition",
]
all-features = true
[features]
ci_limits = []
decoupled_naga = ["bevy_shader/decoupled_naga"]
detailed_trace = []
gles = ["wgpu/gles"]
morph = ["bevy_mesh/morph"]
multi_threaded = ["bevy_tasks/multi_threaded"]
raw_vulkan_init = ["wgpu/vulkan"]
serialize = ["bevy_mesh/serialize"]
shader_format_spirv = [
"bevy_shader/shader_format_spirv",
"wgpu/spirv",
]
spirv_shader_passthrough = ["wgpu/spirv"]
statically-linked-dxc = ["wgpu/static-dxc"]
trace = ["profiling"]
tracing-tracy = ["dep:tracy-client"]
vulkan-portability = ["wgpu/vulkan-portability"]
webgl = ["wgpu/webgl"]
webgpu = ["wgpu/webgpu"]
[lib]
name = "bevy_render"
path = "src/lib.rs"
[dependencies.async-channel]
version = "2.3.0"
[dependencies.bevy_app]
version = "0.18.0"
[dependencies.bevy_asset]
version = "0.18.0"
[dependencies.bevy_camera]
version = "0.18.0"
[dependencies.bevy_color]
version = "0.18.0"
features = [
"serialize",
"wgpu-types",
]
[dependencies.bevy_derive]
version = "0.18.0"
[dependencies.bevy_diagnostic]
version = "0.18.0"
[dependencies.bevy_ecs]
version = "0.18.0"
[dependencies.bevy_encase_derive]
version = "0.18.0"
[dependencies.bevy_image]
version = "0.18.0"
[dependencies.bevy_math]
version = "0.18.0"
[dependencies.bevy_mesh]
version = "0.18.0"
[dependencies.bevy_platform]
version = "0.18.0"
features = [
"std",
"serialize",
]
default-features = false
[dependencies.bevy_reflect]
version = "0.18.0"
[dependencies.bevy_render_macros]
version = "0.18.0"
[dependencies.bevy_shader]
version = "0.18.0"
[dependencies.bevy_tasks]
version = "0.18.0"
[dependencies.bevy_time]
version = "0.18.0"
[dependencies.bevy_transform]
version = "0.18.0"
[dependencies.bevy_utils]
version = "0.18.0"
[dependencies.bevy_window]
version = "0.18.0"
[dependencies.bitflags]
version = "2"
[dependencies.bytemuck]
version = "1.5"
features = [
"derive",
"must_cast",
]
[dependencies.derive_more]
version = "2"
features = ["from"]
default-features = false
[dependencies.downcast-rs]
version = "2"
features = ["std"]
default-features = false
[dependencies.encase]
version = "0.12"
[dependencies.fixedbitset]
version = "0.5"
[dependencies.glam]
version = "0.30.7"
features = [
"std",
"encase",
]
default-features = false
[dependencies.image]
version = "0.25.2"
default-features = false
[dependencies.indexmap]
version = "2"
[dependencies.naga]
version = "27"
features = ["wgsl-in"]
[dependencies.nonmax]
version = "0.5"
[dependencies.offset-allocator]
version = "0.2"
[dependencies.profiling]
version = "1"
features = ["profile-with-tracing"]
optional = true
[dependencies.smallvec]
version = "1"
features = ["const_new"]
default-features = false
[dependencies.thiserror]
version = "2"
default-features = false
[dependencies.tracing]
version = "0.1"
features = ["std"]
default-features = false
[dependencies.tracy-client]
version = "0.18.3"
optional = true
[dependencies.variadics_please]
version = "1.1"
[dependencies.wgpu]
version = "27"
features = [
"wgsl",
"dx12",
"metal",
"vulkan",
"naga-ir",
"fragile-send-sync-non-atomic-wasm",
]
default-features = false
[dev-dependencies.proptest]
version = "1"
[target.'cfg(all(target_arch = "wasm32", target_feature = "atomics"))'.dependencies.send_wrapper]
version = "0.6.0"
[target.'cfg(target_arch = "wasm32")'.dependencies.bevy_app]
version = "0.18.0"
features = ["web"]
default-features = false
[target.'cfg(target_arch = "wasm32")'.dependencies.bevy_platform]
version = "0.18.0"
features = ["web"]
default-features = false
[target.'cfg(target_arch = "wasm32")'.dependencies.bevy_reflect]
version = "0.18.0"
features = ["web"]
default-features = false
[target.'cfg(target_arch = "wasm32")'.dependencies.js-sys]
version = "0.3.83"
[target.'cfg(target_arch = "wasm32")'.dependencies.wasm-bindgen]
version = "0.2"
[target.'cfg(target_arch = "wasm32")'.dependencies.web-sys]
version = "0.3.67"
features = [
"Blob",
"Document",
"Element",
"HtmlElement",
"Node",
"Url",
"Window",
]
[lints.clippy]
alloc_instead_of_core = "warn"
allow_attributes = "warn"
allow_attributes_without_reason = "warn"
doc_markdown = "warn"
manual_let_else = "warn"
match_same_arms = "warn"
needless_lifetimes = "allow"
nonstandard_macro_braces = "warn"
print_stderr = "warn"
print_stdout = "warn"
ptr_as_ptr = "warn"
ptr_cast_constness = "warn"
redundant_closure_for_method_calls = "warn"
redundant_else = "warn"
ref_as_ptr = "warn"
semicolon_if_nothing_returned = "warn"
std_instead_of_alloc = "warn"
std_instead_of_core = "warn"
too_long_first_doc_paragraph = "allow"
too_many_arguments = "allow"
type_complexity = "allow"
undocumented_unsafe_blocks = "warn"
unwrap_or_default = "warn"
[lints.rust]
missing_docs = "warn"
unsafe_code = "deny"
unsafe_op_in_unsafe_fn = "warn"
unused_qualifications = "warn"
[lints.rust.unexpected_cfgs]
level = "warn"
priority = 0
check-cfg = ["cfg(docsrs_dep)"]

View File

@ -1,155 +0,0 @@
[package]
name = "bevy_render"
version = "0.18.1"
edition = "2024"
description = "Provides rendering functionality for Bevy Engine"
homepage = "https://bevy.org"
repository = "https://github.com/bevyengine/bevy"
license = "MIT OR Apache-2.0"
keywords = ["bevy"]
[features]
# Bevy users should _never_ turn this feature on.
#
# Bevy/wgpu developers can turn this feature on to test a newer version of wgpu without needing to also update naga_oil.
#
# When turning this feature on, you can add the following to bevy/Cargo.toml (not this file), and then run `cargo update`:
# [patch.crates-io]
# wgpu = { git = "https://github.com/gfx-rs/wgpu", rev = "..." }
# wgpu-core = { git = "https://github.com/gfx-rs/wgpu", rev = "..." }
# wgpu-hal = { git = "https://github.com/gfx-rs/wgpu", rev = "..." }
# wgpu-types = { git = "https://github.com/gfx-rs/wgpu", rev = "..." }
decoupled_naga = ["bevy_shader/decoupled_naga"]
multi_threaded = ["bevy_tasks/multi_threaded"]
morph = ["bevy_mesh/morph"]
shader_format_spirv = ["bevy_shader/shader_format_spirv", "wgpu/spirv"]
# Enable SPIR-V shader passthrough
spirv_shader_passthrough = ["wgpu/spirv"]
# Statically linked DXC shader compiler for DirectX 12
# TODO: When wgpu switches to DirectX 12 instead of Vulkan by default on windows, make this a default feature
statically-linked-dxc = ["wgpu/static-dxc"]
# Forces the wgpu instance to be initialized using the raw Vulkan HAL, enabling additional configuration
raw_vulkan_init = ["wgpu/vulkan"]
trace = ["profiling"]
tracing-tracy = ["dep:tracy-client"]
ci_limits = []
webgl = ["wgpu/webgl"]
webgpu = ["wgpu/webgpu"]
vulkan-portability = ["wgpu/vulkan-portability"]
gles = ["wgpu/gles"]
detailed_trace = []
## Adds serialization support through `serde`.
serialize = ["bevy_mesh/serialize"]
[dependencies]
# bevy
bevy_app = { path = "../bevy_app", version = "0.18.0" }
bevy_asset = { path = "../bevy_asset", version = "0.18.0" }
bevy_color = { path = "../bevy_color", version = "0.18.0", features = [
"serialize",
"wgpu-types",
] }
bevy_derive = { path = "../bevy_derive", version = "0.18.0" }
bevy_diagnostic = { path = "../bevy_diagnostic", version = "0.18.0" }
bevy_ecs = { path = "../bevy_ecs", version = "0.18.0" }
bevy_encase_derive = { path = "../bevy_encase_derive", version = "0.18.0" }
bevy_math = { path = "../bevy_math", version = "0.18.0" }
bevy_reflect = { path = "../bevy_reflect", version = "0.18.0" }
bevy_render_macros = { path = "macros", version = "0.18.0" }
bevy_time = { path = "../bevy_time", version = "0.18.0" }
bevy_transform = { path = "../bevy_transform", version = "0.18.0" }
bevy_window = { path = "../bevy_window", version = "0.18.0" }
bevy_utils = { path = "../bevy_utils", version = "0.18.0" }
bevy_tasks = { path = "../bevy_tasks", version = "0.18.0" }
bevy_image = { path = "../bevy_image", version = "0.18.0" }
bevy_mesh = { path = "../bevy_mesh", version = "0.18.0" }
bevy_camera = { path = "../bevy_camera", version = "0.18.0" }
bevy_shader = { path = "../bevy_shader", version = "0.18.0" }
bevy_platform = { path = "../bevy_platform", version = "0.18.0", default-features = false, features = [
"std",
"serialize",
] }
# rendering
image = { version = "0.25.2", default-features = false }
# misc
# `fragile-send-sync-non-atomic-wasm` feature means we can't use Wasm threads for rendering
# It is enabled for now to avoid having to do a significant overhaul of the renderer just for wasm.
# When the 'atomics' feature is enabled `fragile-send-sync-non-atomic` does nothing
# and Bevy instead wraps `wgpu` types to verify they are not used off their origin thread.
wgpu = { version = "27", default-features = false, features = [
"wgsl",
"dx12",
"metal",
"vulkan",
"naga-ir",
"fragile-send-sync-non-atomic-wasm",
] }
naga = { version = "27", features = ["wgsl-in"] }
bytemuck = { version = "1.5", features = ["derive", "must_cast"] }
downcast-rs = { version = "2", default-features = false, features = ["std"] }
thiserror = { version = "2", default-features = false }
derive_more = { version = "2", default-features = false, features = ["from"] }
encase = "0.12"
glam = { version = "0.30.7", default-features = false, features = [
"std",
"encase",
] }
# For wgpu profiling using tracing. Use `RUST_LOG=info` to also capture the wgpu spans.
profiling = { version = "1", features = [
"profile-with-tracing",
], optional = true }
async-channel = "2.3.0"
nonmax = "0.5"
smallvec = { version = "1", default-features = false, features = ["const_new"] }
offset-allocator = "0.2"
variadics_please = "1.1"
tracing = { version = "0.1", default-features = false, features = ["std"] }
tracy-client = { version = "0.18.3", optional = true }
indexmap = { version = "2" }
fixedbitset = { version = "0.5" }
bitflags = "2"
[target.'cfg(all(target_arch = "wasm32", target_feature = "atomics"))'.dependencies]
send_wrapper = { version = "0.6.0" }
[dev-dependencies]
proptest = "1"
[target.'cfg(target_arch = "wasm32")'.dependencies]
js-sys = "0.3.83"
web-sys = { version = "0.3.67", features = [
'Blob',
'Document',
'Element',
'HtmlElement',
'Node',
'Url',
'Window',
] }
wasm-bindgen = "0.2"
# TODO: Assuming all wasm builds are for the browser. Require `no_std` support to break assumption.
bevy_app = { path = "../bevy_app", version = "0.18.0", default-features = false, features = [
"web",
] }
bevy_platform = { path = "../bevy_platform", version = "0.18.0", default-features = false, features = [
"web",
] }
bevy_reflect = { path = "../bevy_reflect", version = "0.18.0", default-features = false, features = [
"web",
] }
[lints]
workspace = true
[package.metadata.docs.rs]
rustdoc-args = ["-Zunstable-options", "--generate-link-to-definition"]
all-features = true

View File

@ -1,7 +0,0 @@
# Bevy Render
[![License](https://img.shields.io/badge/license-MIT%2FApache-blue.svg)](https://github.com/bevyengine/bevy#license)
[![Crates.io](https://img.shields.io/crates/v/bevy_render.svg)](https://crates.io/crates/bevy_render)
[![Downloads](https://img.shields.io/crates/d/bevy_render.svg)](https://crates.io/crates/bevy_render)
[![Docs](https://docs.rs/bevy_render/badge.svg)](https://docs.rs/bevy_render/latest/bevy_render/)
[![Discord](https://img.shields.io/discord/691052431525675048.svg?label=&logo=discord&logoColor=ffffff&color=7389D8&labelColor=6A7EC2)](https://discord.gg/bevy)

View File

@ -1,62 +0,0 @@
use bevy_reflect::{std_traits::ReflectDefault, Reflect};
// TODO: add discussion about performance.
/// Sets how a material's base color alpha channel is used for transparency.
#[derive(Debug, Default, Reflect, Copy, Clone, PartialEq)]
#[reflect(Default, Debug, Clone)]
pub enum AlphaMode {
/// Base color alpha values are overridden to be fully opaque (1.0).
#[default]
Opaque,
/// Reduce transparency to fully opaque or fully transparent
/// based on a threshold.
///
/// Compares the base color alpha value to the specified threshold.
/// If the value is below the threshold,
/// considers the color to be fully transparent (alpha is set to 0.0).
/// If it is equal to or above the threshold,
/// considers the color to be fully opaque (alpha is set to 1.0).
Mask(f32),
/// The base color alpha value defines the opacity of the color.
/// Standard alpha-blending is used to blend the fragment's color
/// with the color behind it.
Blend,
/// Similar to [`AlphaMode::Blend`], however assumes RGB channel values are
/// [premultiplied](https://en.wikipedia.org/wiki/Alpha_compositing#Straight_versus_premultiplied).
///
/// For otherwise constant RGB values, behaves more like [`AlphaMode::Blend`] for
/// alpha values closer to 1.0, and more like [`AlphaMode::Add`] for
/// alpha values closer to 0.0.
///
/// Can be used to avoid “border” or “outline” artifacts that can occur
/// when using plain alpha-blended textures.
Premultiplied,
/// Spreads the fragment out over a hardware-dependent number of sample
/// locations proportional to the alpha value. This requires multisample
/// antialiasing; if MSAA isn't on, this is identical to
/// [`AlphaMode::Mask`] with a value of 0.5.
///
/// Alpha to coverage provides improved performance and better visual
/// fidelity over [`AlphaMode::Blend`], as Bevy doesn't have to sort objects
/// when it's in use. It's especially useful for complex transparent objects
/// like foliage.
///
/// [alpha to coverage]: https://en.wikipedia.org/wiki/Alpha_to_coverage
AlphaToCoverage,
/// Combines the color of the fragments with the colors behind them in an
/// additive process, (i.e. like light) producing lighter results.
///
/// Black produces no effect. Alpha values can be used to modulate the result.
///
/// Useful for effects like holograms, ghosts, lasers and other energy beams.
Add,
/// Combines the color of the fragments with the colors behind them in a
/// multiplicative process, (i.e. like pigments) producing darker results.
///
/// White produces no effect. Alpha values can be used to modulate the result.
///
/// Useful for effects like stained glass, window tint film and some colored liquids.
Multiply,
}
impl Eq for AlphaMode {}

File diff suppressed because it is too large Load Diff

View File

@ -1,225 +0,0 @@
use bevy_ecs::{
component::Component,
entity::Entity,
system::{ResMut, SystemParam, SystemParamItem},
};
use bytemuck::Pod;
use gpu_preprocessing::UntypedPhaseIndirectParametersBuffers;
use nonmax::NonMaxU32;
use crate::{
render_phase::{
BinnedPhaseItem, CachedRenderPipelinePhaseItem, DrawFunctionId, PhaseItemExtraIndex,
SortedPhaseItem, SortedRenderPhase, ViewBinnedRenderPhases,
},
render_resource::{CachedRenderPipelineId, GpuArrayBufferable},
sync_world::MainEntity,
};
pub mod gpu_preprocessing;
pub mod no_gpu_preprocessing;
/// Add this component to mesh entities to disable automatic batching
#[derive(Component, Default, Clone, Copy)]
pub struct NoAutomaticBatching;
/// Data necessary to be equal for two draw commands to be mergeable
///
/// This is based on the following assumptions:
/// - Only entities with prepared assets (pipelines, materials, meshes) are
/// queued to phases
/// - View bindings are constant across a phase for a given draw function as
/// phases are per-view
/// - `batch_and_prepare_render_phase` is the only system that performs this
/// batching and has sole responsibility for preparing the per-object data.
/// As such the mesh binding and dynamic offsets are assumed to only be
/// variable as a result of the `batch_and_prepare_render_phase` system, e.g.
/// due to having to split data across separate uniform bindings within the
/// same buffer due to the maximum uniform buffer binding size.
#[derive(PartialEq)]
struct BatchMeta<T: PartialEq> {
/// The pipeline id encompasses all pipeline configuration including vertex
/// buffers and layouts, shaders and their specializations, bind group
/// layouts, etc.
pipeline_id: CachedRenderPipelineId,
/// The draw function id defines the `RenderCommands` that are called to
/// set the pipeline and bindings, and make the draw command
draw_function_id: DrawFunctionId,
dynamic_offset: Option<NonMaxU32>,
user_data: T,
}
impl<T: PartialEq> BatchMeta<T> {
fn new(item: &impl CachedRenderPipelinePhaseItem, user_data: T) -> Self {
BatchMeta {
pipeline_id: item.cached_pipeline(),
draw_function_id: item.draw_function(),
dynamic_offset: match item.extra_index() {
PhaseItemExtraIndex::DynamicOffset(dynamic_offset) => {
NonMaxU32::new(dynamic_offset)
}
PhaseItemExtraIndex::None | PhaseItemExtraIndex::IndirectParametersIndex { .. } => {
None
}
},
user_data,
}
}
}
/// A trait to support getting data used for batching draw commands via phase
/// items.
///
/// This is a simple version that only allows for sorting, not binning, as well
/// as only CPU processing, not GPU preprocessing. For these fancier features,
/// see [`GetFullBatchData`].
pub trait GetBatchData {
/// The system parameters [`GetBatchData::get_batch_data`] needs in
/// order to compute the batch data.
type Param: SystemParam + 'static;
/// Data used for comparison between phase items. If the pipeline id, draw
/// function id, per-instance data buffer dynamic offset and this data
/// matches, the draws can be batched.
type CompareData: PartialEq;
/// The per-instance data to be inserted into the
/// [`crate::render_resource::GpuArrayBuffer`] containing these data for all
/// instances.
type BufferData: GpuArrayBufferable + Sync + Send + 'static;
/// Get the per-instance data to be inserted into the
/// [`crate::render_resource::GpuArrayBuffer`]. If the instance can be
/// batched, also return the data used for comparison when deciding whether
/// draws can be batched, else return None for the `CompareData`.
///
/// This is only called when building instance data on CPU. In the GPU
/// instance data building path, we use
/// [`GetFullBatchData::get_index_and_compare_data`] instead.
fn get_batch_data(
param: &SystemParamItem<Self::Param>,
query_item: (Entity, MainEntity),
) -> Option<(Self::BufferData, Option<Self::CompareData>)>;
}
/// A trait to support getting data used for batching draw commands via phase
/// items.
///
/// This version allows for binning and GPU preprocessing.
pub trait GetFullBatchData: GetBatchData {
/// The per-instance data that was inserted into the
/// [`crate::render_resource::BufferVec`] during extraction.
type BufferInputData: Pod + Default + Sync + Send;
/// Get the per-instance data to be inserted into the
/// [`crate::render_resource::GpuArrayBuffer`].
///
/// This is only called when building uniforms on CPU. In the GPU instance
/// buffer building path, we use
/// [`GetFullBatchData::get_index_and_compare_data`] instead.
fn get_binned_batch_data(
param: &SystemParamItem<Self::Param>,
query_item: MainEntity,
) -> Option<Self::BufferData>;
/// Returns the index of the [`GetFullBatchData::BufferInputData`] that the
/// GPU preprocessing phase will use.
///
/// We already inserted the [`GetFullBatchData::BufferInputData`] during the
/// extraction phase before we got here, so this function shouldn't need to
/// look up any render data. If CPU instance buffer building is in use, this
/// function will never be called.
fn get_index_and_compare_data(
param: &SystemParamItem<Self::Param>,
query_item: MainEntity,
) -> Option<(NonMaxU32, Option<Self::CompareData>)>;
/// Returns the index of the [`GetFullBatchData::BufferInputData`] that the
/// GPU preprocessing phase will use.
///
/// We already inserted the [`GetFullBatchData::BufferInputData`] during the
/// extraction phase before we got here, so this function shouldn't need to
/// look up any render data.
///
/// This function is currently only called for unbatchable entities when GPU
/// instance buffer building is in use. For batchable entities, the uniform
/// index is written during queuing (e.g. in `queue_material_meshes`). In
/// the case of CPU instance buffer building, the CPU writes the uniforms,
/// so there's no index to return.
fn get_binned_index(
param: &SystemParamItem<Self::Param>,
query_item: MainEntity,
) -> Option<NonMaxU32>;
/// Writes the [`gpu_preprocessing::IndirectParametersGpuMetadata`]
/// necessary to draw this batch into the given metadata buffer at the given
/// index.
///
/// This is only used if GPU culling is enabled (which requires GPU
/// preprocessing).
///
/// * `indexed` is true if the mesh is indexed or false if it's non-indexed.
///
/// * `base_output_index` is the index of the first mesh instance in this
/// batch in the `MeshUniform` output buffer.
///
/// * `batch_set_index` is the index of the batch set in the
/// [`gpu_preprocessing::IndirectBatchSet`] buffer, if this batch belongs to
/// a batch set.
///
/// * `indirect_parameters_buffers` is the buffer in which to write the
/// metadata.
///
/// * `indirect_parameters_offset` is the index in that buffer at which to
/// write the metadata.
fn write_batch_indirect_parameters_metadata(
indexed: bool,
base_output_index: u32,
batch_set_index: Option<NonMaxU32>,
indirect_parameters_buffers: &mut UntypedPhaseIndirectParametersBuffers,
indirect_parameters_offset: u32,
);
}
/// Sorts a render phase that uses bins.
pub fn sort_binned_render_phase<BPI>(mut phases: ResMut<ViewBinnedRenderPhases<BPI>>)
where
BPI: BinnedPhaseItem,
{
for phase in phases.values_mut() {
phase.multidrawable_meshes.sort_unstable_keys();
phase.batchable_meshes.sort_unstable_keys();
phase.unbatchable_meshes.sort_unstable_keys();
phase.non_mesh_items.sort_unstable_keys();
}
}
/// Batches the items in a sorted render phase.
///
/// This means comparing metadata needed to draw each phase item and trying to
/// combine the draws into a batch.
///
/// This is common code factored out from
/// [`gpu_preprocessing::batch_and_prepare_sorted_render_phase`] and
/// [`no_gpu_preprocessing::batch_and_prepare_sorted_render_phase`].
fn batch_and_prepare_sorted_render_phase<I, GBD>(
phase: &mut SortedRenderPhase<I>,
mut process_item: impl FnMut(&mut I) -> Option<GBD::CompareData>,
) where
I: CachedRenderPipelinePhaseItem + SortedPhaseItem,
GBD: GetBatchData,
{
let items = phase.items.iter_mut().map(|item| {
let batch_data = match process_item(item) {
Some(compare_data) if I::AUTOMATIC_BATCHING => Some(BatchMeta::new(item, compare_data)),
_ => None,
};
(item.batch_range_mut(), batch_data)
});
items.reduce(|(start_range, prev_batch_meta), (range, batch_meta)| {
if batch_meta.is_some() && prev_batch_meta == batch_meta {
start_range.end = range.end;
(start_range, prev_batch_meta)
} else {
(range, batch_meta)
}
});
}

View File

@ -1,182 +0,0 @@
//! Batching functionality when GPU preprocessing isn't in use.
use bevy_derive::{Deref, DerefMut};
use bevy_ecs::entity::Entity;
use bevy_ecs::resource::Resource;
use bevy_ecs::system::{Res, ResMut, StaticSystemParam};
use smallvec::{smallvec, SmallVec};
use tracing::error;
use wgpu::{BindingResource, Limits};
use crate::{
render_phase::{
BinnedPhaseItem, BinnedRenderPhaseBatch, BinnedRenderPhaseBatchSets,
CachedRenderPipelinePhaseItem, PhaseItemExtraIndex, SortedPhaseItem,
ViewBinnedRenderPhases, ViewSortedRenderPhases,
},
render_resource::{GpuArrayBuffer, GpuArrayBufferable},
renderer::{RenderDevice, RenderQueue},
};
use super::{GetBatchData, GetFullBatchData};
/// The GPU buffers holding the data needed to render batches.
///
/// For example, in the 3D PBR pipeline this holds `MeshUniform`s, which are the
/// `BD` type parameter in that mode.
#[derive(Resource, Deref, DerefMut)]
pub struct BatchedInstanceBuffer<BD>(pub GpuArrayBuffer<BD>)
where
BD: GpuArrayBufferable + Sync + Send + 'static;
impl<BD> BatchedInstanceBuffer<BD>
where
BD: GpuArrayBufferable + Sync + Send + 'static,
{
/// Creates a new buffer.
pub fn new(limits: &Limits) -> Self {
BatchedInstanceBuffer(GpuArrayBuffer::new(limits))
}
/// Returns the binding of the buffer that contains the per-instance data.
///
/// If we're in the GPU instance buffer building mode, this buffer needs to
/// be filled in via a compute shader.
pub fn instance_data_binding(&self) -> Option<BindingResource<'_>> {
self.binding()
}
}
/// A system that clears out the [`BatchedInstanceBuffer`] for the frame.
///
/// This needs to run before the CPU batched instance buffers are used.
pub fn clear_batched_cpu_instance_buffers<GBD>(
cpu_batched_instance_buffer: Option<ResMut<BatchedInstanceBuffer<GBD::BufferData>>>,
) where
GBD: GetBatchData,
{
if let Some(mut cpu_batched_instance_buffer) = cpu_batched_instance_buffer {
cpu_batched_instance_buffer.clear();
}
}
/// Batch the items in a sorted render phase, when GPU instance buffer building
/// isn't in use. This means comparing metadata needed to draw each phase item
/// and trying to combine the draws into a batch.
pub fn batch_and_prepare_sorted_render_phase<I, GBD>(
batched_instance_buffer: ResMut<BatchedInstanceBuffer<GBD::BufferData>>,
mut phases: ResMut<ViewSortedRenderPhases<I>>,
param: StaticSystemParam<GBD::Param>,
) where
I: CachedRenderPipelinePhaseItem + SortedPhaseItem,
GBD: GetBatchData,
{
let system_param_item = param.into_inner();
// We only process CPU-built batch data in this function.
let batched_instance_buffer = batched_instance_buffer.into_inner();
for phase in phases.values_mut() {
super::batch_and_prepare_sorted_render_phase::<I, GBD>(phase, |item| {
let (buffer_data, compare_data) =
GBD::get_batch_data(&system_param_item, (item.entity(), item.main_entity()))?;
let buffer_index = batched_instance_buffer.push(buffer_data);
let index = buffer_index.index;
let (batch_range, extra_index) = item.batch_range_and_extra_index_mut();
*batch_range = index..index + 1;
*extra_index = PhaseItemExtraIndex::maybe_dynamic_offset(buffer_index.dynamic_offset);
compare_data
});
}
}
/// Creates batches for a render phase that uses bins, when GPU batch data
/// building isn't in use.
pub fn batch_and_prepare_binned_render_phase<BPI, GFBD>(
gpu_array_buffer: ResMut<BatchedInstanceBuffer<GFBD::BufferData>>,
mut phases: ResMut<ViewBinnedRenderPhases<BPI>>,
param: StaticSystemParam<GFBD::Param>,
) where
BPI: BinnedPhaseItem,
GFBD: GetFullBatchData,
{
let gpu_array_buffer = gpu_array_buffer.into_inner();
let system_param_item = param.into_inner();
for phase in phases.values_mut() {
// Prepare batchables.
for bin in phase.batchable_meshes.values_mut() {
let mut batch_set: SmallVec<[BinnedRenderPhaseBatch; 1]> = smallvec![];
for main_entity in bin.entities().keys() {
let Some(buffer_data) =
GFBD::get_binned_batch_data(&system_param_item, *main_entity)
else {
continue;
};
let instance = gpu_array_buffer.push(buffer_data);
// If the dynamic offset has changed, flush the batch.
//
// This is the only time we ever have more than one batch per
// bin. Note that dynamic offsets are only used on platforms
// with no storage buffers.
if !batch_set.last().is_some_and(|batch| {
batch.instance_range.end == instance.index
&& batch.extra_index
== PhaseItemExtraIndex::maybe_dynamic_offset(instance.dynamic_offset)
}) {
batch_set.push(BinnedRenderPhaseBatch {
representative_entity: (Entity::PLACEHOLDER, *main_entity),
instance_range: instance.index..instance.index,
extra_index: PhaseItemExtraIndex::maybe_dynamic_offset(
instance.dynamic_offset,
),
});
}
if let Some(batch) = batch_set.last_mut() {
batch.instance_range.end = instance.index + 1;
}
}
match phase.batch_sets {
BinnedRenderPhaseBatchSets::DynamicUniforms(ref mut batch_sets) => {
batch_sets.push(batch_set);
}
BinnedRenderPhaseBatchSets::Direct(_)
| BinnedRenderPhaseBatchSets::MultidrawIndirect { .. } => {
error!(
"Dynamic uniform batch sets should be used when GPU preprocessing is off"
);
}
}
}
// Prepare unbatchables.
for unbatchables in phase.unbatchable_meshes.values_mut() {
for main_entity in unbatchables.entities.keys() {
let Some(buffer_data) =
GFBD::get_binned_batch_data(&system_param_item, *main_entity)
else {
continue;
};
let instance = gpu_array_buffer.push(buffer_data);
unbatchables.buffer_indices.add(instance.into());
}
}
}
}
/// Writes the instance buffer data to the GPU.
pub fn write_batched_instance_buffer<GBD>(
render_device: Res<RenderDevice>,
render_queue: Res<RenderQueue>,
mut cpu_batched_instance_buffer: ResMut<BatchedInstanceBuffer<GBD::BufferData>>,
) where
GBD: GetBatchData,
{
cpu_batched_instance_buffer.write_buffer(&render_device, &render_queue);
}

View File

@ -1,37 +0,0 @@
// Defines the common arrays used to access bindless resources.
//
// This need to be kept up to date with the `BINDING_NUMBERS` table in
// `bindless.rs`.
//
// You access these by indexing into the bindless index table, and from there
// indexing into the appropriate binding array. For example, to access the base
// color texture of a `StandardMaterial` in bindless mode, write
// `bindless_textures_2d[materials[slot].base_color_texture]`, where
// `materials` is the bindless index table and `slot` is the index into that
// table (which can be found in the `Mesh`).
#define_import_path bevy_render::bindless
#ifdef BINDLESS
// Binding 0 is the bindless index table.
// Filtering samplers.
@group(#{MATERIAL_BIND_GROUP}) @binding(1) var bindless_samplers_filtering: binding_array<sampler>;
// Non-filtering samplers (nearest neighbor).
@group(#{MATERIAL_BIND_GROUP}) @binding(2) var bindless_samplers_non_filtering: binding_array<sampler>;
// Comparison samplers (typically for shadow mapping).
@group(#{MATERIAL_BIND_GROUP}) @binding(3) var bindless_samplers_comparison: binding_array<sampler>;
// 1D textures.
@group(#{MATERIAL_BIND_GROUP}) @binding(4) var bindless_textures_1d: binding_array<texture_1d<f32>>;
// 2D textures.
@group(#{MATERIAL_BIND_GROUP}) @binding(5) var bindless_textures_2d: binding_array<texture_2d<f32>>;
// 2D array textures.
@group(#{MATERIAL_BIND_GROUP}) @binding(6) var bindless_textures_2d_array: binding_array<texture_2d_array<f32>>;
// 3D textures.
@group(#{MATERIAL_BIND_GROUP}) @binding(7) var bindless_textures_3d: binding_array<texture_3d<f32>>;
// Cubemap textures.
@group(#{MATERIAL_BIND_GROUP}) @binding(8) var bindless_textures_cube: binding_array<texture_cube<f32>>;
// Cubemap array textures.
@group(#{MATERIAL_BIND_GROUP}) @binding(9) var bindless_textures_cube_array: binding_array<texture_cube_array<f32>>;
#endif // BINDLESS

View File

@ -1,702 +0,0 @@
use crate::{
batching::gpu_preprocessing::{GpuPreprocessingMode, GpuPreprocessingSupport},
extract_component::{ExtractComponent, ExtractComponentPlugin},
extract_resource::{ExtractResource, ExtractResourcePlugin},
render_asset::RenderAssets,
render_graph::{CameraDriverNode, InternedRenderSubGraph, RenderGraph, RenderSubGraph},
render_resource::TextureView,
sync_world::{RenderEntity, SyncToRenderWorld},
texture::{GpuImage, ManualTextureViews},
view::{
ColorGrading, ExtractedView, ExtractedWindows, Hdr, Msaa, NoIndirectDrawing,
RenderVisibleEntities, RetainedViewEntity, ViewUniformOffset,
},
Extract, ExtractSchedule, Render, RenderApp, RenderSystems,
};
use bevy_app::{App, Plugin, PostStartup, PostUpdate};
use bevy_asset::{AssetEvent, AssetEventSystems, AssetId, Assets};
use bevy_camera::{
primitives::Frustum,
visibility::{self, RenderLayers, VisibleEntities},
Camera, Camera2d, Camera3d, CameraMainTextureUsages, CameraOutputMode, CameraUpdateSystems,
ClearColor, ClearColorConfig, Exposure, ManualTextureViewHandle, MsaaWriteback,
NormalizedRenderTarget, Projection, RenderTarget, RenderTargetInfo, Viewport,
};
use bevy_derive::{Deref, DerefMut};
use bevy_ecs::{
change_detection::DetectChanges,
component::Component,
entity::{ContainsEntity, Entity},
error::BevyError,
lifecycle::HookContext,
message::MessageReader,
prelude::With,
query::{Has, QueryItem},
reflect::ReflectComponent,
resource::Resource,
schedule::IntoScheduleConfigs,
system::{Commands, Query, Res, ResMut},
world::DeferredWorld,
};
use bevy_image::Image;
use bevy_math::{uvec2, vec2, Mat4, URect, UVec2, UVec4, Vec2};
use bevy_platform::collections::{HashMap, HashSet};
use bevy_reflect::prelude::*;
use bevy_transform::components::GlobalTransform;
use bevy_window::{PrimaryWindow, Window, WindowCreated, WindowResized, WindowScaleFactorChanged};
use tracing::warn;
use wgpu::TextureFormat;
#[derive(Default)]
pub struct CameraPlugin;
impl Plugin for CameraPlugin {
fn build(&self, app: &mut App) {
app.register_required_components::<Camera, Msaa>()
.register_required_components::<Camera, SyncToRenderWorld>()
.register_required_components::<Camera3d, ColorGrading>()
.register_required_components::<Camera3d, Exposure>()
.add_plugins((
ExtractResourcePlugin::<ClearColor>::default(),
ExtractComponentPlugin::<CameraMainTextureUsages>::default(),
))
.add_systems(PostStartup, camera_system.in_set(CameraUpdateSystems))
.add_systems(
PostUpdate,
camera_system
.in_set(CameraUpdateSystems)
.before(AssetEventSystems)
.before(visibility::update_frusta),
);
app.world_mut()
.register_component_hooks::<Camera>()
.on_add(warn_on_no_render_graph);
if let Some(render_app) = app.get_sub_app_mut(RenderApp) {
render_app
.init_resource::<SortedCameras>()
.add_systems(ExtractSchedule, extract_cameras)
.add_systems(Render, sort_cameras.in_set(RenderSystems::ManageViews));
let camera_driver_node = CameraDriverNode::new(render_app.world_mut());
let mut render_graph = render_app.world_mut().resource_mut::<RenderGraph>();
render_graph.add_node(crate::graph::CameraDriverLabel, camera_driver_node);
}
}
}
fn warn_on_no_render_graph(world: DeferredWorld, HookContext { entity, caller, .. }: HookContext) {
if !world.entity(entity).contains::<CameraRenderGraph>() {
warn!("{}Entity {entity} has a `Camera` component, but it doesn't have a render graph configured. Usually, adding a `Camera2d` or `Camera3d` component will work.
However, you may instead need to enable `bevy_core_pipeline`, or may want to manually add a `CameraRenderGraph` component to create a custom render graph.", caller.map(|location|format!("{location}: ")).unwrap_or_default());
}
}
impl ExtractResource for ClearColor {
type Source = Self;
fn extract_resource(source: &Self::Source) -> Self {
source.clone()
}
}
impl ExtractComponent for CameraMainTextureUsages {
type QueryData = &'static Self;
type QueryFilter = ();
type Out = Self;
fn extract_component(item: QueryItem<Self::QueryData>) -> Option<Self::Out> {
Some(*item)
}
}
impl ExtractComponent for Camera2d {
type QueryData = &'static Self;
type QueryFilter = With<Camera>;
type Out = Self;
fn extract_component(item: QueryItem<Self::QueryData>) -> Option<Self::Out> {
Some(item.clone())
}
}
impl ExtractComponent for Camera3d {
type QueryData = &'static Self;
type QueryFilter = With<Camera>;
type Out = Self;
fn extract_component(item: QueryItem<Self::QueryData>) -> Option<Self::Out> {
Some(item.clone())
}
}
/// Configures the [`RenderGraph`] name assigned to be run for a given [`Camera`] entity.
#[derive(Component, Debug, Deref, DerefMut, Reflect, Clone)]
#[reflect(opaque)]
#[reflect(Component, Debug, Clone)]
pub struct CameraRenderGraph(InternedRenderSubGraph);
impl CameraRenderGraph {
/// Creates a new [`CameraRenderGraph`] from any string-like type.
#[inline]
pub fn new<T: RenderSubGraph>(name: T) -> Self {
Self(name.intern())
}
/// Sets the graph name.
#[inline]
pub fn set<T: RenderSubGraph>(&mut self, name: T) {
self.0 = name.intern();
}
}
pub trait NormalizedRenderTargetExt {
fn get_texture_view<'a>(
&self,
windows: &'a ExtractedWindows,
images: &'a RenderAssets<GpuImage>,
manual_texture_views: &'a ManualTextureViews,
) -> Option<&'a TextureView>;
/// Retrieves the [`TextureFormat`] of this render target, if it exists.
fn get_texture_view_format<'a>(
&self,
windows: &'a ExtractedWindows,
images: &'a RenderAssets<GpuImage>,
manual_texture_views: &'a ManualTextureViews,
) -> Option<TextureFormat>;
fn get_render_target_info<'a>(
&self,
resolutions: impl IntoIterator<Item = (Entity, &'a Window)>,
images: &Assets<Image>,
manual_texture_views: &ManualTextureViews,
) -> Result<RenderTargetInfo, MissingRenderTargetInfoError>;
// Check if this render target is contained in the given changed windows or images.
fn is_changed(
&self,
changed_window_ids: &HashSet<Entity>,
changed_image_handles: &HashSet<&AssetId<Image>>,
) -> bool;
}
impl NormalizedRenderTargetExt for NormalizedRenderTarget {
fn get_texture_view<'a>(
&self,
windows: &'a ExtractedWindows,
images: &'a RenderAssets<GpuImage>,
manual_texture_views: &'a ManualTextureViews,
) -> Option<&'a TextureView> {
match self {
NormalizedRenderTarget::Window(window_ref) => windows
.get(&window_ref.entity())
.and_then(|window| window.swap_chain_texture_view.as_ref()),
NormalizedRenderTarget::Image(image_target) => images
.get(&image_target.handle)
.map(|image| &image.texture_view),
NormalizedRenderTarget::TextureView(id) => {
manual_texture_views.get(id).map(|tex| &tex.texture_view)
}
NormalizedRenderTarget::None { .. } => None,
}
}
/// Retrieves the texture view's [`TextureFormat`] of this render target, if it exists.
fn get_texture_view_format<'a>(
&self,
windows: &'a ExtractedWindows,
images: &'a RenderAssets<GpuImage>,
manual_texture_views: &'a ManualTextureViews,
) -> Option<TextureFormat> {
match self {
NormalizedRenderTarget::Window(window_ref) => windows
.get(&window_ref.entity())
.and_then(|window| window.swap_chain_texture_view_format),
NormalizedRenderTarget::Image(image_target) => images
.get(&image_target.handle)
.map(|image| image.texture_view_format.unwrap_or(image.texture_format)),
NormalizedRenderTarget::TextureView(id) => {
manual_texture_views.get(id).map(|tex| tex.view_format)
}
NormalizedRenderTarget::None { .. } => None,
}
}
fn get_render_target_info<'a>(
&self,
resolutions: impl IntoIterator<Item = (Entity, &'a Window)>,
images: &Assets<Image>,
manual_texture_views: &ManualTextureViews,
) -> Result<RenderTargetInfo, MissingRenderTargetInfoError> {
match self {
NormalizedRenderTarget::Window(window_ref) => resolutions
.into_iter()
.find(|(entity, _)| *entity == window_ref.entity())
.map(|(_, window)| RenderTargetInfo {
physical_size: window.physical_size(),
scale_factor: window.resolution.scale_factor(),
})
.ok_or(MissingRenderTargetInfoError::Window {
window: window_ref.entity(),
}),
NormalizedRenderTarget::Image(image_target) => images
.get(&image_target.handle)
.map(|image| RenderTargetInfo {
physical_size: image.size(),
scale_factor: image_target.scale_factor,
})
.ok_or(MissingRenderTargetInfoError::Image {
image: image_target.handle.id(),
}),
NormalizedRenderTarget::TextureView(id) => manual_texture_views
.get(id)
.map(|tex| RenderTargetInfo {
physical_size: tex.size,
scale_factor: 1.0,
})
.ok_or(MissingRenderTargetInfoError::TextureView { texture_view: *id }),
NormalizedRenderTarget::None { width, height } => Ok(RenderTargetInfo {
physical_size: uvec2(*width, *height),
scale_factor: 1.0,
}),
}
}
// Check if this render target is contained in the given changed windows or images.
fn is_changed(
&self,
changed_window_ids: &HashSet<Entity>,
changed_image_handles: &HashSet<&AssetId<Image>>,
) -> bool {
match self {
NormalizedRenderTarget::Window(window_ref) => {
changed_window_ids.contains(&window_ref.entity())
}
NormalizedRenderTarget::Image(image_target) => {
changed_image_handles.contains(&image_target.handle.id())
}
NormalizedRenderTarget::TextureView(_) => true,
NormalizedRenderTarget::None { .. } => false,
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum MissingRenderTargetInfoError {
#[error("RenderTarget::Window missing ({window:?}): Make sure the provided entity has a Window component.")]
Window { window: Entity },
#[error("RenderTarget::Image missing ({image:?}): Make sure the Image's usages include RenderAssetUsages::MAIN_WORLD.")]
Image { image: AssetId<Image> },
#[error("RenderTarget::TextureView missing ({texture_view:?}): make sure the texture view handle was not removed.")]
TextureView {
texture_view: ManualTextureViewHandle,
},
}
/// System in charge of updating a [`Camera`] when its window or projection changes.
///
/// The system detects window creation, resize, and scale factor change events to update the camera
/// [`Projection`] if needed.
///
/// ## World Resources
///
/// [`Res<Assets<Image>>`](Assets<Image>) -- For cameras that render to an image, this resource is used to
/// inspect information about the render target. This system will not access any other image assets.
///
/// [`OrthographicProjection`]: bevy_camera::OrthographicProjection
/// [`PerspectiveProjection`]: bevy_camera::PerspectiveProjection
pub fn camera_system(
mut window_resized_reader: MessageReader<WindowResized>,
mut window_created_reader: MessageReader<WindowCreated>,
mut window_scale_factor_changed_reader: MessageReader<WindowScaleFactorChanged>,
mut image_asset_event_reader: MessageReader<AssetEvent<Image>>,
primary_window: Query<Entity, With<PrimaryWindow>>,
windows: Query<(Entity, &Window)>,
images: Res<Assets<Image>>,
manual_texture_views: Res<ManualTextureViews>,
mut cameras: Query<(&mut Camera, &RenderTarget, &mut Projection)>,
) -> Result<(), BevyError> {
let primary_window = primary_window.iter().next();
let mut changed_window_ids = <HashSet<_>>::default();
changed_window_ids.extend(window_created_reader.read().map(|event| event.window));
changed_window_ids.extend(window_resized_reader.read().map(|event| event.window));
let scale_factor_changed_window_ids: HashSet<_> = window_scale_factor_changed_reader
.read()
.map(|event| event.window)
.collect();
changed_window_ids.extend(scale_factor_changed_window_ids.clone());
let changed_image_handles: HashSet<&AssetId<Image>> = image_asset_event_reader
.read()
.filter_map(|event| match event {
AssetEvent::Modified { id } | AssetEvent::Added { id } => Some(id),
_ => None,
})
.collect();
for (mut camera, render_target, mut camera_projection) in &mut cameras {
let mut viewport_size = camera
.viewport
.as_ref()
.map(|viewport| viewport.physical_size);
if let Some(normalized_target) = render_target.normalize(primary_window)
&& (normalized_target.is_changed(&changed_window_ids, &changed_image_handles)
|| camera.is_added()
|| camera_projection.is_changed()
|| camera.computed.old_viewport_size != viewport_size
|| camera.computed.old_sub_camera_view != camera.sub_camera_view)
{
let new_computed_target_info = normalized_target.get_render_target_info(
windows,
&images,
&manual_texture_views,
)?;
// Check for the scale factor changing, and resize the viewport if needed.
// This can happen when the window is moved between monitors with different DPIs.
// Without this, the viewport will take a smaller portion of the window moved to
// a higher DPI monitor.
if normalized_target.is_changed(&scale_factor_changed_window_ids, &HashSet::default())
&& let Some(old_scale_factor) = camera
.computed
.target_info
.as_ref()
.map(|info| info.scale_factor)
{
let resize_factor = new_computed_target_info.scale_factor / old_scale_factor;
if let Some(ref mut viewport) = camera.viewport {
let resize = |vec: UVec2| (vec.as_vec2() * resize_factor).as_uvec2();
viewport.physical_position = resize(viewport.physical_position);
viewport.physical_size = resize(viewport.physical_size);
viewport_size = Some(viewport.physical_size);
}
}
// This check is needed because when changing WindowMode to Fullscreen, the viewport may have invalid
// arguments due to a sudden change on the window size to a lower value.
// If the size of the window is lower, the viewport will match that lower value.
if let Some(viewport) = &mut camera.viewport {
viewport.clamp_to_size(new_computed_target_info.physical_size);
}
camera.computed.target_info = Some(new_computed_target_info);
if let Some(size) = camera.logical_viewport_size()
&& size.x != 0.0
&& size.y != 0.0
{
camera_projection.update(size.x, size.y);
camera.computed.clip_from_view = match &camera.sub_camera_view {
Some(sub_view) => camera_projection.get_clip_from_view_for_sub(sub_view),
None => camera_projection.get_clip_from_view(),
}
}
}
if camera.computed.old_viewport_size != viewport_size {
camera.computed.old_viewport_size = viewport_size;
}
if camera.computed.old_sub_camera_view != camera.sub_camera_view {
camera.computed.old_sub_camera_view = camera.sub_camera_view;
}
}
Ok(())
}
#[derive(Component, Debug)]
pub struct ExtractedCamera {
pub target: Option<NormalizedRenderTarget>,
pub physical_viewport_size: Option<UVec2>,
pub physical_target_size: Option<UVec2>,
pub viewport: Option<Viewport>,
pub render_graph: InternedRenderSubGraph,
pub order: isize,
pub output_mode: CameraOutputMode,
pub msaa_writeback: MsaaWriteback,
pub clear_color: ClearColorConfig,
pub sorted_camera_index_for_target: usize,
pub exposure: f32,
pub hdr: bool,
}
pub fn extract_cameras(
mut commands: Commands,
query: Extract<
Query<(
Entity,
RenderEntity,
&Camera,
&RenderTarget,
&CameraRenderGraph,
&GlobalTransform,
&VisibleEntities,
&Frustum,
(
Has<Hdr>,
Option<&ColorGrading>,
Option<&Exposure>,
Option<&TemporalJitter>,
Option<&MipBias>,
Option<&RenderLayers>,
Option<&Projection>,
Has<NoIndirectDrawing>,
),
)>,
>,
primary_window: Extract<Query<Entity, With<PrimaryWindow>>>,
gpu_preprocessing_support: Res<GpuPreprocessingSupport>,
mapper: Extract<Query<&RenderEntity>>,
) {
let primary_window = primary_window.iter().next();
type ExtractedCameraComponents = (
ExtractedCamera,
ExtractedView,
RenderVisibleEntities,
TemporalJitter,
MipBias,
RenderLayers,
Projection,
NoIndirectDrawing,
ViewUniformOffset,
);
for (
main_entity,
render_entity,
camera,
render_target,
camera_render_graph,
transform,
visible_entities,
frustum,
(
hdr,
color_grading,
exposure,
temporal_jitter,
mip_bias,
render_layers,
projection,
no_indirect_drawing,
),
) in query.iter()
{
if !camera.is_active {
commands
.entity(render_entity)
.remove::<ExtractedCameraComponents>();
continue;
}
let color_grading = color_grading.unwrap_or(&ColorGrading::default()).clone();
if let (
Some(URect {
min: viewport_origin,
..
}),
Some(viewport_size),
Some(target_size),
) = (
camera.physical_viewport_rect(),
camera.physical_viewport_size(),
camera.physical_target_size(),
) {
if target_size.x == 0 || target_size.y == 0 {
commands
.entity(render_entity)
.remove::<ExtractedCameraComponents>();
continue;
}
let render_visible_entities = RenderVisibleEntities {
entities: visible_entities
.entities
.iter()
.map(|(type_id, entities)| {
let entities = entities
.iter()
.map(|entity| {
let render_entity = mapper
.get(*entity)
.cloned()
.map(|entity| entity.id())
.unwrap_or(Entity::PLACEHOLDER);
(render_entity, (*entity).into())
})
.collect();
(*type_id, entities)
})
.collect(),
};
let mut commands = commands.entity(render_entity);
commands.insert((
ExtractedCamera {
target: render_target.normalize(primary_window),
viewport: camera.viewport.clone(),
physical_viewport_size: Some(viewport_size),
physical_target_size: Some(target_size),
render_graph: camera_render_graph.0,
order: camera.order,
output_mode: camera.output_mode,
msaa_writeback: camera.msaa_writeback,
clear_color: camera.clear_color,
// this will be set in sort_cameras
sorted_camera_index_for_target: 0,
exposure: exposure
.map(Exposure::exposure)
.unwrap_or_else(|| Exposure::default().exposure()),
hdr,
},
ExtractedView {
retained_view_entity: RetainedViewEntity::new(main_entity.into(), None, 0),
clip_from_view: camera.clip_from_view(),
world_from_view: *transform,
clip_from_world: None,
hdr,
viewport: UVec4::new(
viewport_origin.x,
viewport_origin.y,
viewport_size.x,
viewport_size.y,
),
color_grading,
invert_culling: camera.invert_culling,
},
render_visible_entities,
*frustum,
));
if let Some(temporal_jitter) = temporal_jitter {
commands.insert(temporal_jitter.clone());
} else {
commands.remove::<TemporalJitter>();
}
if let Some(mip_bias) = mip_bias {
commands.insert(mip_bias.clone());
} else {
commands.remove::<MipBias>();
}
if let Some(render_layers) = render_layers {
commands.insert(render_layers.clone());
} else {
commands.remove::<RenderLayers>();
}
if let Some(projection) = projection {
commands.insert(projection.clone());
} else {
commands.remove::<Projection>();
}
if no_indirect_drawing
|| !matches!(
gpu_preprocessing_support.max_supported_mode,
GpuPreprocessingMode::Culling
)
{
commands.insert(NoIndirectDrawing);
} else {
commands.remove::<NoIndirectDrawing>();
}
};
}
}
/// Cameras sorted by their order field. This is updated in the [`sort_cameras`] system.
#[derive(Resource, Default)]
pub struct SortedCameras(pub Vec<SortedCamera>);
pub struct SortedCamera {
pub entity: Entity,
pub order: isize,
pub target: Option<NormalizedRenderTarget>,
pub hdr: bool,
}
pub fn sort_cameras(
mut sorted_cameras: ResMut<SortedCameras>,
mut cameras: Query<(Entity, &mut ExtractedCamera)>,
) {
sorted_cameras.0.clear();
for (entity, camera) in cameras.iter() {
sorted_cameras.0.push(SortedCamera {
entity,
order: camera.order,
target: camera.target.clone(),
hdr: camera.hdr,
});
}
// sort by order and ensure within an order, RenderTargets of the same type are packed together
sorted_cameras
.0
.sort_by(|c1, c2| (c1.order, &c1.target).cmp(&(c2.order, &c2.target)));
let mut previous_order_target = None;
let mut ambiguities = <HashSet<_>>::default();
let mut target_counts = <HashMap<_, _>>::default();
for sorted_camera in &mut sorted_cameras.0 {
let new_order_target = (sorted_camera.order, sorted_camera.target.clone());
if let Some(previous_order_target) = previous_order_target
&& previous_order_target == new_order_target
{
ambiguities.insert(new_order_target.clone());
}
if let Some(target) = &sorted_camera.target {
let count = target_counts
.entry((target.clone(), sorted_camera.hdr))
.or_insert(0usize);
let (_, mut camera) = cameras.get_mut(sorted_camera.entity).unwrap();
camera.sorted_camera_index_for_target = *count;
*count += 1;
}
previous_order_target = Some(new_order_target);
}
if !ambiguities.is_empty() {
warn!(
"Camera order ambiguities detected for active cameras with the following priorities: {:?}. \
To fix this, ensure there is exactly one Camera entity spawned with a given order for a given RenderTarget. \
Ambiguities should be resolved because either (1) multiple active cameras were spawned accidentally, which will \
result in rendering multiple instances of the scene or (2) for cases where multiple active cameras is intentional, \
ambiguities could result in unpredictable render results.",
ambiguities
);
}
}
/// A subpixel offset to jitter a perspective camera's frustum by.
///
/// Useful for temporal rendering techniques.
#[derive(Component, Clone, Default, Reflect)]
#[reflect(Default, Component, Clone)]
pub struct TemporalJitter {
/// Offset is in range [-0.5, 0.5].
pub offset: Vec2,
}
impl TemporalJitter {
pub fn jitter_projection(&self, clip_from_view: &mut Mat4, view_size: Vec2) {
// https://github.com/GPUOpen-LibrariesAndSDKs/FidelityFX-SDK/blob/d7531ae47d8b36a5d4025663e731a47a38be882f/docs/techniques/media/super-resolution-temporal/jitter-space.svg
let mut jitter = (self.offset * vec2(2.0, -2.0)) / view_size;
// orthographic
if clip_from_view.w_axis.w == 1.0 {
jitter *= vec2(clip_from_view.x_axis.x, clip_from_view.y_axis.y) * 0.5;
}
clip_from_view.z_axis.x += jitter.x;
clip_from_view.z_axis.y += jitter.y;
}
}
/// Camera component specifying a mip bias to apply when sampling from material textures.
///
/// Often used in conjunction with antialiasing post-process effects to reduce textures blurriness.
#[derive(Component, Reflect, Clone)]
#[reflect(Default, Component)]
pub struct MipBias(pub f32);
impl Default for MipBias {
fn default() -> Self {
Self(-1.0)
}
}

View File

@ -1,47 +0,0 @@
#define_import_path bevy_render::color_operations
#import bevy_render::maths::FRAC_PI_3
// Converts HSV to RGB.
//
// Input: H [0, 2π), S [0, 1], V [0, 1].
// Output: R [0, 1], G [0, 1], B [0, 1].
//
// <https://en.wikipedia.org/wiki/HSL_and_HSV#HSV_to_RGB_alternative>
fn hsv_to_rgb(hsv: vec3<f32>) -> vec3<f32> {
let n = vec3(5.0, 3.0, 1.0);
let k = (n + hsv.x / FRAC_PI_3) % 6.0;
return hsv.z - hsv.z * hsv.y * max(vec3(0.0), min(k, min(4.0 - k, vec3(1.0))));
}
// Converts RGB to HSV.
//
// Input: R [0, 1], G [0, 1], B [0, 1].
// Output: H [0, 2π), S [0, 1], V [0, 1].
//
// <https://en.wikipedia.org/wiki/HSL_and_HSV#From_RGB>
fn rgb_to_hsv(rgb: vec3<f32>) -> vec3<f32> {
let x_max = max(rgb.r, max(rgb.g, rgb.b)); // i.e. V
let x_min = min(rgb.r, min(rgb.g, rgb.b));
let c = x_max - x_min; // chroma
var swizzle = vec3<f32>(0.0);
if (x_max == rgb.r) {
swizzle = vec3(rgb.gb, 0.0);
} else if (x_max == rgb.g) {
swizzle = vec3(rgb.br, 2.0);
} else {
swizzle = vec3(rgb.rg, 4.0);
}
let h = FRAC_PI_3 * (((swizzle.x - swizzle.y) / c + swizzle.z) % 6.0);
// Avoid division by zero.
var s = 0.0;
if (x_max > 0.0) {
s = c / x_max;
}
return vec3(h, s, x_max);
}

View File

@ -1,81 +0,0 @@
use core::{any::type_name, marker::PhantomData};
use bevy_app::{Plugin, PreUpdate};
use bevy_diagnostic::{Diagnostic, DiagnosticPath, Diagnostics, RegisterDiagnostic};
use bevy_ecs::{resource::Resource, system::Res};
use bevy_platform::sync::atomic::{AtomicUsize, Ordering};
use crate::{
erased_render_asset::{ErasedRenderAsset, ErasedRenderAssets},
Extract, ExtractSchedule, RenderApp,
};
/// Collects diagnostics for a [`ErasedRenderAsset`].
///
/// If the [`ErasedRenderAsset::ErasedAsset`] is shared between other
/// [`ErasedRenderAsset`], they all will report the same number.
pub struct ErasedRenderAssetDiagnosticPlugin<A: ErasedRenderAsset> {
suffix: &'static str,
_phantom: PhantomData<A>,
}
impl<A: ErasedRenderAsset> ErasedRenderAssetDiagnosticPlugin<A> {
pub fn new(suffix: &'static str) -> Self {
Self {
suffix,
_phantom: PhantomData,
}
}
pub fn render_asset_diagnostic_path() -> DiagnosticPath {
DiagnosticPath::from_components(["erased_render_asset", type_name::<A>()])
}
}
impl<A: ErasedRenderAsset> Plugin for ErasedRenderAssetDiagnosticPlugin<A> {
fn build(&self, app: &mut bevy_app::App) {
app.register_diagnostic(
Diagnostic::new(Self::render_asset_diagnostic_path()).with_suffix(self.suffix),
)
.init_resource::<ErasedRenderAssetMeasurements<A>>()
.add_systems(PreUpdate, add_erased_render_asset_measurement::<A>);
if let Some(render_app) = app.get_sub_app_mut(RenderApp) {
render_app.add_systems(ExtractSchedule, measure_erased_render_asset::<A>);
}
}
}
#[derive(Debug, Resource)]
struct ErasedRenderAssetMeasurements<A: ErasedRenderAsset> {
assets: AtomicUsize,
_phantom: PhantomData<A>,
}
impl<A: ErasedRenderAsset> Default for ErasedRenderAssetMeasurements<A> {
fn default() -> Self {
Self {
assets: AtomicUsize::default(),
_phantom: PhantomData,
}
}
}
fn add_erased_render_asset_measurement<A: ErasedRenderAsset>(
mut diagnostics: Diagnostics,
measurements: Res<ErasedRenderAssetMeasurements<A>>,
) {
diagnostics.add_measurement(
&ErasedRenderAssetDiagnosticPlugin::<A>::render_asset_diagnostic_path(),
|| measurements.assets.load(Ordering::Relaxed) as f64,
);
}
fn measure_erased_render_asset<A: ErasedRenderAsset>(
measurements: Extract<Res<ErasedRenderAssetMeasurements<A>>>,
assets: Res<ErasedRenderAssets<A::ErasedAsset>>,
) {
measurements
.assets
.store(assets.iter().count(), Ordering::Relaxed);
}

View File

@ -1,711 +0,0 @@
use alloc::{borrow::Cow, sync::Arc};
use core::{
ops::{DerefMut, Range},
sync::atomic::{AtomicBool, Ordering},
};
use std::thread::{self, ThreadId};
use bevy_diagnostic::{Diagnostic, DiagnosticMeasurement, DiagnosticPath, DiagnosticsStore};
use bevy_ecs::resource::Resource;
use bevy_ecs::system::{Res, ResMut};
use bevy_platform::time::Instant;
use std::sync::Mutex;
use wgpu::{
Buffer, BufferDescriptor, BufferUsages, CommandEncoder, ComputePass, Features, MapMode,
PipelineStatisticsTypes, QuerySet, QuerySetDescriptor, QueryType, RenderPass,
};
use crate::renderer::{RenderAdapterInfo, RenderDevice, RenderQueue, WgpuWrapper};
use super::RecordDiagnostics;
// buffer offset must be divisible by 256, so this constant must be divisible by 32 (=256/8)
const MAX_TIMESTAMP_QUERIES: u32 = 256;
const MAX_PIPELINE_STATISTICS: u32 = 128;
const TIMESTAMP_SIZE: u64 = 8;
const PIPELINE_STATISTICS_SIZE: u64 = 40;
struct DiagnosticsRecorderInternal {
timestamp_period_ns: f32,
features: Features,
current_frame: Mutex<FrameData>,
submitted_frames: Vec<FrameData>,
finished_frames: Vec<FrameData>,
#[cfg(feature = "tracing-tracy")]
tracy_gpu_context: tracy_client::GpuContext,
}
/// Records diagnostics into [`QuerySet`]'s keeping track of the mapping between
/// spans and indices to the corresponding entries in the [`QuerySet`].
#[derive(Resource)]
pub struct DiagnosticsRecorder(WgpuWrapper<DiagnosticsRecorderInternal>);
impl DiagnosticsRecorder {
/// Creates the new `DiagnosticsRecorder`.
pub fn new(
adapter_info: &RenderAdapterInfo,
device: &RenderDevice,
queue: &RenderQueue,
) -> DiagnosticsRecorder {
let features = device.features();
#[cfg(feature = "tracing-tracy")]
let tracy_gpu_context =
super::tracy_gpu::new_tracy_gpu_context(adapter_info, device, queue);
let _ = adapter_info; // Prevent unused variable warnings when tracing-tracy is not enabled
DiagnosticsRecorder(WgpuWrapper::new(DiagnosticsRecorderInternal {
timestamp_period_ns: queue.get_timestamp_period(),
features,
current_frame: Mutex::new(FrameData::new(
device,
features,
#[cfg(feature = "tracing-tracy")]
tracy_gpu_context.clone(),
)),
submitted_frames: Vec::new(),
finished_frames: Vec::new(),
#[cfg(feature = "tracing-tracy")]
tracy_gpu_context,
}))
}
fn current_frame_mut(&mut self) -> &mut FrameData {
self.0.current_frame.get_mut().expect("lock poisoned")
}
fn current_frame_lock(&self) -> impl DerefMut<Target = FrameData> + '_ {
self.0.current_frame.lock().expect("lock poisoned")
}
/// Begins recording diagnostics for a new frame.
pub fn begin_frame(&mut self) {
let internal = &mut self.0;
let mut idx = 0;
while idx < internal.submitted_frames.len() {
let timestamp = internal.timestamp_period_ns;
if internal.submitted_frames[idx].run_mapped_callback(timestamp) {
let removed = internal.submitted_frames.swap_remove(idx);
internal.finished_frames.push(removed);
} else {
idx += 1;
}
}
self.current_frame_mut().begin();
}
/// Copies data from [`QuerySet`]'s to a [`Buffer`], after which it can be downloaded to CPU.
///
/// Should be called before [`DiagnosticsRecorder::finish_frame`].
pub fn resolve(&mut self, encoder: &mut CommandEncoder) {
self.current_frame_mut().resolve(encoder);
}
/// Finishes recording diagnostics for the current frame.
///
/// The specified `callback` will be invoked when diagnostics become available.
///
/// Should be called after [`DiagnosticsRecorder::resolve`],
/// and **after** all commands buffers have been queued.
pub fn finish_frame(
&mut self,
device: &RenderDevice,
callback: impl FnOnce(RenderDiagnostics) + Send + Sync + 'static,
) {
#[cfg(feature = "tracing-tracy")]
let tracy_gpu_context = self.0.tracy_gpu_context.clone();
let internal = &mut self.0;
internal
.current_frame
.get_mut()
.expect("lock poisoned")
.finish(callback);
// reuse one of the finished frames, if we can
let new_frame = match internal.finished_frames.pop() {
Some(frame) => frame,
None => FrameData::new(
device,
internal.features,
#[cfg(feature = "tracing-tracy")]
tracy_gpu_context,
),
};
let old_frame = core::mem::replace(
internal.current_frame.get_mut().expect("lock poisoned"),
new_frame,
);
internal.submitted_frames.push(old_frame);
}
}
impl RecordDiagnostics for DiagnosticsRecorder {
fn begin_time_span<E: WriteTimestamp>(&self, encoder: &mut E, span_name: Cow<'static, str>) {
self.current_frame_lock()
.begin_time_span(encoder, span_name);
}
fn end_time_span<E: WriteTimestamp>(&self, encoder: &mut E) {
self.current_frame_lock().end_time_span(encoder);
}
fn begin_pass_span<P: Pass>(&self, pass: &mut P, span_name: Cow<'static, str>) {
self.current_frame_lock().begin_pass(pass, span_name);
}
fn end_pass_span<P: Pass>(&self, pass: &mut P) {
self.current_frame_lock().end_pass(pass);
}
}
struct SpanRecord {
thread_id: ThreadId,
path_range: Range<usize>,
pass_kind: Option<PassKind>,
begin_timestamp_index: Option<u32>,
end_timestamp_index: Option<u32>,
begin_instant: Option<Instant>,
end_instant: Option<Instant>,
pipeline_statistics_index: Option<u32>,
}
struct FrameData {
timestamps_query_set: Option<QuerySet>,
num_timestamps: u32,
supports_timestamps_inside_passes: bool,
supports_timestamps_inside_encoders: bool,
pipeline_statistics_query_set: Option<QuerySet>,
num_pipeline_statistics: u32,
buffer_size: u64,
pipeline_statistics_buffer_offset: u64,
resolve_buffer: Option<Buffer>,
read_buffer: Option<Buffer>,
path_components: Vec<Cow<'static, str>>,
open_spans: Vec<SpanRecord>,
closed_spans: Vec<SpanRecord>,
is_mapped: Arc<AtomicBool>,
callback: Option<Box<dyn FnOnce(RenderDiagnostics) + Send + Sync + 'static>>,
#[cfg(feature = "tracing-tracy")]
tracy_gpu_context: tracy_client::GpuContext,
}
impl FrameData {
fn new(
device: &RenderDevice,
features: Features,
#[cfg(feature = "tracing-tracy")] tracy_gpu_context: tracy_client::GpuContext,
) -> FrameData {
let wgpu_device = device.wgpu_device();
let mut buffer_size = 0;
let timestamps_query_set = if features.contains(Features::TIMESTAMP_QUERY) {
buffer_size += u64::from(MAX_TIMESTAMP_QUERIES) * TIMESTAMP_SIZE;
Some(wgpu_device.create_query_set(&QuerySetDescriptor {
label: Some("timestamps_query_set"),
ty: QueryType::Timestamp,
count: MAX_TIMESTAMP_QUERIES,
}))
} else {
None
};
let pipeline_statistics_buffer_offset = buffer_size;
let pipeline_statistics_query_set =
if features.contains(Features::PIPELINE_STATISTICS_QUERY) {
buffer_size += u64::from(MAX_PIPELINE_STATISTICS) * PIPELINE_STATISTICS_SIZE;
Some(wgpu_device.create_query_set(&QuerySetDescriptor {
label: Some("pipeline_statistics_query_set"),
ty: QueryType::PipelineStatistics(PipelineStatisticsTypes::all()),
count: MAX_PIPELINE_STATISTICS,
}))
} else {
None
};
let (resolve_buffer, read_buffer) = if buffer_size > 0 {
let resolve_buffer = wgpu_device.create_buffer(&BufferDescriptor {
label: Some("render_statistics_resolve_buffer"),
size: buffer_size,
usage: BufferUsages::QUERY_RESOLVE | BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
let read_buffer = wgpu_device.create_buffer(&BufferDescriptor {
label: Some("render_statistics_read_buffer"),
size: buffer_size,
usage: BufferUsages::COPY_DST | BufferUsages::MAP_READ,
mapped_at_creation: false,
});
(Some(resolve_buffer), Some(read_buffer))
} else {
(None, None)
};
FrameData {
timestamps_query_set,
num_timestamps: 0,
supports_timestamps_inside_passes: features
.contains(Features::TIMESTAMP_QUERY_INSIDE_PASSES),
supports_timestamps_inside_encoders: features
.contains(Features::TIMESTAMP_QUERY_INSIDE_ENCODERS),
pipeline_statistics_query_set,
num_pipeline_statistics: 0,
buffer_size,
pipeline_statistics_buffer_offset,
resolve_buffer,
read_buffer,
path_components: Vec::new(),
open_spans: Vec::new(),
closed_spans: Vec::new(),
is_mapped: Arc::new(AtomicBool::new(false)),
callback: None,
#[cfg(feature = "tracing-tracy")]
tracy_gpu_context,
}
}
fn begin(&mut self) {
self.num_timestamps = 0;
self.num_pipeline_statistics = 0;
self.path_components.clear();
self.open_spans.clear();
self.closed_spans.clear();
}
fn write_timestamp(
&mut self,
encoder: &mut impl WriteTimestamp,
is_inside_pass: bool,
) -> Option<u32> {
// `encoder.write_timestamp` is unsupported on WebGPU.
if !self.supports_timestamps_inside_encoders {
return None;
}
if is_inside_pass && !self.supports_timestamps_inside_passes {
return None;
}
if self.num_timestamps >= MAX_TIMESTAMP_QUERIES {
return None;
}
let set = self.timestamps_query_set.as_ref()?;
let index = self.num_timestamps;
encoder.write_timestamp(set, index);
self.num_timestamps += 1;
Some(index)
}
fn write_pipeline_statistics(
&mut self,
encoder: &mut impl WritePipelineStatistics,
) -> Option<u32> {
if self.num_pipeline_statistics >= MAX_PIPELINE_STATISTICS {
return None;
}
let set = self.pipeline_statistics_query_set.as_ref()?;
let index = self.num_pipeline_statistics;
encoder.begin_pipeline_statistics_query(set, index);
self.num_pipeline_statistics += 1;
Some(index)
}
fn open_span(
&mut self,
pass_kind: Option<PassKind>,
name: Cow<'static, str>,
) -> &mut SpanRecord {
let thread_id = thread::current().id();
let parent = self.open_spans.iter().rfind(|v| v.thread_id == thread_id);
let path_range = match &parent {
Some(parent) if parent.path_range.end == self.path_components.len() => {
parent.path_range.start..parent.path_range.end + 1
}
Some(parent) => {
self.path_components
.extend_from_within(parent.path_range.clone());
self.path_components.len() - parent.path_range.len()..self.path_components.len() + 1
}
None => self.path_components.len()..self.path_components.len() + 1,
};
self.path_components.push(name);
self.open_spans.push(SpanRecord {
thread_id,
path_range,
pass_kind,
begin_timestamp_index: None,
end_timestamp_index: None,
begin_instant: None,
end_instant: None,
pipeline_statistics_index: None,
});
self.open_spans.last_mut().unwrap()
}
fn close_span(&mut self) -> &mut SpanRecord {
let thread_id = thread::current().id();
let iter = self.open_spans.iter();
let (index, _) = iter
.enumerate()
.rfind(|(_, v)| v.thread_id == thread_id)
.unwrap();
let span = self.open_spans.swap_remove(index);
self.closed_spans.push(span);
self.closed_spans.last_mut().unwrap()
}
fn begin_time_span(&mut self, encoder: &mut impl WriteTimestamp, name: Cow<'static, str>) {
let begin_instant = Instant::now();
let begin_timestamp_index = self.write_timestamp(encoder, false);
let span = self.open_span(None, name);
span.begin_instant = Some(begin_instant);
span.begin_timestamp_index = begin_timestamp_index;
}
fn end_time_span(&mut self, encoder: &mut impl WriteTimestamp) {
let end_timestamp_index = self.write_timestamp(encoder, false);
let span = self.close_span();
span.end_timestamp_index = end_timestamp_index;
span.end_instant = Some(Instant::now());
}
fn begin_pass<P: Pass>(&mut self, pass: &mut P, name: Cow<'static, str>) {
let begin_instant = Instant::now();
let begin_timestamp_index = self.write_timestamp(pass, true);
let pipeline_statistics_index = self.write_pipeline_statistics(pass);
let span = self.open_span(Some(P::KIND), name);
span.begin_instant = Some(begin_instant);
span.begin_timestamp_index = begin_timestamp_index;
span.pipeline_statistics_index = pipeline_statistics_index;
}
fn end_pass(&mut self, pass: &mut impl Pass) {
let end_timestamp_index = self.write_timestamp(pass, true);
let span = self.close_span();
span.end_timestamp_index = end_timestamp_index;
if span.pipeline_statistics_index.is_some() {
pass.end_pipeline_statistics_query();
}
span.end_instant = Some(Instant::now());
}
fn resolve(&mut self, encoder: &mut CommandEncoder) {
let Some(resolve_buffer) = &self.resolve_buffer else {
return;
};
match &self.timestamps_query_set {
Some(set) if self.num_timestamps > 0 => {
encoder.resolve_query_set(set, 0..self.num_timestamps, resolve_buffer, 0);
}
_ => {}
}
match &self.pipeline_statistics_query_set {
Some(set) if self.num_pipeline_statistics > 0 => {
encoder.resolve_query_set(
set,
0..self.num_pipeline_statistics,
resolve_buffer,
self.pipeline_statistics_buffer_offset,
);
}
_ => {}
}
let Some(read_buffer) = &self.read_buffer else {
return;
};
encoder.copy_buffer_to_buffer(resolve_buffer, 0, read_buffer, 0, self.buffer_size);
}
fn diagnostic_path(&self, range: &Range<usize>, field: &str) -> DiagnosticPath {
DiagnosticPath::from_components(
core::iter::once("render")
.chain(self.path_components[range.clone()].iter().map(|v| &**v))
.chain(core::iter::once(field)),
)
}
fn finish(&mut self, callback: impl FnOnce(RenderDiagnostics) + Send + Sync + 'static) {
let Some(read_buffer) = &self.read_buffer else {
// we still have cpu timings, so let's use them
let mut diagnostics = Vec::new();
for span in &self.closed_spans {
if let (Some(begin), Some(end)) = (span.begin_instant, span.end_instant) {
diagnostics.push(RenderDiagnostic {
path: self.diagnostic_path(&span.path_range, "elapsed_cpu"),
suffix: "ms",
value: (end - begin).as_secs_f64() * 1000.0,
});
}
}
callback(RenderDiagnostics(diagnostics));
return;
};
self.callback = Some(Box::new(callback));
let is_mapped = self.is_mapped.clone();
read_buffer.slice(..).map_async(MapMode::Read, move |res| {
if let Err(e) = res {
tracing::warn!("Failed to download render statistics buffer: {e}");
return;
}
is_mapped.store(true, Ordering::Release);
});
}
// returns true if the frame is considered finished, false otherwise
fn run_mapped_callback(&mut self, timestamp_period_ns: f32) -> bool {
let Some(read_buffer) = &self.read_buffer else {
return true;
};
if !self.is_mapped.load(Ordering::Acquire) {
// need to wait more
return false;
}
let Some(callback) = self.callback.take() else {
return true;
};
let data = read_buffer.slice(..).get_mapped_range();
let timestamps = data[..(self.num_timestamps * 8) as usize]
.chunks(8)
.map(|v| u64::from_le_bytes(v.try_into().unwrap()))
.collect::<Vec<u64>>();
let start = self.pipeline_statistics_buffer_offset as usize;
let len = (self.num_pipeline_statistics as usize) * 40;
let pipeline_statistics = data[start..start + len]
.chunks(8)
.map(|v| u64::from_le_bytes(v.try_into().unwrap()))
.collect::<Vec<u64>>();
let mut diagnostics = Vec::new();
for span in &self.closed_spans {
if let (Some(begin), Some(end)) = (span.begin_instant, span.end_instant) {
diagnostics.push(RenderDiagnostic {
path: self.diagnostic_path(&span.path_range, "elapsed_cpu"),
suffix: "ms",
value: (end - begin).as_secs_f64() * 1000.0,
});
}
if let (Some(begin), Some(end)) = (span.begin_timestamp_index, span.end_timestamp_index)
{
let begin = timestamps[begin as usize] as f64;
let end = timestamps[end as usize] as f64;
let value = (end - begin) * (timestamp_period_ns as f64) / 1e6;
#[cfg(feature = "tracing-tracy")]
{
// Calling span_alloc() and end_zone() here instead of in open_span() and close_span() means that tracy does not know where each GPU command was recorded on the CPU timeline.
// Unfortunately we must do it this way, because tracy does not play nicely with multithreaded command recording. The start/end pairs would get all mixed up.
// The GPU spans themselves are still accurate though, and it's probably safe to assume that each GPU span in frame N belongs to the corresponding CPU render node span from frame N-1.
let name = &self.path_components[span.path_range.clone()].join("/");
let mut tracy_gpu_span =
self.tracy_gpu_context.span_alloc(name, "", "", 0).unwrap();
tracy_gpu_span.end_zone();
tracy_gpu_span.upload_timestamp_start(begin as i64);
tracy_gpu_span.upload_timestamp_end(end as i64);
}
diagnostics.push(RenderDiagnostic {
path: self.diagnostic_path(&span.path_range, "elapsed_gpu"),
suffix: "ms",
value,
});
}
if let Some(index) = span.pipeline_statistics_index {
let index = (index as usize) * 5;
if span.pass_kind == Some(PassKind::Render) {
diagnostics.push(RenderDiagnostic {
path: self.diagnostic_path(&span.path_range, "vertex_shader_invocations"),
suffix: "",
value: pipeline_statistics[index] as f64,
});
diagnostics.push(RenderDiagnostic {
path: self.diagnostic_path(&span.path_range, "clipper_invocations"),
suffix: "",
value: pipeline_statistics[index + 1] as f64,
});
diagnostics.push(RenderDiagnostic {
path: self.diagnostic_path(&span.path_range, "clipper_primitives_out"),
suffix: "",
value: pipeline_statistics[index + 2] as f64,
});
diagnostics.push(RenderDiagnostic {
path: self.diagnostic_path(&span.path_range, "fragment_shader_invocations"),
suffix: "",
value: pipeline_statistics[index + 3] as f64,
});
}
if span.pass_kind == Some(PassKind::Compute) {
diagnostics.push(RenderDiagnostic {
path: self.diagnostic_path(&span.path_range, "compute_shader_invocations"),
suffix: "",
value: pipeline_statistics[index + 4] as f64,
});
}
}
}
callback(RenderDiagnostics(diagnostics));
drop(data);
read_buffer.unmap();
self.is_mapped.store(false, Ordering::Release);
true
}
}
/// Resource which stores render diagnostics of the most recent frame.
#[derive(Debug, Default, Clone, Resource)]
pub struct RenderDiagnostics(Vec<RenderDiagnostic>);
/// A render diagnostic which has been recorded, but not yet stored in [`DiagnosticsStore`].
#[derive(Debug, Clone, Resource)]
pub struct RenderDiagnostic {
pub path: DiagnosticPath,
pub suffix: &'static str,
pub value: f64,
}
/// Stores render diagnostics before they can be synced with the main app.
///
/// This mutex is locked twice per frame:
/// 1. in `PreUpdate`, during [`sync_diagnostics`],
/// 2. after rendering has finished and statistics have been downloaded from GPU.
#[derive(Debug, Default, Clone, Resource)]
pub struct RenderDiagnosticsMutex(pub(crate) Arc<Mutex<Option<RenderDiagnostics>>>);
/// Updates render diagnostics measurements.
pub fn sync_diagnostics(mutex: Res<RenderDiagnosticsMutex>, mut store: ResMut<DiagnosticsStore>) {
let Some(diagnostics) = mutex.0.lock().ok().and_then(|mut v| v.take()) else {
return;
};
let time = Instant::now();
for diagnostic in &diagnostics.0 {
if store.get(&diagnostic.path).is_none() {
store.add(Diagnostic::new(diagnostic.path.clone()).with_suffix(diagnostic.suffix));
}
store
.get_mut(&diagnostic.path)
.unwrap()
.add_measurement(DiagnosticMeasurement {
time,
value: diagnostic.value,
});
}
}
pub trait WriteTimestamp {
fn write_timestamp(&mut self, query_set: &QuerySet, index: u32);
}
impl WriteTimestamp for CommandEncoder {
fn write_timestamp(&mut self, query_set: &QuerySet, index: u32) {
if cfg!(target_os = "macos") {
// When using tracy (and thus this function), rendering was flickering on macOS Tahoe.
// See: https://github.com/bevyengine/bevy/issues/22257
// The issue seems to be triggered when `write_timestamp` is called very close to frame
// presentation.
return;
}
CommandEncoder::write_timestamp(self, query_set, index);
}
}
impl WriteTimestamp for RenderPass<'_> {
fn write_timestamp(&mut self, query_set: &QuerySet, index: u32) {
RenderPass::write_timestamp(self, query_set, index);
}
}
impl WriteTimestamp for ComputePass<'_> {
fn write_timestamp(&mut self, query_set: &QuerySet, index: u32) {
ComputePass::write_timestamp(self, query_set, index);
}
}
pub trait WritePipelineStatistics {
fn begin_pipeline_statistics_query(&mut self, query_set: &QuerySet, index: u32);
fn end_pipeline_statistics_query(&mut self);
}
impl WritePipelineStatistics for RenderPass<'_> {
fn begin_pipeline_statistics_query(&mut self, query_set: &QuerySet, index: u32) {
RenderPass::begin_pipeline_statistics_query(self, query_set, index);
}
fn end_pipeline_statistics_query(&mut self) {
RenderPass::end_pipeline_statistics_query(self);
}
}
impl WritePipelineStatistics for ComputePass<'_> {
fn begin_pipeline_statistics_query(&mut self, query_set: &QuerySet, index: u32) {
ComputePass::begin_pipeline_statistics_query(self, query_set, index);
}
fn end_pipeline_statistics_query(&mut self) {
ComputePass::end_pipeline_statistics_query(self);
}
}
pub trait Pass: WritePipelineStatistics + WriteTimestamp {
const KIND: PassKind;
}
impl Pass for RenderPass<'_> {
const KIND: PassKind = PassKind::Render;
}
impl Pass for ComputePass<'_> {
const KIND: PassKind = PassKind::Compute;
}
#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
pub enum PassKind {
Render,
Compute,
}

View File

@ -1,91 +0,0 @@
use bevy_app::{Plugin, PreUpdate};
use bevy_diagnostic::{Diagnostic, DiagnosticPath, Diagnostics, RegisterDiagnostic};
use bevy_ecs::{resource::Resource, system::Res};
use bevy_platform::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
use crate::{mesh::allocator::MeshAllocator, Extract, ExtractSchedule, RenderApp};
/// Number of meshes allocated by the allocator
static MESH_ALLOCATOR_SLABS: DiagnosticPath = DiagnosticPath::const_new("mesh_allocator_slabs");
/// Total size of all slabs
static MESH_ALLOCATOR_SLABS_SIZE: DiagnosticPath =
DiagnosticPath::const_new("mesh_allocator_slabs_size");
/// Number of meshes allocated into slabs
static MESH_ALLOCATOR_ALLOCATIONS: DiagnosticPath =
DiagnosticPath::const_new("mesh_allocator_allocations");
pub struct MeshAllocatorDiagnosticPlugin;
impl MeshAllocatorDiagnosticPlugin {
/// Get the [`DiagnosticPath`] for slab count
pub fn slabs_diagnostic_path() -> &'static DiagnosticPath {
&MESH_ALLOCATOR_SLABS
}
/// Get the [`DiagnosticPath`] for total slabs size
pub fn slabs_size_diagnostic_path() -> &'static DiagnosticPath {
&MESH_ALLOCATOR_SLABS_SIZE
}
/// Get the [`DiagnosticPath`] for mesh allocations
pub fn allocations_diagnostic_path() -> &'static DiagnosticPath {
&MESH_ALLOCATOR_ALLOCATIONS
}
}
impl Plugin for MeshAllocatorDiagnosticPlugin {
fn build(&self, app: &mut bevy_app::App) {
app.register_diagnostic(
Diagnostic::new(MESH_ALLOCATOR_SLABS.clone()).with_suffix(" slabs"),
)
.register_diagnostic(
Diagnostic::new(MESH_ALLOCATOR_SLABS_SIZE.clone()).with_suffix(" bytes"),
)
.register_diagnostic(
Diagnostic::new(MESH_ALLOCATOR_ALLOCATIONS.clone()).with_suffix(" meshes"),
)
.init_resource::<MeshAllocatorMeasurements>()
.add_systems(PreUpdate, add_mesh_allocator_measurement);
if let Some(render_app) = app.get_sub_app_mut(RenderApp) {
render_app.add_systems(ExtractSchedule, measure_allocator);
}
}
}
#[derive(Debug, Default, Resource)]
struct MeshAllocatorMeasurements {
slabs: AtomicUsize,
slabs_size: AtomicU64,
allocations: AtomicUsize,
}
fn add_mesh_allocator_measurement(
mut diagnostics: Diagnostics,
measurements: Res<MeshAllocatorMeasurements>,
) {
diagnostics.add_measurement(&MESH_ALLOCATOR_SLABS, || {
measurements.slabs.load(Ordering::Relaxed) as f64
});
diagnostics.add_measurement(&MESH_ALLOCATOR_SLABS_SIZE, || {
measurements.slabs_size.load(Ordering::Relaxed) as f64
});
diagnostics.add_measurement(&MESH_ALLOCATOR_ALLOCATIONS, || {
measurements.allocations.load(Ordering::Relaxed) as f64
});
}
fn measure_allocator(
measurements: Extract<Res<MeshAllocatorMeasurements>>,
allocator: Res<MeshAllocator>,
) {
measurements
.slabs
.store(allocator.slab_count(), Ordering::Relaxed);
measurements
.slabs_size
.store(allocator.slabs_size(), Ordering::Relaxed);
measurements
.allocations
.store(allocator.allocations(), Ordering::Relaxed);
}

View File

@ -1,199 +0,0 @@
//! Infrastructure for recording render diagnostics.
//!
//! For more info, see [`RenderDiagnosticsPlugin`].
mod erased_render_asset_diagnostic_plugin;
pub(crate) mod internal;
mod mesh_allocator_diagnostic_plugin;
mod render_asset_diagnostic_plugin;
#[cfg(feature = "tracing-tracy")]
mod tracy_gpu;
use alloc::{borrow::Cow, sync::Arc};
use core::marker::PhantomData;
use bevy_app::{App, Plugin, PreUpdate};
use crate::{renderer::RenderAdapterInfo, RenderApp};
use self::internal::{
sync_diagnostics, DiagnosticsRecorder, Pass, RenderDiagnosticsMutex, WriteTimestamp,
};
pub use self::{
erased_render_asset_diagnostic_plugin::ErasedRenderAssetDiagnosticPlugin,
mesh_allocator_diagnostic_plugin::MeshAllocatorDiagnosticPlugin,
render_asset_diagnostic_plugin::RenderAssetDiagnosticPlugin,
};
use crate::renderer::{RenderDevice, RenderQueue};
/// Enables collecting render diagnostics, such as CPU/GPU elapsed time per render pass,
/// as well as pipeline statistics (number of primitives, number of shader invocations, etc).
///
/// To access the diagnostics, you can use the [`DiagnosticsStore`](bevy_diagnostic::DiagnosticsStore) resource,
/// add [`LogDiagnosticsPlugin`](bevy_diagnostic::LogDiagnosticsPlugin), or use [Tracy](https://github.com/bevyengine/bevy/blob/main/docs/profiling.md#tracy-renderqueue).
///
/// To record diagnostics in your own passes:
/// 1. First, obtain the diagnostic recorder using [`RenderContext::diagnostic_recorder`](crate::renderer::RenderContext::diagnostic_recorder).
///
/// It won't do anything unless [`RenderDiagnosticsPlugin`] is present,
/// so you're free to omit `#[cfg]` clauses.
/// ```ignore
/// let diagnostics = render_context.diagnostic_recorder();
/// ```
/// 2. Begin the span inside a command encoder, or a render/compute pass encoder.
/// ```ignore
/// let time_span = diagnostics.time_span(render_context.command_encoder(), "shadows");
/// ```
/// 3. End the span, providing the same encoder.
/// ```ignore
/// time_span.end(render_context.command_encoder());
/// ```
///
/// # Supported platforms
/// Timestamp queries and pipeline statistics are currently supported only on Vulkan and DX12.
/// On other platforms (Metal, WebGPU, WebGL2) only CPU time will be recorded.
#[derive(Default)]
pub struct RenderDiagnosticsPlugin;
impl Plugin for RenderDiagnosticsPlugin {
fn build(&self, app: &mut App) {
let render_diagnostics_mutex = RenderDiagnosticsMutex::default();
app.insert_resource(render_diagnostics_mutex.clone())
.add_systems(PreUpdate, sync_diagnostics);
if let Some(render_app) = app.get_sub_app_mut(RenderApp) {
render_app.insert_resource(render_diagnostics_mutex);
}
}
fn finish(&self, app: &mut App) {
let Some(render_app) = app.get_sub_app_mut(RenderApp) else {
return;
};
let adapter_info = render_app.world().resource::<RenderAdapterInfo>();
let device = render_app.world().resource::<RenderDevice>();
let queue = render_app.world().resource::<RenderQueue>();
render_app.insert_resource(DiagnosticsRecorder::new(adapter_info, device, queue));
}
}
/// Allows recording diagnostic spans.
pub trait RecordDiagnostics: Send + Sync {
/// Begin a time span, which will record elapsed CPU and GPU time.
///
/// Returns a guard, which will panic on drop unless you end the span.
fn time_span<E, N>(&self, encoder: &mut E, name: N) -> TimeSpanGuard<'_, Self, E>
where
E: WriteTimestamp,
N: Into<Cow<'static, str>>,
{
self.begin_time_span(encoder, name.into());
TimeSpanGuard {
recorder: self,
marker: PhantomData,
}
}
/// Begin a pass span, which will record elapsed CPU and GPU time,
/// as well as pipeline statistics on supported platforms.
///
/// Returns a guard, which will panic on drop unless you end the span.
fn pass_span<P, N>(&self, pass: &mut P, name: N) -> PassSpanGuard<'_, Self, P>
where
P: Pass,
N: Into<Cow<'static, str>>,
{
let name = name.into();
self.begin_pass_span(pass, name.clone());
PassSpanGuard {
recorder: self,
name,
marker: PhantomData,
}
}
#[doc(hidden)]
fn begin_time_span<E: WriteTimestamp>(&self, encoder: &mut E, name: Cow<'static, str>);
#[doc(hidden)]
fn end_time_span<E: WriteTimestamp>(&self, encoder: &mut E);
#[doc(hidden)]
fn begin_pass_span<P: Pass>(&self, pass: &mut P, name: Cow<'static, str>);
#[doc(hidden)]
fn end_pass_span<P: Pass>(&self, pass: &mut P);
}
/// Guard returned by [`RecordDiagnostics::time_span`].
///
/// Will panic on drop unless [`TimeSpanGuard::end`] is called.
pub struct TimeSpanGuard<'a, R: ?Sized, E> {
recorder: &'a R,
marker: PhantomData<E>,
}
impl<R: RecordDiagnostics + ?Sized, E: WriteTimestamp> TimeSpanGuard<'_, R, E> {
/// End the span. You have to provide the same encoder which was used to begin the span.
pub fn end(self, encoder: &mut E) {
self.recorder.end_time_span(encoder);
core::mem::forget(self);
}
}
impl<R: ?Sized, E> Drop for TimeSpanGuard<'_, R, E> {
fn drop(&mut self) {
panic!("TimeSpanScope::end was never called")
}
}
/// Guard returned by [`RecordDiagnostics::pass_span`].
///
/// Will panic on drop unless [`PassSpanGuard::end`] is called.
pub struct PassSpanGuard<'a, R: ?Sized, P> {
recorder: &'a R,
name: Cow<'static, str>,
marker: PhantomData<P>,
}
impl<R: RecordDiagnostics + ?Sized, P: Pass> PassSpanGuard<'_, R, P> {
/// End the span. You have to provide the same pass which was used to begin the span.
pub fn end(self, pass: &mut P) {
self.recorder.end_pass_span(pass);
core::mem::forget(self);
}
}
impl<R: ?Sized, P> Drop for PassSpanGuard<'_, R, P> {
fn drop(&mut self) {
panic!("PassSpanGuard::end was never called for {}", self.name)
}
}
impl<T: RecordDiagnostics> RecordDiagnostics for Option<Arc<T>> {
fn begin_time_span<E: WriteTimestamp>(&self, encoder: &mut E, name: Cow<'static, str>) {
if let Some(recorder) = &self {
recorder.begin_time_span(encoder, name);
}
}
fn end_time_span<E: WriteTimestamp>(&self, encoder: &mut E) {
if let Some(recorder) = &self {
recorder.end_time_span(encoder);
}
}
fn begin_pass_span<P: Pass>(&self, pass: &mut P, name: Cow<'static, str>) {
if let Some(recorder) = &self {
recorder.begin_pass_span(pass, name);
}
}
fn end_pass_span<P: Pass>(&self, pass: &mut P) {
if let Some(recorder) = &self {
recorder.end_pass_span(pass);
}
}
}

View File

@ -1,77 +0,0 @@
use core::{any::type_name, marker::PhantomData};
use bevy_app::{Plugin, PreUpdate};
use bevy_diagnostic::{Diagnostic, DiagnosticPath, Diagnostics, RegisterDiagnostic};
use bevy_ecs::{resource::Resource, system::Res};
use bevy_platform::sync::atomic::{AtomicUsize, Ordering};
use crate::{
render_asset::{RenderAsset, RenderAssets},
Extract, ExtractSchedule, RenderApp,
};
pub struct RenderAssetDiagnosticPlugin<A: RenderAsset> {
suffix: &'static str,
_phantom: PhantomData<A>,
}
impl<A: RenderAsset> RenderAssetDiagnosticPlugin<A> {
pub fn new(suffix: &'static str) -> Self {
Self {
suffix,
_phantom: PhantomData,
}
}
pub fn render_asset_diagnostic_path() -> DiagnosticPath {
DiagnosticPath::from_components(["render_asset", type_name::<A>()])
}
}
impl<A: RenderAsset> Plugin for RenderAssetDiagnosticPlugin<A> {
fn build(&self, app: &mut bevy_app::App) {
app.register_diagnostic(
Diagnostic::new(Self::render_asset_diagnostic_path()).with_suffix(self.suffix),
)
.init_resource::<RenderAssetMeasurements<A>>()
.add_systems(PreUpdate, add_render_asset_measurement::<A>);
if let Some(render_app) = app.get_sub_app_mut(RenderApp) {
render_app.add_systems(ExtractSchedule, measure_render_asset::<A>);
}
}
}
#[derive(Debug, Resource)]
struct RenderAssetMeasurements<A: RenderAsset> {
assets: AtomicUsize,
_phantom: PhantomData<A>,
}
impl<A: RenderAsset> Default for RenderAssetMeasurements<A> {
fn default() -> Self {
Self {
assets: AtomicUsize::default(),
_phantom: PhantomData,
}
}
}
fn add_render_asset_measurement<A: RenderAsset>(
mut diagnostics: Diagnostics,
measurements: Res<RenderAssetMeasurements<A>>,
) {
diagnostics.add_measurement(
&RenderAssetDiagnosticPlugin::<A>::render_asset_diagnostic_path(),
|| measurements.assets.load(Ordering::Relaxed) as f64,
);
}
fn measure_render_asset<A: RenderAsset>(
measurements: Extract<Res<RenderAssetMeasurements<A>>>,
assets: Res<RenderAssets<A>>,
) {
measurements
.assets
.store(assets.iter().count(), Ordering::Relaxed);
}

View File

@ -1,69 +0,0 @@
use crate::renderer::{RenderAdapterInfo, RenderDevice, RenderQueue};
use tracy_client::{Client, GpuContext, GpuContextType};
use wgpu::{
Backend, BufferDescriptor, BufferUsages, CommandEncoderDescriptor, MapMode, PollType,
QuerySetDescriptor, QueryType, QUERY_SIZE,
};
pub fn new_tracy_gpu_context(
adapter_info: &RenderAdapterInfo,
device: &RenderDevice,
queue: &RenderQueue,
) -> GpuContext {
let tracy_gpu_backend = match adapter_info.backend {
Backend::Vulkan => GpuContextType::Vulkan,
Backend::Dx12 => GpuContextType::Direct3D12,
Backend::Gl => GpuContextType::OpenGL,
Backend::Metal | Backend::BrowserWebGpu | Backend::Noop => GpuContextType::Invalid,
};
let tracy_client = Client::running().unwrap();
tracy_client
.new_gpu_context(
Some("RenderQueue"),
tracy_gpu_backend,
initial_timestamp(device, queue),
queue.get_timestamp_period(),
)
.unwrap()
}
// Code copied from https://github.com/Wumpf/wgpu-profiler/blob/f9de342a62cb75f50904a98d11dd2bbeb40ceab8/src/tracy.rs
fn initial_timestamp(device: &RenderDevice, queue: &RenderQueue) -> i64 {
let query_set = device.wgpu_device().create_query_set(&QuerySetDescriptor {
label: None,
ty: QueryType::Timestamp,
count: 1,
});
let resolve_buffer = device.create_buffer(&BufferDescriptor {
label: None,
size: QUERY_SIZE as _,
usage: BufferUsages::QUERY_RESOLVE | BufferUsages::COPY_SRC,
mapped_at_creation: false,
});
let map_buffer = device.create_buffer(&BufferDescriptor {
label: None,
size: QUERY_SIZE as _,
usage: BufferUsages::MAP_READ | BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut timestamp_encoder = device.create_command_encoder(&CommandEncoderDescriptor::default());
timestamp_encoder.write_timestamp(&query_set, 0);
timestamp_encoder.resolve_query_set(&query_set, 0..1, &resolve_buffer, 0);
// Workaround for https://github.com/gfx-rs/wgpu/issues/6406
// TODO when that bug is fixed, merge these encoders together again
let mut copy_encoder = device.create_command_encoder(&CommandEncoderDescriptor::default());
copy_encoder.copy_buffer_to_buffer(&resolve_buffer, 0, &map_buffer, 0, Some(QUERY_SIZE as _));
queue.submit([timestamp_encoder.finish(), copy_encoder.finish()]);
map_buffer.slice(..).map_async(MapMode::Read, |_| ());
device
.poll(PollType::wait_indefinitely())
.expect("Failed to poll device for map async");
let view = map_buffer.slice(..).get_mapped_range();
i64::from_le_bytes((*view).try_into().unwrap())
}

View File

@ -1,427 +0,0 @@
use crate::{
render_resource::AsBindGroupError, ExtractSchedule, MainWorld, Render, RenderApp,
RenderSystems, Res,
};
use bevy_app::{App, Plugin, SubApp};
use bevy_asset::RenderAssetUsages;
use bevy_asset::{Asset, AssetEvent, AssetId, Assets, UntypedAssetId};
use bevy_ecs::{
prelude::{Commands, IntoScheduleConfigs, MessageReader, ResMut, Resource},
schedule::{ScheduleConfigs, SystemSet},
system::{ScheduleSystem, StaticSystemParam, SystemParam, SystemParamItem, SystemState},
world::{FromWorld, Mut},
};
use bevy_platform::collections::{HashMap, HashSet};
use bevy_render::render_asset::RenderAssetBytesPerFrameLimiter;
use core::marker::PhantomData;
use thiserror::Error;
use tracing::{debug, error};
#[derive(Debug, Error)]
pub enum PrepareAssetError<E: Send + Sync + 'static> {
#[error("Failed to prepare asset")]
RetryNextUpdate(E),
#[error("Failed to build bind group: {0}")]
AsBindGroupError(AsBindGroupError),
}
/// The system set during which we extract modified assets to the render world.
#[derive(SystemSet, Clone, PartialEq, Eq, Debug, Hash)]
pub struct AssetExtractionSystems;
/// Describes how an asset gets extracted and prepared for rendering.
///
/// In the [`ExtractSchedule`] step the [`ErasedRenderAsset::SourceAsset`] is transferred
/// from the "main world" into the "render world".
///
/// After that in the [`RenderSystems::PrepareAssets`] step the extracted asset
/// is transformed into its GPU-representation of type [`ErasedRenderAsset`].
pub trait ErasedRenderAsset: Send + Sync + 'static {
/// The representation of the asset in the "main world".
type SourceAsset: Asset + Clone;
/// The target representation of the asset in the "render world".
type ErasedAsset: Send + Sync + 'static + Sized;
/// Specifies all ECS data required by [`ErasedRenderAsset::prepare_asset`].
///
/// For convenience use the [`lifetimeless`](bevy_ecs::system::lifetimeless) [`SystemParam`].
type Param: SystemParam;
/// Whether or not to unload the asset after extracting it to the render world.
#[inline]
fn asset_usage(_source_asset: &Self::SourceAsset) -> RenderAssetUsages {
RenderAssetUsages::default()
}
/// Size of the data the asset will upload to the gpu. Specifying a return value
/// will allow the asset to be throttled via [`RenderAssetBytesPerFrameLimiter`].
#[inline]
#[expect(
unused_variables,
reason = "The parameters here are intentionally unused by the default implementation; however, putting underscores here will result in the underscores being copied by rust-analyzer's tab completion."
)]
fn byte_len(erased_asset: &Self::SourceAsset) -> Option<usize> {
None
}
/// Prepares the [`ErasedRenderAsset::SourceAsset`] for the GPU by transforming it into a [`ErasedRenderAsset`].
///
/// ECS data may be accessed via `param`.
fn prepare_asset(
source_asset: Self::SourceAsset,
asset_id: AssetId<Self::SourceAsset>,
param: &mut SystemParamItem<Self::Param>,
) -> Result<Self::ErasedAsset, PrepareAssetError<Self::SourceAsset>>;
/// Called whenever the [`ErasedRenderAsset::SourceAsset`] has been removed.
///
/// You can implement this method if you need to access ECS data (via
/// `_param`) in order to perform cleanup tasks when the asset is removed.
///
/// The default implementation does nothing.
fn unload_asset(
_source_asset: AssetId<Self::SourceAsset>,
_param: &mut SystemParamItem<Self::Param>,
) {
}
}
/// This plugin extracts the changed assets from the "app world" into the "render world"
/// and prepares them for the GPU. They can then be accessed from the [`ErasedRenderAssets`] resource.
///
/// Therefore it sets up the [`ExtractSchedule`] and
/// [`RenderSystems::PrepareAssets`] steps for the specified [`ErasedRenderAsset`].
///
/// The `AFTER` generic parameter can be used to specify that `A::prepare_asset` should not be run until
/// `prepare_assets::<AFTER>` has completed. This allows the `prepare_asset` function to depend on another
/// prepared [`ErasedRenderAsset`], for example `Mesh::prepare_asset` relies on `ErasedRenderAssets::<GpuImage>` for morph
/// targets, so the plugin is created as `ErasedRenderAssetPlugin::<RenderMesh, GpuImage>::default()`.
pub struct ErasedRenderAssetPlugin<
A: ErasedRenderAsset,
AFTER: ErasedRenderAssetDependency + 'static = (),
> {
phantom: PhantomData<fn() -> (A, AFTER)>,
}
impl<A: ErasedRenderAsset, AFTER: ErasedRenderAssetDependency + 'static> Default
for ErasedRenderAssetPlugin<A, AFTER>
{
fn default() -> Self {
Self {
phantom: Default::default(),
}
}
}
impl<A: ErasedRenderAsset, AFTER: ErasedRenderAssetDependency + 'static> Plugin
for ErasedRenderAssetPlugin<A, AFTER>
{
fn build(&self, app: &mut App) {
app.init_resource::<CachedExtractErasedRenderAssetSystemState<A>>();
}
fn finish(&self, app: &mut App) {
if let Some(render_app) = app.get_sub_app_mut(RenderApp) {
render_app
.init_resource::<ExtractedAssets<A>>()
.init_resource::<ErasedRenderAssets<A::ErasedAsset>>()
.init_resource::<PrepareNextFrameAssets<A>>()
.add_systems(
ExtractSchedule,
extract_erased_render_asset::<A>.in_set(AssetExtractionSystems),
);
AFTER::register_system(
render_app,
prepare_erased_assets::<A>.in_set(RenderSystems::PrepareAssets),
);
}
}
}
// helper to allow specifying dependencies between render assets
pub trait ErasedRenderAssetDependency {
fn register_system(render_app: &mut SubApp, system: ScheduleConfigs<ScheduleSystem>);
}
impl ErasedRenderAssetDependency for () {
fn register_system(render_app: &mut SubApp, system: ScheduleConfigs<ScheduleSystem>) {
render_app.add_systems(Render, system);
}
}
impl<A: ErasedRenderAsset> ErasedRenderAssetDependency for A {
fn register_system(render_app: &mut SubApp, system: ScheduleConfigs<ScheduleSystem>) {
render_app.add_systems(Render, system.after(prepare_erased_assets::<A>));
}
}
/// Temporarily stores the extracted and removed assets of the current frame.
#[derive(Resource)]
pub struct ExtractedAssets<A: ErasedRenderAsset> {
/// The assets extracted this frame.
///
/// These are assets that were either added or modified this frame.
pub extracted: Vec<(AssetId<A::SourceAsset>, A::SourceAsset)>,
/// IDs of the assets that were removed this frame.
///
/// These assets will not be present in [`ExtractedAssets::extracted`].
pub removed: HashSet<AssetId<A::SourceAsset>>,
/// IDs of the assets that were modified this frame.
pub modified: HashSet<AssetId<A::SourceAsset>>,
/// IDs of the assets that were added this frame.
pub added: HashSet<AssetId<A::SourceAsset>>,
}
impl<A: ErasedRenderAsset> Default for ExtractedAssets<A> {
fn default() -> Self {
Self {
extracted: Default::default(),
removed: Default::default(),
modified: Default::default(),
added: Default::default(),
}
}
}
/// Stores all GPU representations ([`ErasedRenderAsset`])
/// of [`ErasedRenderAsset::SourceAsset`] as long as they exist.
#[derive(Resource)]
pub struct ErasedRenderAssets<ERA>(HashMap<UntypedAssetId, ERA>);
impl<ERA> Default for ErasedRenderAssets<ERA> {
fn default() -> Self {
Self(Default::default())
}
}
impl<ERA> ErasedRenderAssets<ERA> {
pub fn get(&self, id: impl Into<UntypedAssetId>) -> Option<&ERA> {
self.0.get(&id.into())
}
pub fn get_mut(&mut self, id: impl Into<UntypedAssetId>) -> Option<&mut ERA> {
self.0.get_mut(&id.into())
}
pub fn insert(&mut self, id: impl Into<UntypedAssetId>, value: ERA) -> Option<ERA> {
self.0.insert(id.into(), value)
}
pub fn remove(&mut self, id: impl Into<UntypedAssetId>) -> Option<ERA> {
self.0.remove(&id.into())
}
pub fn iter(&self) -> impl Iterator<Item = (UntypedAssetId, &ERA)> {
self.0.iter().map(|(k, v)| (*k, v))
}
pub fn iter_mut(&mut self) -> impl Iterator<Item = (UntypedAssetId, &mut ERA)> {
self.0.iter_mut().map(|(k, v)| (*k, v))
}
}
#[derive(Resource)]
struct CachedExtractErasedRenderAssetSystemState<A: ErasedRenderAsset> {
state: SystemState<(
MessageReader<'static, 'static, AssetEvent<A::SourceAsset>>,
ResMut<'static, Assets<A::SourceAsset>>,
)>,
}
impl<A: ErasedRenderAsset> FromWorld for CachedExtractErasedRenderAssetSystemState<A> {
fn from_world(world: &mut bevy_ecs::world::World) -> Self {
Self {
state: SystemState::new(world),
}
}
}
/// This system extracts all created or modified assets of the corresponding [`ErasedRenderAsset::SourceAsset`] type
/// into the "render world".
pub(crate) fn extract_erased_render_asset<A: ErasedRenderAsset>(
mut commands: Commands,
mut main_world: ResMut<MainWorld>,
) {
main_world.resource_scope(
|world, mut cached_state: Mut<CachedExtractErasedRenderAssetSystemState<A>>| {
let (mut events, mut assets) = cached_state.state.get_mut(world);
let mut needs_extracting = <HashSet<_>>::default();
let mut removed = <HashSet<_>>::default();
let mut modified = <HashSet<_>>::default();
for event in events.read() {
#[expect(
clippy::match_same_arms,
reason = "LoadedWithDependencies is marked as a TODO, so it's likely this will no longer lint soon."
)]
match event {
AssetEvent::Added { id } => {
needs_extracting.insert(*id);
}
AssetEvent::Modified { id } => {
needs_extracting.insert(*id);
modified.insert(*id);
}
AssetEvent::Removed { .. } => {
// We don't care that the asset was removed from Assets<T> in the main world.
// An asset is only removed from ErasedRenderAssets<T> when its last handle is dropped (AssetEvent::Unused).
}
AssetEvent::Unused { id } => {
needs_extracting.remove(id);
modified.remove(id);
removed.insert(*id);
}
AssetEvent::LoadedWithDependencies { .. } => {
// TODO: handle this
}
}
}
let mut extracted_assets = Vec::new();
let mut added = <HashSet<_>>::default();
for id in needs_extracting.drain() {
if let Some(asset) = assets.get(id) {
let asset_usage = A::asset_usage(asset);
if asset_usage.contains(RenderAssetUsages::RENDER_WORLD) {
if asset_usage == RenderAssetUsages::RENDER_WORLD {
if let Some(asset) = assets.remove(id) {
extracted_assets.push((id, asset));
added.insert(id);
}
} else {
extracted_assets.push((id, asset.clone()));
added.insert(id);
}
}
}
}
commands.insert_resource(ExtractedAssets::<A> {
extracted: extracted_assets,
removed,
modified,
added,
});
cached_state.state.apply(world);
},
);
}
// TODO: consider storing inside system?
/// All assets that should be prepared next frame.
#[derive(Resource)]
pub struct PrepareNextFrameAssets<A: ErasedRenderAsset> {
assets: Vec<(AssetId<A::SourceAsset>, A::SourceAsset)>,
}
impl<A: ErasedRenderAsset> Default for PrepareNextFrameAssets<A> {
fn default() -> Self {
Self {
assets: Default::default(),
}
}
}
/// This system prepares all assets of the corresponding [`ErasedRenderAsset::SourceAsset`] type
/// which where extracted this frame for the GPU.
pub fn prepare_erased_assets<A: ErasedRenderAsset>(
mut extracted_assets: ResMut<ExtractedAssets<A>>,
mut render_assets: ResMut<ErasedRenderAssets<A::ErasedAsset>>,
mut prepare_next_frame: ResMut<PrepareNextFrameAssets<A>>,
param: StaticSystemParam<<A as ErasedRenderAsset>::Param>,
bpf: Res<RenderAssetBytesPerFrameLimiter>,
) {
let mut wrote_asset_count = 0;
let mut param = param.into_inner();
let queued_assets = core::mem::take(&mut prepare_next_frame.assets);
for (id, extracted_asset) in queued_assets {
if extracted_assets.removed.contains(&id) || extracted_assets.added.contains(&id) {
// skip previous frame's assets that have been removed or updated
continue;
}
let write_bytes = if let Some(size) = A::byte_len(&extracted_asset) {
// we could check if available bytes > byte_len here, but we want to make some
// forward progress even if the asset is larger than the max bytes per frame.
// this way we always write at least one (sized) asset per frame.
// in future we could also consider partial asset uploads.
if bpf.exhausted() {
prepare_next_frame.assets.push((id, extracted_asset));
continue;
}
size
} else {
0
};
match A::prepare_asset(extracted_asset, id, &mut param) {
Ok(prepared_asset) => {
render_assets.insert(id, prepared_asset);
bpf.write_bytes(write_bytes);
wrote_asset_count += 1;
}
Err(PrepareAssetError::RetryNextUpdate(extracted_asset)) => {
prepare_next_frame.assets.push((id, extracted_asset));
}
Err(PrepareAssetError::AsBindGroupError(e)) => {
error!(
"{} Bind group construction failed: {e}",
core::any::type_name::<A>()
);
}
}
}
for removed in extracted_assets.removed.drain() {
render_assets.remove(removed);
A::unload_asset(removed, &mut param);
}
for (id, extracted_asset) in extracted_assets.extracted.drain(..) {
// we remove previous here to ensure that if we are updating the asset then
// any users will not see the old asset after a new asset is extracted,
// even if the new asset is not yet ready or we are out of bytes to write.
render_assets.remove(id);
let write_bytes = if let Some(size) = A::byte_len(&extracted_asset) {
if bpf.exhausted() {
prepare_next_frame.assets.push((id, extracted_asset));
continue;
}
size
} else {
0
};
match A::prepare_asset(extracted_asset, id, &mut param) {
Ok(prepared_asset) => {
render_assets.insert(id, prepared_asset);
bpf.write_bytes(write_bytes);
wrote_asset_count += 1;
}
Err(PrepareAssetError::RetryNextUpdate(extracted_asset)) => {
prepare_next_frame.assets.push((id, extracted_asset));
}
Err(PrepareAssetError::AsBindGroupError(e)) => {
error!(
"{} Bind group construction failed: {e}",
core::any::type_name::<A>()
);
}
}
}
if bpf.exhausted() && !prepare_next_frame.assets.is_empty() {
debug!(
"{} write budget exhausted with {} assets remaining (wrote {})",
core::any::type_name::<A>(),
prepare_next_frame.assets.len(),
wrote_asset_count
);
}
}

Some files were not shown because too many files have changed in this diff Show More