Compare commits

..

No commits in common. "b5e561904c5f2c0f3c010d1c083736ba352b3862" and "f09d35e54e02e696e5d24e4fab0b0cf0c987c884" have entirely different histories.

206 changed files with 29203 additions and 7078 deletions

View File

@ -6,4 +6,3 @@ rustflags = ["-C", "link-arg=-fuse-ld=mold"]
[alias]
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
- name: Validate level scenes
run: cargo validate-levels
run: cargo run -p xtask --bin validate-levels
- name: Test scene schema crate
run: cargo test -p scene

13
.vscode/tasks.json vendored
View File

@ -122,19 +122,6 @@
"problemMatcher": [],
"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)",
"type": "cargo",

2339
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"
[workspace.dependencies]
avian3d = { version = "0.7", default-features = false, features = [
avian3d = { version = "0.6", default-features = false, features = [
"3d",
"f32",
"parry-f32",
@ -26,14 +26,14 @@ avian3d = { version = "0.7", default-features = false, features = [
"collider-from-mesh",
"serialize",
] }
bevy = { version = "0.19", features = ["serialize", "jpeg"] }
bevy_core_pipeline = "0.19"
bevy_solari = "0.19"
bevy_ufbx = "0.18.1-rc.1"
bevy_egui = "0.40"
bevy-inspector-egui = "0.37"
egui_dock = { version = "0.19.1", features = ["serde"] }
egui_phosphor_icons = { version = "0.3.1", default-features = false }
bevy = { version = "0.18", features = ["serialize", "jpeg"] }
bevy_core_pipeline = "0.18"
bevy_solari = "0.18"
bevy_ufbx = "0.18"
bevy_egui = "0.39"
bevy-inspector-egui = "0.36"
egui_dock = { version = "0.18", features = ["serde"] }
egui_phosphor_icons = "0.2"
transform-gizmo-bevy = "0.9"
serde = { version = "1", features = ["derive"] }
shared = { path = "crates/shared" }
@ -45,8 +45,8 @@ settings = { path = "crates/settings" }
scene = { path = "crates/scene" }
[patch.crates-io]
# Local Bevy 0.19 compatibility patch until upstream publishes a matching FBX loader.
bevy_ufbx = { path = "third_party/bevy_ufbx" }
# Linux surface acquire timeouts can be transient on Wayland/Xwayland drivers.
bevy_render = { path = "third_party/bevy_render" }
# Atmosphere-aware mesh view bind groups (WYSIWYG editor + transform gizmos).
transform-gizmo-bevy = { path = "third_party/transform-gizmo-bevy" }
@ -59,16 +59,6 @@ rpath = true
[profile.dev.package."*"]
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.
[profile.release]
lto = "thin"

View File

@ -1,7 +1,7 @@
# Bevy FPS Foundation
A modular first-person game foundation and in-process editor built on **Bevy 0.19** and
**Avian 0.7** physics.
A modular first-person game foundation and in-process editor built on **Bevy 0.18** and
**Avian 0.6** physics.
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
@ -76,20 +76,13 @@ 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 --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. |
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.
VS Code tasks: **target cleanup (dry run)** and **target cleanup (safe apply)**.
### 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.
- 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.
- Bevy 0.19 removed the prior local `bevy_render` swapchain-timeout patch; launch troubleshooting should start from current wgpu/driver/compositor logs.
- The workspace patches `bevy_render` locally so transient Linux swapchain acquire timeouts skip one frame instead of panicking in `prepare_windows`.
- 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.
@ -118,9 +111,9 @@ deep-stale variants.
| `W` / `E` / `R` | Translate / rotate / scale gizmo |
| `X` | Toggle world/local gizmo orientation |
| `B` | Enter Draw Brush mode |
| 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 |
| Draw Brush: LMB / `Enter` / `Esc` / `Backspace` | Place floor points / create brush / cancel / remove last point |
| Brush selected: `1` / `2` / `3` / `4` | Vertex / edge / face / clip edit modes |
| 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 |
| Brush edit mode: LMB / `Shift+LMB` / `Esc` | Select element / toggle element selection / return to object mode |
| 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 |
| `Tab` in viewport | Cycle selection through overlapping objects at last click |
@ -128,7 +121,7 @@ deep-stale variants.
| 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 context/details actions | Apply textures/materials, regenerate thumbnails, reimport models, place assets/submeshes, or move file assets to `assets/.trash/` |
| `Ctrl+P` | Centered command palette; search human labels or stable command IDs, use arrow keys to select, Enter to run |
| `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) |
| `F7` | While paused in Play: advance one sim tick |
| Shift/Ctrl + click (Hierarchy) | Additive selection |
| Hierarchy context | Reparent to other selection / Unparent |
@ -189,18 +182,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
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
details when you need full `WorldAssetRoot` loading for animation/skinning/scene data. Expanding a
details when you need full `SceneRoot` loading for animation/skinning/scene data. Expanding a
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.
- Brush actors are persisted as `ActorKind::Brush + BrushDesc`; valid convex faces hydrate into
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,
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
status text. The viewport shows quick brush key hints while drawing. `Esc` or right-click cancels
without changing the scene.
- Brush actors are persisted as `ActorKind::Brush + BrushDesc`; the MVP hydrates additive convex
cube brushes into generated preview meshes while face/edge/CSG editing remains roadmap work.
- Draw Brush mode (`B` or toolbar pencil) places snapped floor points and commits an additive
prism brush with `Enter`; `Esc` or right-click cancels without changing the scene.
- Runtime-only handles/colliders are not serialized directly, keeping scenes stable and portable.
- Editor-only cameras and helper roots are filtered from selection, hierarchy, and scene save.
- **PIE restores player sim only** (transform, velocity, jump state) when you stop Play; authored
@ -216,8 +204,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.
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.19 Solari.
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.
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.
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.
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.
7. **Custom post FX**: RON under `assets/post_fx/`; assign path in volume inspector (see [docs/editor/rendering.md](docs/editor/rendering.md)).
@ -306,8 +294,7 @@ crates/
- [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] `scene` crate schema stamp/migrate/validate on save/load + CI `validate-levels`
- [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] Project Settings draft + Apply (HDR/swapchain safe); File → Project New/Open
- [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] Asset browser model thumbnails (unified `assets/thumbnails/` pipeline; `ThumbnailState` cache; FBX via `FbxThumbnailSource`)
@ -317,8 +304,6 @@ 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] 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 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
@ -331,7 +316,7 @@ crates/
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
collider components. Expanded mesh subassets generate independent thumbnails and can be placed independently. Asset details can switch
placement to **Scene Instance** for `ModelRef`/`WorldAssetRoot` playback, shared material assets can be
placement to **Scene Instance** for `ModelRef`/`SceneRoot` playback, shared material assets can be
edited from the browser, and delete actions move files to `assets/.trash/`. Skeletal animation
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.

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -10,6 +10,7 @@ use bevy::math::bounding::Aabb3d;
use bevy::mesh::VertexAttributeValues;
use bevy::prelude::*;
use bevy::render::render_resource::TextureFormat;
use bevy::scene::SceneRoot;
use bevy_egui::EguiUserTextures;
use shared::{material_from_desc, ModelRef};
@ -133,7 +134,6 @@ fn setup_thumbnail_studio(mut commands: Commands, mut images: ResMut<Assets<Imag
ThumbnailStudioLayer,
RenderLayers::layer(THUMBNAIL_LAYER),
Camera3d::default(),
Msaa::Off,
Camera {
is_active: false,
order: -50,
@ -157,7 +157,7 @@ fn setup_thumbnail_studio(mut commands: Commands, mut images: ResMut<Assets<Imag
RenderLayers::layer(THUMBNAIL_LAYER),
DirectionalLight {
illuminance: 12_000.0,
shadow_maps_enabled: false,
shadows_enabled: false,
..default()
},
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),
DirectionalLight {
illuminance: 3_500.0,
shadow_maps_enabled: false,
shadows_enabled: false,
..default()
},
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 {
ThumbnailJobSource::Model { model_path } => {
if uses_scene_root(model_path) {
commands.entity(root).insert(WorldAssetRoot(
commands.entity(root).insert(SceneRoot(
asset_server.load(model_scene_asset_path(model_path, 0)),
));
}
@ -494,10 +494,6 @@ fn finish_active_thumbnail(
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(
commands: &mut Commands,
studio: &mut ThumbnailStudio,

View File

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

@ -50,10 +50,6 @@ pub enum EditorCommand {
snapshot: EditorEntitySnapshot,
entity: Option<Entity>,
},
SpawnMany {
snapshots: Vec<EditorEntitySnapshot>,
entities: Vec<Entity>,
},
Despawn {
snapshot: EditorEntitySnapshot,
entity: Option<Entity>,
@ -112,21 +108,6 @@ pub enum EditorCommand {
old: Option<BrushDesc>,
new: BrushDesc,
},
SetBrushTransform {
entity: Entity,
old_transform: Transform,
new_transform: Transform,
old_brush: Option<BrushDesc>,
new_brush: BrushDesc,
},
ApplyBrushCsg {
primary: Entity,
old_transform: Transform,
new_transform: Transform,
old_brush: Option<BrushDesc>,
new_brush: BrushDesc,
deleted: Vec<(EditorEntitySnapshot, Option<Entity>)>,
},
SetStaticMeshRenderer {
entity: Entity,
old: Option<StaticMeshRenderer>,
@ -179,7 +160,6 @@ impl EditorCommand {
pub fn label(&self) -> &'static str {
match self {
EditorCommand::Spawn { .. } => "Spawn",
EditorCommand::SpawnMany { .. } => "Spawn Brushes",
EditorCommand::Despawn { .. } => "Delete",
EditorCommand::Duplicate { .. } => "Duplicate",
EditorCommand::Rename { .. } => "Rename",
@ -192,8 +172,6 @@ impl EditorCommand {
EditorCommand::SetCollider { .. } => "Set Collider",
EditorCommand::SetPrimitive { .. } => "Set Primitive",
EditorCommand::SetBrush { .. } => "Set Brush",
EditorCommand::SetBrushTransform { .. } => "Clip Brush",
EditorCommand::ApplyBrushCsg { .. } => "Brush CSG",
EditorCommand::SetStaticMeshRenderer { .. } => "Set Static Mesh Renderer",
EditorCommand::SetPostProcessVolume { .. } => "Set Post Process Volume",
EditorCommand::SetTransformGroup { .. } => "Move Selection",

View File

@ -274,32 +274,6 @@ pub fn spawn_with_history(world: &mut World, mut snapshot: EditorEntitySnapshot)
entity
}
pub fn spawn_many_with_history(
world: &mut World,
snapshots: impl IntoIterator<Item = EditorEntitySnapshot>,
) -> Vec<Entity> {
let mut snapshots: Vec<EditorEntitySnapshot> = snapshots.into_iter().collect();
if snapshots.is_empty() {
return Vec::new();
}
for (sibling_index, snapshot) in (next_sibling_index(world, None)..).zip(snapshots.iter_mut()) {
snapshot.hierarchy_sibling_index = sibling_index;
}
let entities: Vec<Entity> = snapshots
.iter()
.map(|snapshot| spawn_snapshot(world, snapshot))
.collect();
push_history(
world,
EditorCommand::SpawnMany {
snapshots,
entities: entities.clone(),
},
);
select_many(world, &entities);
entities
}
pub fn delete_entities_with_history(world: &mut World, entities: &[Entity]) {
let mut deleted = Vec::new();
for entity in entities {
@ -497,82 +471,6 @@ pub fn set_brush_with_history(world: &mut World, entity: Entity, new: BrushDesc)
push_history(world, EditorCommand::SetBrush { entity, old, new });
}
pub fn set_brush_transform_with_history(
world: &mut World,
entity: Entity,
new_transform: Transform,
new_brush: BrushDesc,
) {
if !is_level_object(world, entity) {
return;
}
let old_transform = world.get::<Transform>(entity).copied().unwrap_or_default();
let old_brush = world.get::<BrushDesc>(entity).cloned();
if old_transform == new_transform && old_brush.as_ref() == Some(&new_brush) {
return;
}
if let Ok(mut entity_mut) = world.get_entity_mut(entity) {
entity_mut.insert(new_transform);
entity_mut.insert(new_brush.clone());
}
push_history(
world,
EditorCommand::SetBrushTransform {
entity,
old_transform,
new_transform,
old_brush,
new_brush,
},
);
}
pub fn apply_brush_csg_with_history(
world: &mut World,
primary: Entity,
new_transform: Transform,
new_brush: BrushDesc,
delete_entities: &[Entity],
) {
if !is_level_object(world, primary) {
return;
}
let old_transform = world.get::<Transform>(primary).copied().unwrap_or_default();
let old_brush = world.get::<BrushDesc>(primary).cloned();
let deleted: Vec<(EditorEntitySnapshot, Option<Entity>)> = delete_entities
.iter()
.copied()
.filter_map(|entity| {
snapshot_entity(world, entity).map(|snapshot| (snapshot, Some(entity)))
})
.collect();
if old_transform == new_transform
&& old_brush.as_ref() == Some(&new_brush)
&& deleted.is_empty()
{
return;
}
if let Ok(mut entity_mut) = world.get_entity_mut(primary) {
entity_mut.insert(new_transform);
entity_mut.insert(new_brush.clone());
}
for (_, entity) in &deleted {
despawn_entity(world, *entity);
}
push_history(
world,
EditorCommand::ApplyBrushCsg {
primary,
old_transform,
new_transform,
old_brush,
new_brush,
deleted,
},
);
select_one(world, primary);
}
pub fn set_static_mesh_renderer_with_history(
world: &mut World,
entity: Entity,
@ -854,12 +752,6 @@ fn undo_command(world: &mut World, command: &mut EditorCommand) {
despawn_entity(world, entity.take());
clear_selection(world);
}
EditorCommand::SpawnMany { entities, .. } => {
for entity in std::mem::take(entities) {
despawn_entity(world, Some(entity));
}
clear_selection(world);
}
EditorCommand::Despawn { snapshot, entity } => {
let spawned = spawn_snapshot(world, snapshot);
*entity = Some(spawned);
@ -908,36 +800,6 @@ fn undo_command(world: &mut World, command: &mut EditorCommand) {
EditorCommand::SetBrush { entity, old, .. } => {
apply_brush(world, *entity, old);
}
EditorCommand::SetBrushTransform {
entity,
old_transform,
old_brush,
..
} => {
if let Some(mut transform) = world.get_mut::<Transform>(*entity) {
*transform = *old_transform;
}
apply_brush(world, *entity, old_brush);
}
EditorCommand::ApplyBrushCsg {
primary,
old_transform,
old_brush,
deleted,
..
} => {
if let Some(mut transform) = world.get_mut::<Transform>(*primary) {
*transform = *old_transform;
}
apply_brush(world, *primary, old_brush);
let mut restored = vec![*primary];
for (snapshot, entity) in deleted.iter_mut() {
let spawned = spawn_snapshot(world, snapshot);
*entity = Some(spawned);
restored.push(spawned);
}
select_many(world, &restored);
}
EditorCommand::SetStaticMeshRenderer { entity, old, .. } => {
apply_static_mesh_renderer(world, *entity, old);
}
@ -985,17 +847,6 @@ fn redo_command(world: &mut World, command: &mut EditorCommand) {
*entity = Some(spawned);
select_one(world, spawned);
}
EditorCommand::SpawnMany {
snapshots,
entities,
} => {
let spawned: Vec<Entity> = snapshots
.iter()
.map(|snapshot| spawn_snapshot(world, snapshot))
.collect();
*entities = spawned.clone();
select_many(world, &spawned);
}
EditorCommand::Despawn { entity, .. } => {
despawn_entity(world, entity.take());
clear_selection(world);
@ -1066,33 +917,6 @@ fn redo_command(world: &mut World, command: &mut EditorCommand) {
entity_mut.insert(new.clone());
}
}
EditorCommand::SetBrushTransform {
entity,
new_transform,
new_brush,
..
} => {
if let Ok(mut entity_mut) = world.get_entity_mut(*entity) {
entity_mut.insert(*new_transform);
entity_mut.insert(new_brush.clone());
}
}
EditorCommand::ApplyBrushCsg {
primary,
new_transform,
new_brush,
deleted,
..
} => {
if let Ok(mut entity_mut) = world.get_entity_mut(*primary) {
entity_mut.insert(*new_transform);
entity_mut.insert(new_brush.clone());
}
for (_, entity) in deleted.iter_mut() {
despawn_entity(world, entity.take());
}
select_one(world, *primary);
}
EditorCommand::SetStaticMeshRenderer { entity, new, .. } => {
if let Ok(mut entity_mut) = world.get_entity_mut(*entity) {
entity_mut.insert(new.clone());
@ -1487,14 +1311,11 @@ fn capture_gizmo_transform_edits(world: &mut World) {
Entity,
&Transform,
&transform_gizmo_bevy::prelude::GizmoTarget,
Option<&crate::viewport::brush_edit::BrushElementGizmo>,
)>();
let active = query
.iter(world)
.find(|(_, _, target, brush_element_gizmo)| {
brush_element_gizmo.is_none() && target.is_active()
})
.map(|(entity, transform, _, _)| (entity, *transform));
.find(|(_, _, target)| target.is_active())
.map(|(entity, transform, _)| (entity, *transform));
let start_group =
active.map(|(entity, transform)| selected_transforms(world, &selection, entity, transform));

View File

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

View File

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

View File

@ -0,0 +1,150 @@
//! 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,9 +3,10 @@ use std::path::{Path, PathBuf};
use bevy::ecs::entity::EntityHashMap;
use bevy::ecs::system::SystemState;
use bevy::prelude::*;
use bevy::scene::serde::SceneDeserializer;
use bevy::scene::DynamicScene;
use bevy::scene::DynamicSceneBuilder;
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 serde::de::DeserializeSeed;
use shared::{
@ -468,9 +469,7 @@ fn save_entities(world: &mut World, path: &Path, entities: Vec<Entity>) -> Resul
}
}
let ron = {
let registry = world.resource::<AppTypeRegistry>().read();
let scene = DynamicWorldBuilder::from_world(world, &registry)
let scene = DynamicSceneBuilder::from_world(world)
.deny_all()
.allow_component::<Name>()
.allow_component::<Transform>()
@ -504,6 +503,8 @@ fn save_entities(world: &mut World, path: &Path, entities: Vec<Entity>) -> Resul
.remove_empty_entities()
.build();
let ron = {
let registry = world.resource::<AppTypeRegistry>().read();
scene
.serialize(&registry)
.map_err(|err| format!("could not serialize scene: {err}"))?
@ -535,12 +536,10 @@ fn load_level(world: &mut World, path: &Path) -> Result<(), String> {
clear_loaded_scene_roots(world);
clear_level_objects(world);
let dynamic_scene: DynamicWorld = {
let dynamic_scene: DynamicScene = {
let registry = world.resource::<AppTypeRegistry>().read();
let mut asset_server = world.resource::<AssetServer>().clone();
let scene_deserializer = WorldDeserializer {
let scene_deserializer = SceneDeserializer {
type_registry: &registry,
load_from_path: &mut asset_server,
};
let mut deserializer =
ron::de::Deserializer::from_str(&bevy_ron).map_err(|err| err.to_string())?;
@ -591,9 +590,7 @@ fn finalize_scene_load(world: &mut World) {
)> = SystemState::new(world);
{
let (settings, scene_suns, project_suns) = state
.get_mut(world)
.expect("finalize_scene_load system params should be valid");
let (settings, scene_suns, project_suns) = state.get_mut(world);
game_hot::sync_project_sun_from_settings(settings, scene_suns, project_suns);
}
state.apply(world);

View File

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

View File

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

View File

@ -17,26 +17,5 @@ fn configure_editor_fonts(mut contexts: Query<&mut EguiContext, Added<PrimaryEgu
};
let mut fonts = bevy_egui::egui::FontDefinitions::default();
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);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -66,7 +66,7 @@ pub struct UiState {
}
pub fn egui_captures_keyboard(ctx: &egui::Context) -> bool {
ctx.egui_wants_keyboard_input()
ctx.wants_keyboard_input()
}
pub fn egui_captures_keyboard_from_world(world: &mut World) -> bool {
@ -118,10 +118,6 @@ 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) {
apply_editor_theme(ctx);
@ -133,6 +129,17 @@ 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.viewport_pointer_pos = None;
@ -147,18 +154,6 @@ impl UiState {
});
}
egui::CentralPanel::no_frame().show(ctx, |root_ui| {
top_menu_bar(
world,
root_ui,
&self.selected_entities,
&mut self.dock_state,
&mut self.panel_nodes,
);
editor_toolbar_panel(world, root_ui);
status_bar_ui(world, root_ui, &self.selected_entities, mode);
let mut viewer = TabViewer {
world,
viewport_rect: &mut self.viewport_rect,
@ -173,8 +168,7 @@ impl UiState {
DockArea::new(&mut self.dock_state)
.style(editor_dock_style(ctx))
.show_inside(root_ui, &mut viewer);
});
.show(ctx, &mut viewer);
self.panel_nodes = PanelNodes::discover(&self.dock_state, self.panel_nodes);

View File

@ -13,28 +13,18 @@ use super::theme::{status_bar_frame, TEXT};
pub fn status_bar_ui(
world: &World,
root_ui: &mut egui::Ui,
ctx: &egui::Context,
selected: &SelectedEntities,
mode: EditorMode,
) {
egui::Panel::bottom("editor_status_bar")
.exact_size(24.0)
egui::TopBottomPanel::bottom("editor_status_bar")
.exact_height(24.0)
.frame(status_bar_frame())
.show_inside(root_ui, |ui| {
ui.spacing_mut().item_spacing.x = 12.0;
egui::containers::Sides::new()
.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| {
.show(ctx, |ui| {
ui.horizontal_centered(|ui| {
ui.spacing_mut().item_spacing.x = 16.0;
ui.label(egui::RichText::new(scene_line(world)).color(TEXT));
let mode_label = match mode {
EditorMode::Editing => "Edit".to_string(),
EditorMode::Playing => {
@ -48,12 +38,14 @@ pub fn status_bar_ui(
ui.label(egui::RichText::new(mode_label).color(TEXT));
let count = selected.len();
let selection = match count {
0 => "None".to_string(),
1 => "1 selected".to_string(),
_ => format!("{count} selected"),
let sel = if count == 0 {
"None".to_string()
} else if count == 1 {
"1 selected".to_string()
} else {
format!("{count} selected")
};
ui.label(egui::RichText::new(selection).color(TEXT));
ui.label(egui::RichText::new(sel).color(TEXT));
ui.label(
egui::RichText::new(world.resource::<EditorHistory>().status.clone())
@ -65,35 +57,15 @@ pub fn status_bar_ui(
}
#[cfg(feature = "hot-reload")]
if let Some(hot) = world.get_resource::<crate::hot_reload::HotReloadState>()
{
if let Some(hot) = world.get_resource::<crate::hot_reload::HotReloadState>() {
ui.label(egui::RichText::new(hot.label.clone()).color(TEXT));
}
},
);
});
});
}
fn scene_line(scene_io: &SceneIo) -> String {
fn scene_line(world: &World) -> String {
let scene_io = world.resource::<SceneIo>();
let dirty = if scene_io.dirty { " *" } else { "" };
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_corner_radius = CornerRadius::same(4);
let mut style = (*ctx.global_style()).clone();
let mut style = (*ctx.style()).clone();
style.visuals = visuals;
style.spacing.item_spacing = egui::vec2(8.0, 6.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,
FontId::new(15.0, egui::FontFamily::Proportional),
);
ctx.set_global_style(style);
ctx.set_style(style);
}
pub fn editor_dock_style(ctx: &egui::Context) -> DockStyle {
let mut style = DockStyle::from_egui(ctx.global_style().as_ref());
let mut style = DockStyle::from_egui(ctx.style().as_ref());
style.dock_area_padding = Some(egui::Margin::same(2));
style.main_surface_border_stroke = Stroke::new(1.0, BORDER);
style.separator.width = 2.0;

View File

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

View File

@ -15,8 +15,7 @@ use crate::render_target::ViewportRenderTarget;
use crate::selection::ViewportClick;
use crate::state::PlayPossession;
use crate::viewport::actor_icons::ActorIconSettings;
use crate::viewport::brush_edit::{BrushEditMode, BrushElementSelection};
use crate::viewport::brush_tool::{BrushToolPhase, BrushToolState};
use crate::viewport::brush_edit::BrushEditMode;
use crate::viewport::{
snap_translation, viewport_ground_position, EditorViewportMode, ViewportDisplayMode,
ViewportSettings,
@ -36,10 +35,6 @@ 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)";
#[expect(
clippy::too_many_arguments,
reason = "viewport tab rendering keeps immediate-mode UI inputs explicit"
)]
pub fn viewport_tab_ui(
world: &mut World,
ui: &mut egui::Ui,
@ -114,7 +109,6 @@ pub fn viewport_tab_ui(
}
scene_view_overlay_toolbar(world, ui.ctx(), rect);
scene_view_brush_draw_hints(world, ui.ctx(), rect);
scene_view_mode_badge(world, ui.ctx(), rect);
scene_view_brush_mode_badge(world, ui.ctx(), rect);
scene_view_gi_badge(world, ui.ctx(), rect);
@ -165,139 +159,21 @@ fn scene_view_brush_mode_badge(world: &World, ctx: &egui::Context, scene_rect: e
if !mode.is_element_mode() {
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"))
.fixed_pos(scene_rect.left_top() + egui::vec2(8.0, 52.0))
.fixed_pos(scene_rect.right_bottom() + egui::vec2(-150.0, -22.0))
.interactable(false)
.show(ctx, |ui| {
egui::Frame::new()
.fill(egui::Color32::from_rgba_unmultiplied(20, 18, 28, 230))
.stroke(egui::Stroke::new(
1.0,
egui::Color32::from_rgba_unmultiplied(210, 140, 255, 210),
))
.corner_radius(egui::CornerRadius::same(4))
.inner_margin(egui::Margin::symmetric(8, 6))
.fill(egui::Color32::from_rgba_unmultiplied(35, 44, 50, 220))
.corner_radius(egui::CornerRadius::same(3))
.inner_margin(egui::Margin::symmetric(6, 2))
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(
egui::RichText::new(format!("Brush {}", mode.label()))
.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");
});
});
});
}
fn scene_view_brush_draw_hints(world: &World, ctx: &egui::Context, scene_rect: egui::Rect) {
let Some(tool) = world.get_resource::<BrushToolState>() else {
return;
};
if !tool.active {
return;
}
let hint = match tool.phase {
BrushToolPhase::Outline if tool.vertices.len() < 3 => "Place points".to_string(),
BrushToolPhase::Outline => "Set height".to_string(),
BrushToolPhase::Height => format!("{:.1}m", tool.height),
};
egui::Area::new(egui::Id::new("scene_view_brush_draw_hints"))
.fixed_pos(scene_rect.left_top() + egui::vec2(8.0, 52.0))
.interactable(false)
.show(ctx, |ui| {
egui::Frame::new()
.fill(egui::Color32::from_rgba_unmultiplied(18, 22, 26, 220))
.stroke(egui::Stroke::new(
1.0,
egui::Color32::from_rgba_unmultiplied(95, 150, 190, 180),
))
.corner_radius(egui::CornerRadius::same(4))
.inner_margin(egui::Margin::symmetric(8, 6))
.show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(
egui::RichText::new("Draw Brush")
egui::RichText::new(format!("Brush: {}", mode.label()))
.color(egui::Color32::from_rgb(170, 220, 255))
.strong(),
);
ui.label(
egui::RichText::new(format!("{} pts", tool.vertices.len()))
.color(egui::Color32::from_rgb(190, 200, 205))
.small(),
);
ui.label(
egui::RichText::new(hint)
.color(egui::Color32::from_rgb(150, 220, 170))
.small(),
);
});
ui.add_space(4.0);
ui.horizontal_wrapped(|ui| match tool.phase {
BrushToolPhase::Outline => {
key_hint(ui, "LMB", "Point");
key_hint(ui, "Enter", "Height");
key_hint(ui, "Backspace", "Remove");
key_hint(ui, "Esc", "Cancel");
key_hint(ui, "RMB", "Cancel");
}
BrushToolPhase::Height => {
key_hint(ui, "Mouse Up/Down", "Height");
key_hint(ui, "Enter", "Create");
key_hint(ui, "LMB", "Create");
key_hint(ui, "Backspace", "Outline");
key_hint(ui, "Esc", "Cancel");
key_hint(ui, "RMB", "Cancel");
}
});
});
});
}
fn key_hint(ui: &mut egui::Ui, key: &str, label: &str) {
ui.horizontal(|ui| {
ui.label(
egui::RichText::new(key)
.monospace()
.color(egui::Color32::from_rgb(232, 238, 242))
.background_color(egui::Color32::from_rgba_unmultiplied(45, 52, 58, 220)),
);
ui.label(
egui::RichText::new(label)
.color(egui::Color32::from_rgb(190, 200, 205))
.small(),
);
});
}

View File

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

View File

@ -7,7 +7,9 @@ use shared::{
BrushDesc, LevelObject,
};
use crate::history::apply_brush_csg_with_history;
use crate::history::{
delete_entities_with_history, set_brush_with_history, set_transform_with_history,
};
use crate::scene_io::SceneIo;
use crate::ui::UiState;
@ -239,18 +241,17 @@ fn commit_csg_preview(world: &mut World, pending: PendingBrushCsg) {
return;
}
let new_brush = BrushDesc::cuboid(pending.result.size());
let to_delete: Vec<_> = if matches!(pending.op, BrushCsgOp::Merge | BrushCsgOp::Intersect) {
pending
apply_bounds_result(world, primary, pending.result, new_brush);
if matches!(pending.op, BrushCsgOp::Merge | BrushCsgOp::Intersect) {
let to_delete: Vec<_> = pending
.selected
.iter()
.skip(1)
.copied()
.filter(|entity| world.get::<BrushDesc>(*entity).is_some())
.collect()
} else {
Vec::new()
};
apply_bounds_result(world, primary, pending.result, new_brush, &to_delete);
.collect();
delete_entities_with_history(world, &to_delete);
}
set_status(
world,
match pending.op {
@ -317,7 +318,6 @@ fn apply_bounds_result(
entity: Entity,
bounds: BrushBounds,
new_brush: BrushDesc,
delete_entities: &[Entity],
) {
let center = bounds.center();
let old_transform = world.get::<Transform>(entity).copied().unwrap_or_default();
@ -325,7 +325,8 @@ fn apply_bounds_result(
new_transform.translation = center;
new_transform.rotation = Quat::IDENTITY;
new_transform.scale = Vec3::ONE;
apply_brush_csg_with_history(world, entity, new_transform, new_brush, delete_entities);
set_transform_with_history(world, entity, old_transform, new_transform);
set_brush_with_history(world, entity, new_brush);
}
fn brush_world_bounds(world: &World, entity: Entity) -> Option<BrushBounds> {
@ -397,7 +398,6 @@ fn set_status(world: &mut World, status: impl Into<String>) {
#[cfg(test)]
mod tests {
use super::*;
use crate::history::EditorCommand;
fn bounds(min: Vec3, max: Vec3) -> BrushBounds {
BrushBounds { min, max }
@ -418,18 +418,4 @@ mod tests {
assert_eq!(result.min, Vec3::new(1.0, 0.0, 0.0));
assert_eq!(result.max, source.max);
}
#[test]
fn csg_history_command_is_grouped() {
let command = EditorCommand::ApplyBrushCsg {
primary: Entity::PLACEHOLDER,
old_transform: Transform::default(),
new_transform: Transform::from_translation(Vec3::X),
old_brush: Some(BrushDesc::default()),
new_brush: BrushDesc::default(),
deleted: Vec::new(),
};
assert_eq!(command.label(), "Brush CSG");
}
}

View File

@ -8,15 +8,15 @@ use shared::{
};
use crate::camera::EditorCamera;
use crate::history::{push_command, set_brush_transform_with_history, EditorCommand};
use crate::infra::EditorOnly;
use crate::history::{
push_command, set_brush_with_history, set_transform_with_history, EditorCommand,
};
use crate::operators::{ActiveOperator, OperatorPhase, OperatorStatus};
use crate::scene_io::SceneIo;
use crate::selection::ViewportClick;
use crate::state::scene_tools_active;
use crate::ui::{viewport_keyboard_shortcuts_active, UiState};
use crate::viewport::{scene_view_ray, ViewportDisplayMode};
use transform_gizmo_bevy::prelude::GizmoTarget;
#[derive(Resource, Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum BrushEditMode {
@ -100,33 +100,27 @@ impl Plugin for BrushEditPlugin {
fn build(&self, app: &mut App) {
app.init_resource::<BrushEditMode>()
.init_resource::<BrushElementSelection>()
.init_resource::<BrushElementGizmoState>()
.init_resource::<BrushElementDrag>()
.add_systems(
Update,
(
brush_edit_hotkeys,
brush_element_pick,
sync_brush_element_gizmo,
brush_element_drag,
brush_clip_commit,
draw_brush_edit_overlays,
)
.chain()
.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)]
struct BrushElementGizmoState {
entity: Option<Entity>,
struct BrushElementDrag {
brush: Option<Entity>,
old_brush: Option<BrushDesc>,
old_gizmo: Option<Transform>,
last_gizmo: Option<Transform>,
old: Option<BrushDesc>,
last_floor: Option<Vec3>,
changed: bool,
}
@ -147,9 +141,7 @@ fn brush_edit_hotkeys(
return Ok(());
}
let ctx = contexts.ctx_mut()?;
if !viewport_keyboard_shortcuts_active(&ui_state, ctx, &buttons)
|| ctx.egui_wants_keyboard_input()
{
if !viewport_keyboard_shortcuts_active(&ui_state, ctx, &buttons) || ctx.wants_keyboard_input() {
return Ok(());
}
if keys.just_pressed(KeyCode::Escape) && mode.is_element_mode() {
@ -243,160 +235,83 @@ fn brush_element_pick(
}
}
fn sync_brush_element_gizmo(
#[allow(clippy::too_many_arguments)]
fn brush_element_drag(
mut commands: Commands,
mode: Res<BrushEditMode>,
selection: Res<BrushElementSelection>,
mut state: ResMut<BrushElementGizmoState>,
brushes: Query<(&BrushDesc, &GlobalTransform)>,
mut gizmos: Query<&mut Transform, With<BrushElementGizmo>>,
mut drag: ResMut<BrushElementDrag>,
mut active_operator: ResMut<ActiveOperator>,
buttons: Res<ButtonInput<MouseButton>>,
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) {
clear_brush_element_gizmo(&mut commands, &mut state);
clear_drag(&mut drag);
return;
}
let Some(brush_entity) = selection.brush else {
clear_brush_element_gizmo(&mut commands, &mut state);
clear_drag(&mut drag);
return;
};
if selection.elements.is_empty() {
clear_brush_element_gizmo(&mut commands, &mut state);
clear_drag(&mut drag);
return;
}
let Ok((brush, brush_transform)) = brushes.get(brush_entity) else {
clear_brush_element_gizmo(&mut commands, &mut state);
let Some(pointer_pos) = ui_state.viewport_pointer_pos else {
return;
};
let Some(pivot) = selected_element_world_pivot(brush, brush_transform, &selection.elements)
let Some(current_floor) = pointer_floor_position(&cameras, pointer_pos, ui_state.viewport_rect)
else {
clear_brush_element_gizmo(&mut commands, &mut state);
return;
};
let rotation = brush_transform.to_scale_rotation_translation().1;
let desired = Transform::from_translation(pivot).with_rotation(rotation);
let needs_new_entity = state.brush != Some(brush_entity)
|| state
.entity
.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;
if buttons.just_pressed(MouseButton::Left) && drag.brush.is_none() {
if let Ok((brush, _)) = brushes.get(brush_entity) {
drag.brush = Some(brush_entity);
drag.old = Some(brush.clone());
drag.last_floor = Some(current_floor);
drag.changed = false;
}
return;
}
let Some(entity) = state.entity else {
if buttons.pressed(MouseButton::Left) && drag.brush == Some(brush_entity) {
let Some(last_floor) = drag.last_floor else {
drag.last_floor = Some(current_floor);
return;
};
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);
let world_delta = current_floor - last_floor;
if world_delta.length_squared() <= 0.000001 {
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;
};
if state.old_brush.is_none() {
state.old_brush = Some(brush.clone());
state.old_gizmo = Some(*gizmo_transform);
state.last_gizmo = Some(*gizmo_transform);
state.changed = false;
return;
}
let Some(last_gizmo) = state.last_gizmo else {
state.last_gizmo = Some(*gizmo_transform);
return;
};
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);
if let Ok((mut brush, transform)) = brushes.get_mut(brush_entity) {
let local_delta = transform.affine().inverse().transform_vector3(world_delta);
if move_selected_elements(&mut brush, &selection.elements, local_delta) {
drag.changed = true;
drag.last_floor = Some(current_floor);
set_brush_edit_status(
&mut active_operator,
OperatorPhase::Preview,
format!(
"Gizmo editing {} brush element(s)",
selection.elements.len()
),
format!("Dragging {} 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();
if buttons.just_released(MouseButton::Left) {
if let (Some(brush), Some(old)) = (drag.brush, drag.old.take()) {
if drag.changed {
if let Ok((mut brush_desc, _)) = brushes.get_mut(brush) {
let new = brush_desc.clone();
let validation = validate_brush(&new);
if validation.is_valid() {
commands.queue(move |world: &mut World| {
push_command(
world,
EditorCommand::SetBrush {
entity: brush_entity,
entity: brush,
old: Some(old),
new,
},
@ -405,14 +320,16 @@ fn apply_brush_element_gizmo(
set_brush_edit_status(
&mut active_operator,
OperatorPhase::Committed,
"Committed brush element gizmo edit",
"Committed brush element edit",
);
} else {
*brush = old;
*brush_desc = old;
let message = validation
.diagnostics
.iter()
.find(|diagnostic| diagnostic.severity == BrushDiagnosticSeverity::Error)
.find(|diagnostic| {
diagnostic.severity == BrushDiagnosticSeverity::Error
})
.map(|diagnostic| diagnostic.message.as_str())
.unwrap_or("Brush edit produced invalid geometry");
set_brush_edit_status(
@ -423,92 +340,16 @@ fn apply_brush_element_gizmo(
}
}
}
state.last_gizmo = Some(*gizmo_transform);
state.changed = false;
}
clear_drag(&mut drag);
}
}
fn clear_brush_element_gizmo(commands: &mut Commands, state: &mut BrushElementGizmoState) {
if let Some(entity) = state.entity.take() {
if let Ok(mut entity_mut) = commands.get_entity(entity) {
entity_mut.despawn();
}
}
state.brush = None;
state.old_brush = None;
state.old_gizmo = None;
state.last_gizmo = None;
state.changed = false;
}
fn selected_element_world_pivot(
brush: &BrushDesc,
transform: &GlobalTransform,
selected: &[BrushElementKey],
) -> Option<Vec3> {
let anchors = selected_anchor_positions(brush, selected);
if anchors.is_empty() {
return None;
}
let affine = transform.affine();
Some(
anchors
.iter()
.map(|anchor| affine.transform_point3(*anchor))
.sum::<Vec3>()
/ anchors.len() as f32,
)
}
fn gizmo_transform_is_active(state: &BrushElementGizmoState, transform: Transform) -> bool {
state
.last_gizmo
.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;
}
}
}
}
if changed {
recompute_face_planes(brush);
}
changed
}
fn transforms_nearly_equal(a: Transform, b: Transform) -> bool {
a.translation.distance_squared(b.translation) <= 0.000001
&& a.rotation.dot(b.rotation).abs() >= 0.99999
&& a.scale.distance_squared(b.scale) <= 0.000001
fn clear_drag(drag: &mut BrushElementDrag) {
drag.brush = None;
drag.old = None;
drag.last_floor = None;
drag.changed = false;
}
fn brush_clip_commit(
@ -540,7 +381,12 @@ fn brush_clip_commit(
};
scene_io.status = "Brush clip committed".into();
commands.queue(move |world: &mut World| {
set_brush_transform_with_history(world, brush_entity, result.transform, result.brush);
let old_transform = world
.get::<Transform>(brush_entity)
.copied()
.unwrap_or_default();
set_transform_with_history(world, brush_entity, old_transform, result.transform);
set_brush_with_history(world, brush_entity, result.brush);
});
}
@ -838,6 +684,50 @@ 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> {
let mut anchors = Vec::new();
for element in selected {
@ -1103,12 +993,10 @@ mod tests {
.count();
let face = brush.faces[0].id.clone();
let changed = transform_selected_elements_by_gizmo(
let changed = move_selected_elements(
&mut brush,
&[BrushElementKey::Vertex { face, index: 0 }],
&GlobalTransform::default(),
Transform::default(),
Transform::from_translation(Vec3::new(0.5, 0.0, 0.0)),
Vec3::new(0.5, 0.0, 0.0),
);
let after = brush
@ -1125,12 +1013,10 @@ mod tests {
fn collapsed_face_move_produces_invalid_brush() {
let mut brush = BrushDesc::default();
let face = brush.faces[0].id.clone();
assert!(transform_selected_elements_by_gizmo(
assert!(move_selected_elements(
&mut brush,
&[BrushElementKey::Face { face }],
&GlobalTransform::default(),
Transform::default(),
Transform::from_translation(Vec3::new(-1.0, 0.0, 0.0)),
Vec3::new(-1.0, 0.0, 0.0),
));
assert!(!validate_brush(&brush).is_valid());
}
@ -1145,17 +1031,4 @@ mod tests {
assert_eq!(result.transform.translation, Vec3::new(0.25, 0.0, 0.0));
assert!(validate_brush(&result.brush).is_valid());
}
#[test]
fn clip_history_command_is_grouped() {
let command = EditorCommand::SetBrushTransform {
entity: Entity::PLACEHOLDER,
old_transform: Transform::default(),
new_transform: Transform::from_translation(Vec3::X),
old_brush: Some(BrushDesc::default()),
new_brush: BrushDesc::default(),
};
assert_eq!(command.label(), "Clip Brush");
}
}

View File

@ -3,12 +3,11 @@
use bevy::prelude::*;
use bevy_egui::{egui, EguiContexts};
use shared::{
brush_math::{decompose_floor_polygon_to_convex, BrushPolygonError},
ActorKind, BrushDesc, EditorVisibility, MaterialDesc,
brush_math::validate_floor_polygon, ActorKind, BrushDesc, EditorVisibility, MaterialDesc,
};
use crate::camera::EditorCamera;
use crate::history::{spawn_many_with_history, EditorEntitySnapshot};
use crate::history::{spawn_with_history, EditorEntitySnapshot};
use crate::operators::{ActiveOperator, OperatorPhase, OperatorStatus};
use crate::scene_io::SceneIo;
use crate::selection::ViewportClick;
@ -17,35 +16,21 @@ use crate::ui::{viewport_keyboard_shortcuts_active, UiState};
use crate::viewport::{scene_view_ray, snap_translation, ViewportDisplayMode, ViewportSettings};
const DEFAULT_BRUSH_HEIGHT: f32 = 2.5;
const MIN_BRUSH_HEIGHT: f32 = 0.1;
const HEIGHT_PIXELS_TO_WORLD: f32 = 0.03;
const MIN_COMMIT_VERTICES: usize = 3;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BrushToolPhase {
Outline,
Height,
}
#[derive(Resource, Debug, Clone)]
pub struct BrushToolState {
pub active: bool,
pub phase: BrushToolPhase,
pub vertices: Vec<Vec3>,
pub height: f32,
height_anchor_pointer_y: Option<f32>,
height_anchor_value: f32,
}
impl Default for BrushToolState {
fn default() -> Self {
Self {
active: false,
phase: BrushToolPhase::Outline,
vertices: Vec::new(),
height: DEFAULT_BRUSH_HEIGHT,
height_anchor_pointer_y: None,
height_anchor_value: DEFAULT_BRUSH_HEIGHT,
}
}
}
@ -53,47 +38,13 @@ impl Default for BrushToolState {
impl BrushToolState {
pub fn start(&mut self) {
self.active = true;
self.phase = BrushToolPhase::Outline;
self.vertices.clear();
self.height = DEFAULT_BRUSH_HEIGHT;
self.height_anchor_pointer_y = None;
self.height_anchor_value = DEFAULT_BRUSH_HEIGHT;
}
pub fn cancel(&mut self) {
self.active = false;
self.phase = BrushToolPhase::Outline;
self.vertices.clear();
self.height = DEFAULT_BRUSH_HEIGHT;
self.height_anchor_pointer_y = None;
self.height_anchor_value = DEFAULT_BRUSH_HEIGHT;
}
fn start_height_phase(&mut self, pointer_pos: Option<egui::Pos2>) {
self.phase = BrushToolPhase::Height;
self.height = DEFAULT_BRUSH_HEIGHT;
self.height_anchor_value = DEFAULT_BRUSH_HEIGHT;
self.height_anchor_pointer_y = pointer_pos.map(|pos| pos.y);
}
fn return_to_outline_phase(&mut self) {
self.phase = BrushToolPhase::Outline;
self.height = DEFAULT_BRUSH_HEIGHT;
self.height_anchor_pointer_y = None;
self.height_anchor_value = DEFAULT_BRUSH_HEIGHT;
}
fn update_height_from_pointer(&mut self, pointer_pos: Option<egui::Pos2>) {
if self.phase != BrushToolPhase::Height {
return;
}
let Some(pointer_pos) = pointer_pos else {
return;
};
let anchor_y = self.height_anchor_pointer_y.get_or_insert(pointer_pos.y);
let height =
self.height_anchor_value + (*anchor_y - pointer_pos.y) * HEIGHT_PIXELS_TO_WORLD;
self.height = quantize_height(height);
}
}
@ -111,12 +62,12 @@ impl Plugin for BrushToolPlugin {
pub fn start_draw_brush_tool(world: &mut World) {
world.resource_mut::<BrushToolState>().start();
world.resource_mut::<SceneIo>().status =
"Draw Brush: click floor points, Enter sets height".to_string();
"Draw Brush: click floor points, Enter creates brush".to_string();
if let Some(mut active_operator) = world.get_resource_mut::<ActiveOperator>() {
set_brush_tool_status(
&mut active_operator,
OperatorPhase::Preview,
"Click floor points, Enter sets height, Esc cancels",
"Click floor points, Enter creates brush, Esc cancels",
);
}
if let Some(mut viewport_click) = world.get_resource_mut::<ViewportClick>() {
@ -148,17 +99,17 @@ fn brush_tool_input(
}
let ctx = contexts.ctx_mut()?;
let egui_keyboard_busy = ctx.egui_wants_keyboard_input();
let egui_keyboard_busy = ctx.wants_keyboard_input();
let keyboard_available =
viewport_keyboard_shortcuts_active(&ui_state, ctx, &buttons) && !egui_keyboard_busy;
if keyboard_available && keys.just_pressed(KeyCode::KeyB) {
tool.start();
scene_io.status = "Draw Brush: click floor points, Enter sets height".to_string();
scene_io.status = "Draw Brush: click floor points, Enter creates brush".to_string();
set_brush_tool_status(
&mut active_operator,
OperatorPhase::Preview,
"Click floor points, Enter sets height, Esc cancels",
"Click floor points, Enter creates brush, Esc cancels",
);
viewport_click.0 = None;
return Ok(());
@ -167,7 +118,6 @@ fn brush_tool_input(
if !tool.active {
return Ok(());
}
tool.update_height_from_pointer(ui_state.viewport_pointer_pos);
if (!egui_keyboard_busy && keys.just_pressed(KeyCode::Escape))
|| buttons.just_pressed(MouseButton::Right)
@ -180,53 +130,15 @@ fn brush_tool_input(
}
if !egui_keyboard_busy && keys.just_pressed(KeyCode::Backspace) {
if tool.phase == BrushToolPhase::Height {
tool.return_to_outline_phase();
} else {
tool.vertices.pop();
}
let status = draw_brush_status(&tool);
scene_io.status = status.clone();
set_brush_tool_status(&mut active_operator, OperatorPhase::Preview, status);
viewport_click.0 = None;
return Ok(());
}
if tool.phase == BrushToolPhase::Height {
if buttons.just_pressed(MouseButton::Left)
|| (!egui_keyboard_busy && keys.just_pressed(KeyCode::Enter))
{
match brush_snapshots_from_points(&tool.vertices, tool.height) {
Ok(snapshots) => {
let brush_count = snapshots.len();
let height = tool.height;
commands.queue(move |world: &mut World| {
spawn_many_with_history(world, snapshots);
});
tool.cancel();
scene_io.status = brush_created_status(brush_count, height);
set_brush_tool_status(
&mut active_operator,
OperatorPhase::Committed,
brush_committed_status(brush_count, height),
OperatorPhase::Preview,
brush_tool_hint(tool.vertices.len()),
);
}
Err(error) => {
let message = format!("Draw Brush failed: {error}");
warn!("{message}; points={:?}", tool.vertices);
scene_io.status = message.clone();
set_brush_tool_status(&mut active_operator, OperatorPhase::Blocked, message);
}
}
viewport_click.0 = None;
return Ok(());
}
viewport_click.0 = None;
let status = draw_brush_status(&tool);
scene_io.status = status.clone();
set_brush_tool_status(&mut active_operator, OperatorPhase::Preview, status);
return Ok(());
}
if let Some(pointer_pos) = viewport_click.0.take() {
if let Some(mut point) =
@ -234,29 +146,32 @@ fn brush_tool_input(
{
point = snap_translation(point, &settings);
tool.vertices.push(point);
let status = draw_brush_status(&tool);
scene_io.status = status.clone();
set_brush_tool_status(&mut active_operator, OperatorPhase::Preview, status);
scene_io.status = format!("Draw Brush: {} point(s)", tool.vertices.len());
set_brush_tool_status(
&mut active_operator,
OperatorPhase::Preview,
brush_tool_hint(tool.vertices.len()),
);
}
}
if !egui_keyboard_busy && keys.just_pressed(KeyCode::Enter) {
match decompose_floor_polygon_to_convex(&tool.vertices) {
Ok(parts) => {
let part_count = parts.len();
tool.start_height_phase(ui_state.viewport_pointer_pos);
scene_io.status = draw_brush_status(&tool);
match brush_snapshot_from_points(&tool.vertices, tool.height) {
Ok(snapshot) => {
commands.queue(move |world: &mut World| {
spawn_with_history(world, snapshot);
});
tool.cancel();
scene_io.status = "Brush created".to_string();
set_brush_tool_status(
&mut active_operator,
OperatorPhase::Preview,
height_phase_status(part_count, tool.height),
OperatorPhase::Committed,
"Committed brush.draw",
);
}
Err(error) => {
let message = format!("Draw Brush failed: {}", floor_polygon_error_message(error));
warn!("{message}; points={:?}", tool.vertices);
scene_io.status = message.clone();
set_brush_tool_status(&mut active_operator, OperatorPhase::Blocked, message);
scene_io.status = format!("Draw Brush failed: {error}");
set_brush_tool_status(&mut active_operator, OperatorPhase::Blocked, error);
}
}
}
@ -267,7 +182,6 @@ fn brush_tool_input(
fn draw_brush_tool_preview(
tool: Res<BrushToolState>,
ui_state: Res<UiState>,
settings: Res<ViewportSettings>,
cameras: Query<(&Camera, &GlobalTransform), With<EditorCamera>>,
mut gizmos: Gizmos,
) {
@ -275,20 +189,15 @@ fn draw_brush_tool_preview(
return;
}
let mut points = tool.vertices.clone();
if tool.phase == BrushToolPhase::Outline {
if let Some(pointer_pos) = ui_state.viewport_pointer_pos {
if let Some(mut point) =
pointer_floor_position(&cameras, pointer_pos, ui_state.viewport_rect)
{
point = snap_translation(point, &settings);
if let Some(point) = pointer_floor_position(&cameras, pointer_pos, ui_state.viewport_rect) {
points.push(point);
}
}
}
if points.is_empty() {
return;
}
let color = brush_preview_color(&points);
let color = Color::srgb(0.2, 0.72, 1.0);
for point in &points {
gizmos.sphere(*point + Vec3::Y * 0.02, 0.08, color);
gizmos.line(*point, *point + Vec3::Y * tool.height, color);
@ -310,11 +219,6 @@ fn draw_brush_tool_preview(
first + Vec3::Y * tool.height,
color,
);
if let Ok(parts) = decompose_floor_polygon_to_convex(&points) {
if parts.len() > 1 {
draw_decomposition_preview(&parts, tool.height, &mut gizmos);
}
}
}
}
@ -336,31 +240,11 @@ fn pointer_floor_position(
Some(ray.origin + direction * t.max(0.0))
}
fn brush_snapshots_from_points(
fn brush_snapshot_from_points(
points: &[Vec3],
height: f32,
) -> Result<Vec<EditorEntitySnapshot>, String> {
let parts = decompose_floor_polygon_to_convex(points).map_err(floor_polygon_error_message)?;
let part_count = parts.len();
parts
.iter()
.enumerate()
.map(|(index, part)| {
let name = if part_count == 1 {
"Brush".to_string()
} else {
format!("Brush Part {}", index + 1)
};
brush_snapshot_from_convex_points(part, height, name)
})
.collect()
}
fn brush_snapshot_from_convex_points(
points: &[Vec3],
height: f32,
name: String,
) -> Result<EditorEntitySnapshot, String> {
validate_floor_polygon(points).map_err(|error| format!("{error:?}"))?;
let center = polygon_center(points);
let local_points: Vec<Vec3> = points.iter().map(|point| *point - center).collect();
let brush = BrushDesc::extruded_prism(&local_points, height)
@ -369,7 +253,7 @@ fn brush_snapshot_from_convex_points(
actor_id: None,
actor_kind: ActorKind::Brush,
actor_name: None,
name: Some(name),
name: Some("Brush".to_string()),
transform: Transform::from_translation(center),
primitive: None,
brush: Some(brush),
@ -403,115 +287,12 @@ fn polygon_center(points: &[Vec3]) -> Vec3 {
sum / points.len() as f32
}
fn quantize_height(height: f32) -> f32 {
((height.max(MIN_BRUSH_HEIGHT) * 10.0).round() / 10.0).max(MIN_BRUSH_HEIGHT)
}
fn brush_tool_hint(vertex_count: usize) -> String {
match vertex_count {
0 => "Click first floor point".to_string(),
1 => "Click second floor point".to_string(),
2 => "Click third floor point".to_string(),
count => format!("{count} points, Enter sets height"),
}
}
fn draw_brush_status(tool: &BrushToolState) -> String {
if tool.phase == BrushToolPhase::Height {
let part_count = decompose_floor_polygon_to_convex(&tool.vertices)
.map(|parts| parts.len())
.unwrap_or(1);
return height_phase_status(part_count, tool.height);
}
let points = &tool.vertices;
if points.len() < MIN_COMMIT_VERTICES {
return format!("Draw Brush: {}", brush_tool_hint(points.len()));
}
match decompose_floor_polygon_to_convex(points) {
Ok(parts) if parts.len() == 1 => {
format!("Draw Brush: {} points, Enter creates brush", points.len())
}
Ok(parts) => format!(
"Draw Brush: {} points, Enter creates {} convex brush parts",
points.len(),
parts.len()
),
Err(error) => format!("Draw Brush blocked: {}", floor_polygon_error_message(error)),
}
}
fn height_phase_status(part_count: usize, height: f32) -> String {
if part_count == 1 {
format!("Draw Brush height: {height:.1}m, Enter/LMB creates brush")
} else {
format!("Draw Brush height: {height:.1}m, Enter/LMB creates {part_count} convex parts")
}
}
fn brush_created_status(brush_count: usize, height: f32) -> String {
if brush_count == 1 {
format!("Brush created at {height:.1}m")
} else {
format!("Brush created as {brush_count} convex parts at {height:.1}m")
}
}
fn brush_committed_status(brush_count: usize, height: f32) -> String {
if brush_count == 1 {
format!("Committed brush.draw ({height:.1}m)")
} else {
format!("Committed brush.draw ({brush_count} parts, {height:.1}m)")
}
}
fn brush_preview_color(points: &[Vec3]) -> Color {
if points.len() < MIN_COMMIT_VERTICES {
return Color::srgb(0.2, 0.72, 1.0);
}
match decompose_floor_polygon_to_convex(points) {
Ok(_) => Color::srgb(0.2, 0.72, 1.0),
Err(_) => Color::srgb(1.0, 0.25, 0.2),
}
}
fn draw_decomposition_preview(parts: &[Vec<Vec3>], height: f32, gizmos: &mut Gizmos) {
let color = Color::srgb(0.15, 1.0, 0.65);
for part in parts {
for window in part.windows(2) {
gizmos.line(
window[0] + Vec3::Y * 0.04,
window[1] + Vec3::Y * 0.04,
color,
);
gizmos.line(
window[0] + Vec3::Y * height,
window[1] + Vec3::Y * height,
color,
);
}
if let (Some(first), Some(last)) = (part.first(), part.last()) {
gizmos.line(*last + Vec3::Y * 0.04, *first + Vec3::Y * 0.04, color);
gizmos.line(*last + Vec3::Y * height, *first + Vec3::Y * height, color);
}
}
}
fn floor_polygon_error_message(error: BrushPolygonError) -> String {
match error {
BrushPolygonError::TooFewVertices => {
"need at least three points before creating a brush".into()
}
BrushPolygonError::NonFiniteVertex => "one or more points are not finite".into(),
BrushPolygonError::DuplicateVertex => {
"two points overlap; remove or move the duplicate point".into()
}
BrushPolygonError::ZeroArea => "polygon area is zero; points must enclose an area".into(),
BrushPolygonError::SelfIntersecting => {
"polygon edges cross; draw points around the perimeter in order".into()
}
BrushPolygonError::NonConvex => {
"concave outline could not be decomposed into convex brush parts".into()
}
count => format!("{count} points, Enter creates brush"),
}
}
@ -535,7 +316,7 @@ mod tests {
#[test]
fn brush_snapshot_centers_polygon_points() {
let snapshots = brush_snapshots_from_points(
let snapshot = brush_snapshot_from_points(
&[
Vec3::new(0.0, 0.0, 0.0),
Vec3::new(2.0, 0.0, 0.0),
@ -545,8 +326,6 @@ mod tests {
3.0,
)
.expect("valid brush");
assert_eq!(snapshots.len(), 1);
let snapshot = &snapshots[0];
assert_eq!(snapshot.actor_kind, ActorKind::Brush);
assert_eq!(snapshot.transform.translation, Vec3::new(1.0, 0.0, 1.0));
@ -554,8 +333,8 @@ mod tests {
}
#[test]
fn brush_snapshot_decomposes_non_convex_polygon() {
let snapshots = brush_snapshots_from_points(
fn brush_snapshot_rejects_non_convex_polygon() {
let result = brush_snapshot_from_points(
&[
Vec3::new(-1.0, 0.0, -1.0),
Vec3::new(1.0, 0.0, -1.0),
@ -564,67 +343,8 @@ mod tests {
Vec3::new(-1.0, 0.0, 1.0),
],
2.0,
)
.expect("concave brush parts");
assert_eq!(snapshots.len(), 2);
assert!(snapshots.iter().all(|snapshot| snapshot.brush.is_some()));
}
#[test]
fn brush_snapshot_reports_actionable_invalid_polygon_reason() {
let result = brush_snapshots_from_points(
&[
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),
],
2.0,
);
assert_eq!(
result.unwrap_err(),
"polygon edges cross; draw points around the perimeter in order"
);
}
#[test]
fn draw_brush_status_explains_invalid_shape() {
let tool = BrushToolState {
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),
],
..Default::default()
};
let status = draw_brush_status(&tool);
assert!(status.contains("polygon edges cross"));
}
#[test]
fn height_phase_status_reports_height_and_part_count() {
let mut tool = BrushToolState {
active: true,
phase: BrushToolPhase::Height,
height: 4.2,
vertices: vec![
Vec3::new(-1.0, 0.0, -1.0),
Vec3::new(1.0, 0.0, -1.0),
Vec3::new(0.0, 0.0, 0.0),
Vec3::new(1.0, 0.0, 1.0),
Vec3::new(-1.0, 0.0, 1.0),
],
..Default::default()
};
tool.height = quantize_height(tool.height);
let status = draw_brush_status(&tool);
assert!(status.contains("4.2m"));
assert!(status.contains("2 convex parts"));
assert!(result.is_err());
}
}

View File

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

View File

@ -154,10 +154,6 @@ 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(
keys: Res<ButtonInput<KeyCode>>,
buttons: Res<ButtonInput<MouseButton>>,

View File

@ -5,17 +5,16 @@
use std::collections::HashSet;
use bevy::anti_alias::taa::TemporalAntiAliasing;
use bevy::camera::Hdr;
use bevy::core_pipeline::tonemapping::Tonemapping;
use bevy::light::atmosphere::ScatteringMedium;
use bevy::pbr::{AtmosphereSettings, ScreenSpaceAmbientOcclusion};
use bevy::pbr::{Atmosphere, ScatteringMedium, ScreenSpaceAmbientOcclusion};
use bevy::post_process::auto_exposure::AutoExposure;
use bevy::post_process::bloom::Bloom;
use bevy::prelude::*;
use bevy::render::view::Hdr;
use game::rendering::{
clear_viewport_camera_stack, has_local_shadow_lights, resolve_viewport_camera_owner,
sync_project_atmosphere, sync_viewport_camera_stack, ProjectAtmosphere,
SolariRaytracingSceneStats, ViewportCameraOwner, ViewportFxSnapshot, ViewportStackApply,
sync_viewport_camera_stack, SolariRaytracingSceneStats, ViewportCameraOwner,
ViewportFxSnapshot, ViewportStackApply,
};
use settings::{
ActiveCameraRenderProfile, EffectiveRenderStack, GiPath, ProjectRenderCamera, ProjectSettings,
@ -66,7 +65,7 @@ type CameraFxState = (bool, bool, bool, bool, bool, bool, bool, bool);
fn fx_snapshot(state: CameraFxState) -> ViewportFxSnapshot {
ViewportFxSnapshot {
has_stack: state.0,
has_atmosphere_settings: state.1,
has_atmosphere: state.1,
has_tonemapping: state.2,
has_bloom: state.3,
has_ssao: state.4,
@ -102,10 +101,9 @@ fn sync_project_render_view(
editor_cameras: Query<Entity, With<EditorCamera>>,
player_cameras: Query<Entity, With<PlayerCamera>>,
stacked: Query<Entity, With<ProjectRenderCamera>>,
atmospheres: Query<Entity, With<ProjectAtmosphere>>,
camera_fx: Query<(
Has<ProjectRenderCamera>,
Has<AtmosphereSettings>,
Has<Atmosphere>,
Has<Tonemapping>,
Has<Bloom>,
Has<ScreenSpaceAmbientOcclusion>,
@ -191,12 +189,6 @@ fn sync_project_render_view(
|| solari_readiness_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;
for entity in &active {
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(
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.19.",
"Solari deferred: directional lights and emissive meshes affect lighting; point/spot LightDesc components are disabled in Bevy 0.18.",
);
} else if stack.fallback_reason == Some(RenderFallbackReason::RtUnsupported)
|| (!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
.query::<&DirectionalLight>()
.iter(world)
.filter(|light| light.shadow_maps_enabled)
.filter(|light| light.shadows_enabled)
.count();
let shadowed_point = world
.query::<&PointLight>()
.iter(world)
.filter(|light| light.shadow_maps_enabled)
.filter(|light| light.shadows_enabled)
.count();
let shadowed_spot = world
.query::<&SpotLight>()
.iter(world)
.filter(|light| light.shadow_maps_enabled)
.filter(|light| light.shadows_enabled)
.count();
ui.label(format!(
"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::UiState;
use crate::viewport::actor_icons::ActorIconProxy;
use crate::viewport::brush_edit::{BrushEditMode, BrushElementGizmo};
use crate::viewport::brush_edit::BrushEditMode;
use crate::viewport::brush_tool::BrushToolState;
use crate::viewport::ViewportDisplayMode;
use crate::visualizers::EditorVisualizerProxy;
@ -238,10 +238,6 @@ fn cycle_overlapping_viewport_pick(
Ok(())
}
#[expect(
clippy::too_many_arguments,
reason = "viewport picking combines explicit camera, ray-cast, selection, and input state"
)]
fn apply_viewport_pick(
hierarchy: Option<&HierarchyPanelState>,
ui_state: &mut UiState,
@ -373,40 +369,30 @@ fn pick_target_for_entity(entity: Entity, pick_targets: &PickTargetQueries) -> O
None
}
#[expect(
clippy::too_many_arguments,
reason = "Bevy system parameters represent independent selection and gizmo state"
)]
fn sync_gizmo_targets(
mut ui_state: ResMut<UiState>,
mut selected: ResMut<SelectedEntity>,
mut commands: Commands,
targets: Query<(Entity, Option<&BrushElementGizmo>), With<GizmoTarget>>,
targets: Query<Entity, With<GizmoTarget>>,
level_objects: Query<(), (With<LevelObject>, Without<EditorOnly>)>,
editor_only: Query<(), With<EditorOnly>>,
transforms: Query<(), With<Transform>>,
hierarchy: Option<Res<HierarchyPanelState>>,
display: Res<ViewportDisplayMode>,
brush_mode: Res<BrushEditMode>,
) {
ui_state
.selected_entities
.retain(|entity| transforms.contains(entity) && !editor_only.contains(entity));
selected.0 = ui_state.selected_entities.as_slice().first().copied();
if display.clean_game_view || brush_mode.is_element_mode() {
for (entity, brush_element_gizmo) in &targets {
if brush_element_gizmo.is_none() {
if display.clean_game_view {
for entity in &targets {
commands.entity(entity).remove::<GizmoTarget>();
}
}
return;
}
for (entity, brush_element_gizmo) in &targets {
if brush_element_gizmo.is_some() {
continue;
}
for entity in &targets {
if !ui_state.selected_entities.contains(entity) {
commands.entity(entity).remove::<GizmoTarget>();
}

View File

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

View File

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

View File

@ -16,10 +16,9 @@ pub mod rendering {
clear_viewport_camera_stack, effective_gi_path_for_camera, effective_viewport_gi_path,
effective_viewport_render_stack, has_local_shadow_lights, hdr_enabled_profile,
patch_camera_effects, resolve_viewport_camera_owner, strip_project_camera_fx,
sync_optional_rendering_fx, sync_project_atmosphere, sync_viewport_camera_stack,
world_has_local_shadow_lights, FullscreenEffectsPlugin, ProjectAtmosphere,
SolariRaytracingSceneStats, SolariRenderingPlugin, ViewportCameraOwner, ViewportFxSnapshot,
ViewportStackApply, HDR_ENV_VAR,
sync_optional_rendering_fx, sync_viewport_camera_stack, world_has_local_shadow_lights,
FullscreenEffectsPlugin, SolariRaytracingSceneStats, SolariRenderingPlugin,
ViewportCameraOwner, ViewportFxSnapshot, ViewportStackApply, HDR_ENV_VAR,
};
}
@ -67,7 +66,7 @@ mod hot {
use bevy::prelude::*;
use bevy::time::Fixed;
use bevy::window::{CursorOptions, PrimaryWindow};
use game_hot::{ProjectAtmosphere, SolariRaytracingSceneStats};
use game_hot::SolariRaytracingSceneStats;
use protocol::PlayerInputIntent;
use settings::{
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,
resolve_viewport_camera_owner, sample_volumes_at, setup_project_camera_effects,
snapshot_viewport_fx, strip_project_camera_fx, sync_optional_rendering_fx,
sync_project_atmosphere, sync_viewport_camera_stack, tag_player_camera,
world_has_local_shadow_lights, AutoExposureRenderingPlugin, FullscreenEffectsPlugin,
ProjectAtmosphere, SolariRaytracingSceneStats, SolariRenderingPlugin, ViewportCameraOwner,
ViewportFxSnapshot, ViewportStackApply, HDR_ENV_VAR,
sync_viewport_camera_stack, tag_player_camera, world_has_local_shadow_lights,
AutoExposureRenderingPlugin, FullscreenEffectsPlugin, SolariRaytracingSceneStats,
SolariRenderingPlugin, ViewportCameraOwner, ViewportFxSnapshot, ViewportStackApply,
HDR_ENV_VAR,
};
pub use sim_systems::{apply_player_intent, move_and_slide, sync_player_physics_position};
pub use world::{

View File

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

View File

@ -1,13 +1,13 @@
//! Assembles the high-fidelity rendering stack on project cameras.
use bevy::anti_alias::taa::TemporalAntiAliasing;
use bevy::camera::{Exposure, Hdr};
use bevy::camera::Exposure;
use bevy::core_pipeline::tonemapping::Tonemapping;
use bevy::light::{atmosphere::ScatteringMedium, Atmosphere};
use bevy::pbr::{AtmosphereSettings, ScreenSpaceAmbientOcclusion};
use bevy::pbr::{Atmosphere, AtmosphereSettings, ScatteringMedium, ScreenSpaceAmbientOcclusion};
use bevy::post_process::auto_exposure::AutoExposure;
use bevy::post_process::bloom::Bloom;
use bevy::prelude::*;
use bevy::render::view::Hdr;
use settings::{
effective_auto_exposure, ActiveCameraRenderProfile, GiPath, ProjectRenderCamera,
ProjectSettings, RenderingCapabilities, RenderingSettings,
@ -20,10 +20,6 @@ use super::fullscreen_effects::{strip_fullscreen_effects, sync_fullscreen_effect
use super::solari::{strip_gi_path, sync_gi_path, SolariRaytracingSceneStats};
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).
pub const HDR_ENV_VAR: &str = "BEVY_FPS_HDR";
@ -77,10 +73,6 @@ pub fn hdr_required_for_render_stack(
/// Applies the shared project rendering profile to all [`ProjectRenderCamera`] entities.
#[unsafe(no_mangle)]
#[expect(
clippy::too_many_arguments,
reason = "Bevy systems expose independent render resources and light queries"
)]
pub fn setup_project_camera_effects(
mut commands: Commands,
mut mediums: ResMut<Assets<ScatteringMedium>>,
@ -89,7 +81,6 @@ pub fn setup_project_camera_effects(
caps: Option<Res<RenderingCapabilities>>,
solari_stats: Option<Res<SolariRaytracingSceneStats>>,
cameras: Query<Entity, With<ProjectRenderCamera>>,
atmospheres: Query<Entity, With<ProjectAtmosphere>>,
points: Query<&PointLight>,
spots: Query<&SpotLight>,
) {
@ -102,12 +93,6 @@ pub fn setup_project_camera_effects(
)
});
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 {
apply_camera_render_profile(
&mut commands,
@ -121,29 +106,6 @@ 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.
pub fn sync_exposure(
commands: &mut Commands,
@ -173,7 +135,7 @@ pub fn apply_camera_render_profile(
profile: &ActiveCameraRenderProfile,
caps: &RenderingCapabilities,
solari_stats: Option<&SolariRaytracingSceneStats>,
_mediums: &mut Assets<ScatteringMedium>,
mediums: &mut Assets<ScatteringMedium>,
has_local_shadow_lights: bool,
) {
commands.entity(entity).insert(Msaa::Off);
@ -193,15 +155,13 @@ pub fn apply_camera_render_profile(
});
if profile.atmosphere {
let medium = mediums.add(ScatteringMedium::default());
commands
.entity(entity)
.insert(Atmosphere::earthlike(medium));
commands
.entity(entity)
.remove::<Atmosphere>()
.insert(AtmosphereSettings::default());
} else {
commands
.entity(entity)
.remove::<Atmosphere>()
.remove::<AtmosphereSettings>();
}
if profile.tonemapping_aces {
commands.entity(entity).insert(Tonemapping::AcesFitted);
@ -285,8 +245,8 @@ pub fn sync_optional_rendering_fx(
profile: &ActiveCameraRenderProfile,
caps: &RenderingCapabilities,
solari_stats: Option<&SolariRaytracingSceneStats>,
_mediums: &mut Assets<ScatteringMedium>,
has_atmosphere_settings: bool,
mediums: &mut Assets<ScatteringMedium>,
has_atmosphere: bool,
has_tonemapping: bool,
has_bloom: bool,
has_ssao: bool,
@ -298,18 +258,19 @@ pub fn sync_optional_rendering_fx(
let effective_gi =
effective_viewport_gi_path(profile.gi_path, caps, solari_stats, has_local_shadow_lights);
if profile.atmosphere && !has_atmosphere_settings {
if profile.atmosphere && !has_atmosphere {
let medium = mediums.add(ScatteringMedium::default());
commands
.entity(entity)
.insert(Atmosphere::earthlike(medium));
commands
.entity(entity)
.remove::<Atmosphere>()
.insert(AtmosphereSettings::default());
} else if !profile.atmosphere && has_atmosphere_settings {
} else if !profile.atmosphere && has_atmosphere {
commands
.entity(entity)
.remove::<Atmosphere>()
.remove::<AtmosphereSettings>();
} else if profile.atmosphere {
commands.entity(entity).remove::<Atmosphere>();
}
if profile.tonemapping_aces && !has_tonemapping {
@ -404,6 +365,31 @@ 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.
pub fn refresh_player_camera_fx(world: &mut World) {
let Some(settings) = world.get_resource::<ProjectSettings>().cloned() else {
@ -433,16 +419,6 @@ pub fn refresh_player_camera_fx(world: &mut World) {
}
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 {
apply_camera_render_profile(
&mut world.commands(),
@ -456,28 +432,3 @@ 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::render::extract_component::ExtractComponent;
use bevy::render::render_graph::{InternedRenderLabel, RenderLabel, RenderSubGraph};
use bevy::render::render_resource::ShaderType;
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::tonemapping::tonemapping;
use bevy_core_pipeline::Core3dSystems;
use shared::{PostProcessEffectAsset, PostProcessEffectKind};
/// Vignette fullscreen pass.
@ -21,10 +21,16 @@ impl FullscreenMaterial for VignetteFx {
"post_fx/vignette.wgsl".into()
}
fn schedule_configs(
system: bevy::ecs::schedule::ScheduleConfigs<bevy::ecs::system::BoxedSystem>,
) -> bevy::ecs::schedule::ScheduleConfigs<bevy::ecs::system::BoxedSystem> {
system.in_set(Core3dSystems::PostProcess).after(tonemapping)
fn node_edges() -> Vec<InternedRenderLabel> {
vec![
Node3d::Tonemapping.intern(),
Self::node_label().intern(),
Node3d::EndMainPassPostProcessing.intern(),
]
}
fn sub_graph() -> Option<bevy::render::render_graph::InternedRenderSubGraph> {
Some(Core3d.intern())
}
}
@ -40,10 +46,16 @@ impl FullscreenMaterial for ChromaticAberrationFx {
"post_fx/chromatic_aberration.wgsl".into()
}
fn schedule_configs(
system: bevy::ecs::schedule::ScheduleConfigs<bevy::ecs::system::BoxedSystem>,
) -> bevy::ecs::schedule::ScheduleConfigs<bevy::ecs::system::BoxedSystem> {
system.in_set(Core3dSystems::PostProcess).after(tonemapping)
fn node_edges() -> Vec<InternedRenderLabel> {
vec![
Node3d::Tonemapping.intern(),
Self::node_label().intern(),
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,
hdr_enabled_profile, patch_camera_effects, refresh_player_camera_fx,
setup_project_camera_effects, strip_project_camera_fx, sync_optional_rendering_fx,
sync_project_atmosphere, tag_player_camera, ProjectAtmosphere, HDR_ENV_VAR,
tag_player_camera, HDR_ENV_VAR,
};
pub use fullscreen_effects::FullscreenEffectsPlugin;
pub use solari::{effective_gi_path_for_camera, SolariRaytracingSceneStats, SolariRenderingPlugin};

View File

@ -204,10 +204,6 @@ 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(
caps: Res<RenderingCapabilities>,
mut commands: Commands,
@ -514,9 +510,7 @@ mod tests {
let mut state: SystemState<(Query<&ChildOf>, Query<(), With<LevelObject>>)> =
SystemState::new(&mut world);
let (parents, level_roots) = state
.get(&world)
.expect("solari test system params should be valid");
let (parents, level_roots) = state.get(&world);
assert!(has_level_object_ancestor(child, &parents, &level_roots));
assert!(has_level_object_ancestor(root, &parents, &level_roots));
@ -529,9 +523,7 @@ mod tests {
let mut state: SystemState<(Query<&ChildOf>, Query<(), With<LevelObject>>)> =
SystemState::new(&mut world);
let (parents, level_roots) = state
.get(&world)
.expect("solari test system params should be valid");
let (parents, level_roots) = state.get(&world);
assert!(!has_level_object_ancestor(orphan, &parents, &level_roots));
}

View File

@ -6,13 +6,13 @@ use super::camera_fx::{
};
use super::solari::SolariRaytracingSceneStats;
use bevy::anti_alias::taa::TemporalAntiAliasing;
use bevy::camera::{Exposure, Hdr};
use bevy::camera::Exposure;
use bevy::core_pipeline::tonemapping::Tonemapping;
use bevy::light::atmosphere::ScatteringMedium;
use bevy::pbr::{AtmosphereSettings, ScreenSpaceAmbientOcclusion};
use bevy::pbr::{Atmosphere, ScatteringMedium, ScreenSpaceAmbientOcclusion};
use bevy::post_process::auto_exposure::AutoExposure;
use bevy::post_process::bloom::Bloom;
use bevy::prelude::*;
use bevy::render::view::Hdr;
use settings::{
resolve_effective_render_stack, write_effective_render_stack, ActiveCameraRenderProfile,
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.
pub fn has_local_shadow_lights(points: &Query<&PointLight>, spots: &Query<&SpotLight>) -> bool {
points.iter().any(|l| l.shadow_maps_enabled) || spots.iter().any(|l| l.shadow_maps_enabled)
points.iter().any(|l| l.shadows_enabled) || spots.iter().any(|l| l.shadows_enabled)
}
/// [`has_local_shadow_lights`] for exclusive [`World`] access (hot reload, diagnostics).
pub fn world_has_local_shadow_lights(world: &mut World) -> bool {
for pl in world.query::<&PointLight>().iter(world) {
if pl.shadow_maps_enabled {
if pl.shadows_enabled {
return true;
}
}
for sl in world.query::<&SpotLight>().iter(world) {
if sl.shadow_maps_enabled {
if sl.shadows_enabled {
return true;
}
}
@ -92,7 +92,7 @@ pub fn clear_viewport_camera_stack(commands: &mut Commands, entity: Entity) {
#[derive(Debug, Clone, Copy, Default)]
pub struct ViewportFxSnapshot {
pub has_stack: bool,
pub has_atmosphere_settings: bool,
pub has_atmosphere: bool,
pub has_tonemapping: bool,
pub has_bloom: bool,
pub has_ssao: bool,
@ -156,7 +156,7 @@ pub fn sync_viewport_camera_stack(
caps,
solari_stats,
mediums,
snap.has_atmosphere_settings,
snap.has_atmosphere,
snap.has_tonemapping,
snap.has_bloom,
snap.has_ssao,
@ -203,19 +203,6 @@ pub fn sync_viewport_camera_stack(
}
/// 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)]
mod tests {
use super::*;
@ -243,3 +230,16 @@ 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,10 +252,6 @@ pub fn resolve_active_camera_render_profile(
}
/// 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(
mut commands: Commands,
settings: Res<ProjectSettings>,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -125,20 +125,6 @@ pub fn validate_brush(brush: &BrushDesc) -> BrushValidationReport {
"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);
@ -166,196 +152,33 @@ pub fn signed_area_xz(vertices: &[Vec3]) -> f32 {
* 0.5
}
pub fn normalize_floor_polygon(vertices: &[Vec3]) -> Result<Vec<Vec3>, BrushPolygonError> {
pub fn validate_floor_polygon(vertices: &[Vec3]) -> Result<(), BrushPolygonError> {
if vertices.len() < 3 {
return Err(BrushPolygonError::TooFewVertices);
}
if !vertices.iter().all(|vertex| vertex.is_finite()) {
return Err(BrushPolygonError::NonFiniteVertex);
}
let mut normalized = Vec::with_capacity(vertices.len());
for vertex in vertices {
if normalized.last().is_some_and(|previous: &Vec3| {
previous.xz().distance_squared(vertex.xz()) <= EPSILON * EPSILON
}) {
continue;
}
normalized.push(*vertex);
}
if normalized.len() >= 2
&& normalized[0]
.xz()
.distance_squared(normalized[normalized.len() - 1].xz())
<= EPSILON * EPSILON
{
normalized.pop();
}
if normalized.len() < 3 {
return Err(BrushPolygonError::TooFewVertices);
}
for i in 0..normalized.len() {
for j in (i + 1)..normalized.len() {
if normalized[i].xz().distance_squared(normalized[j].xz()) <= EPSILON * EPSILON {
for i in 0..vertices.len() {
for j in (i + 1)..vertices.len() {
if vertices[i].xz().distance_squared(vertices[j].xz()) <= EPSILON * EPSILON {
return Err(BrushPolygonError::DuplicateVertex);
}
}
}
Ok(normalized)
}
pub fn validate_simple_floor_polygon(vertices: &[Vec3]) -> Result<(), BrushPolygonError> {
let vertices = normalize_floor_polygon(vertices)?;
let area = signed_area_xz(&vertices);
if self_intersects_xz(&vertices) {
let area = signed_area_xz(vertices);
if self_intersects_xz(vertices) {
return Err(BrushPolygonError::SelfIntersecting);
}
if area.abs() <= EPSILON {
return Err(BrushPolygonError::ZeroArea);
}
Ok(())
}
pub fn validate_floor_polygon(vertices: &[Vec3]) -> Result<(), BrushPolygonError> {
let vertices = normalize_floor_polygon(vertices)?;
validate_simple_floor_polygon(&vertices)?;
if !is_convex_xz(&vertices) {
if !is_convex_xz(vertices) {
return Err(BrushPolygonError::NonConvex);
}
Ok(())
}
pub fn decompose_floor_polygon_to_convex(
vertices: &[Vec3],
) -> Result<Vec<Vec<Vec3>>, BrushPolygonError> {
let mut vertices = normalize_floor_polygon(vertices)?;
validate_simple_floor_polygon(&vertices)?;
if is_convex_xz(&vertices) {
if signed_area_xz(&vertices) < 0.0 {
vertices.reverse();
}
return Ok(vec![vertices]);
}
if signed_area_xz(&vertices) < 0.0 {
vertices.reverse();
}
let mut remaining = vertices;
let mut triangles = Vec::with_capacity(remaining.len().saturating_sub(2));
while remaining.len() > 3 {
let Some(ear_index) = find_ear_xz(&remaining) else {
return Err(BrushPolygonError::NonConvex);
};
let prev = (ear_index + remaining.len() - 1) % remaining.len();
let next = (ear_index + 1) % remaining.len();
triangles.push(vec![remaining[prev], remaining[ear_index], remaining[next]]);
remaining.remove(ear_index);
}
triangles.push(remaining);
Ok(merge_convex_parts(triangles))
}
fn merge_convex_parts(mut parts: Vec<Vec<Vec3>>) -> Vec<Vec<Vec3>> {
let mut merged_any = true;
while merged_any {
merged_any = false;
'pairs: for a in 0..parts.len() {
for b in (a + 1)..parts.len() {
let Some(merged) = merge_convex_pair(&parts[a], &parts[b]) else {
continue;
};
parts[a] = merged;
parts.remove(b);
merged_any = true;
break 'pairs;
}
}
}
parts
}
fn merge_convex_pair(a: &[Vec3], b: &[Vec3]) -> Option<Vec<Vec3>> {
let mut edges = Vec::<(Vec3, Vec3)>::with_capacity(a.len() + b.len());
for part in [a, b] {
for index in 0..part.len() {
edges.push((part[index], part[(index + 1) % part.len()]));
}
}
let mut boundary = Vec::<(Vec3, Vec3)>::new();
let mut removed_shared_edges = 0;
for (index, edge) in edges.iter().enumerate() {
let is_reversed_duplicate = edges.iter().enumerate().any(|(other_index, other)| {
index != other_index && same_point_xz(edge.0, other.1) && same_point_xz(edge.1, other.0)
});
if is_reversed_duplicate {
removed_shared_edges += 1;
} else {
boundary.push(*edge);
}
}
if removed_shared_edges != 2 || boundary.len() < 3 {
return None;
}
let mut polygon = Vec::with_capacity(boundary.len());
let first = boundary.remove(0);
polygon.push(first.0);
let mut current = first.1;
while !boundary.is_empty() {
let next_index = boundary
.iter()
.position(|edge| same_point_xz(edge.0, current))?;
let (_, next) = boundary.remove(next_index);
if !same_point_xz(current, polygon[0]) {
polygon.push(current);
}
current = next;
}
if !same_point_xz(current, polygon[0]) {
return None;
}
let mut polygon = normalize_floor_polygon(&polygon).ok()?;
if signed_area_xz(&polygon) < 0.0 {
polygon.reverse();
}
validate_floor_polygon(&polygon).ok()?;
Some(polygon)
}
fn find_ear_xz(vertices: &[Vec3]) -> Option<usize> {
vertices.iter().enumerate().find_map(|(index, current)| {
let prev_index = (index + vertices.len() - 1) % vertices.len();
let next_index = (index + 1) % vertices.len();
let prev = vertices[prev_index];
let next = vertices[next_index];
if orient_xz(prev, *current, next) <= EPSILON {
return None;
}
let contains_other_vertex = vertices.iter().enumerate().any(|(candidate, point)| {
candidate != prev_index
&& candidate != index
&& candidate != next_index
&& point_in_triangle_xz(*point, prev, *current, next)
});
(!contains_other_vertex).then_some(index)
})
}
fn point_in_triangle_xz(point: Vec3, a: Vec3, b: Vec3, c: Vec3) -> bool {
let ab = orient_xz(a, b, point);
let bc = orient_xz(b, c, point);
let ca = orient_xz(c, a, point);
ab >= -EPSILON && bc >= -EPSILON && ca >= -EPSILON
}
fn same_point_xz(a: Vec3, b: Vec3) -> bool {
a.xz().distance_squared(b.xz()) <= EPSILON * EPSILON
}
fn self_intersects_xz(vertices: &[Vec3]) -> bool {
for a in 0..vertices.len() {
let b = (a + 1) % vertices.len();
@ -494,57 +317,6 @@ mod tests {
);
}
#[test]
fn normalizes_adjacent_and_closing_duplicate_floor_points() {
let normalized = normalize_floor_polygon(&[
p(-1.0, -1.0),
p(-1.0, -1.0),
p(1.0, -1.0),
p(1.0, 1.0),
p(-1.0, 1.0),
p(-1.0, -1.0),
])
.expect("normalized polygon");
assert_eq!(normalized.len(), 4);
assert!(validate_floor_polygon(&normalized).is_ok());
}
#[test]
fn decomposes_concave_floor_polygon_into_convex_parts() {
let parts = decompose_floor_polygon_to_convex(&[
p(-1.0, -1.0),
p(1.0, -1.0),
p(0.0, 0.0),
p(1.0, 1.0),
p(-1.0, 1.0),
])
.expect("convex decomposition");
assert_eq!(parts.len(), 2);
for part in parts {
assert!(validate_floor_polygon(&part).is_ok());
}
}
#[test]
fn merges_l_shaped_floor_polygon_to_two_convex_parts() {
let parts = decompose_floor_polygon_to_convex(&[
p(0.0, 0.0),
p(4.0, 0.0),
p(4.0, 1.0),
p(1.0, 1.0),
p(1.0, 4.0),
p(0.0, 4.0),
])
.expect("convex decomposition");
assert_eq!(parts.len(), 2);
for part in parts {
assert!(validate_floor_polygon(&part).is_ok());
}
}
#[test]
fn reports_invalid_brush_faces() {
let mut brush = BrushDesc::default();
@ -561,26 +333,12 @@ mod tests {
fn reports_uv_warnings_without_invalidating_brush() {
let mut brush = BrushDesc::default();
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);
assert!(report.is_valid());
assert_eq!(
report
.diagnostics
.iter()
.filter(|diagnostic| diagnostic.severity == BrushDiagnosticSeverity::Warning)
.count(),
3
);
assert!(report
.diagnostics
.iter()
.any(|diagnostic| diagnostic.message.contains("UV offset")));
assert!(report
.diagnostics
.iter()
.any(|diagnostic| diagnostic.message.contains("UV rotation")));
.any(|diagnostic| diagnostic.severity == BrushDiagnosticSeverity::Warning));
}
#[test]
@ -606,19 +364,4 @@ mod tests {
.iter()
.any(|diagnostic| diagnostic.message.contains("non-manifold")));
}
#[test]
fn validates_extruded_prism_winding() {
let brush = BrushDesc::extruded_prism(
&[p(-1.0, -1.0), p(1.0, -1.0), p(1.0, 1.0), p(-1.0, 1.0)],
1.0,
)
.expect("valid prism");
let report = validate_brush(&brush);
assert!(
report.is_valid(),
"unexpected diagnostics: {:?}",
report.diagnostics
);
}
}

View File

@ -438,9 +438,11 @@ impl BrushDesc {
}
pub fn extruded_prism(base_vertices: &[Vec3], height: f32) -> Option<Self> {
let mut base = crate::brush_math::normalize_floor_polygon(base_vertices).ok()?;
crate::brush_math::validate_floor_polygon(&base).ok()?;
if crate::brush_math::validate_floor_polygon(base_vertices).is_err() {
return None;
}
let height = height.max(0.001);
let mut base = base_vertices.to_vec();
if crate::brush_math::signed_area_xz(&base) < 0.0 {
base.reverse();
}
@ -448,12 +450,12 @@ impl BrushDesc {
.iter()
.map(|vertex| *vertex + Vec3::Y * height)
.collect();
let mut top_face = top;
top_face.reverse();
let mut bottom = base.clone();
bottom.reverse();
let mut faces = Vec::with_capacity(base.len() + 2);
faces.push(brush_face("face:+y", Vec3::Y, top_face));
faces.push(brush_face("face:-y", Vec3::NEG_Y, base.clone()));
faces.push(brush_face("face:+y", Vec3::Y, top));
faces.push(brush_face("face:-y", Vec3::NEG_Y, bottom));
for index in 0..base.len() {
let next = (index + 1) % base.len();
let a = base[index];
@ -765,7 +767,7 @@ impl Default for MaterialDesc {
}
/// Reflectable reference to an imported 3D model scene (glTF/GLB or FBX).
/// Hydration turns this into a `WorldAssetRoot` on the entity.
/// Hydration turns this into a `SceneRoot` on the entity.
#[derive(Component, Reflect, Default, Debug, Clone, Serialize, Deserialize)]
#[reflect(Component, Default, Debug, Serialize, Deserialize)]
pub struct ModelRef {
@ -790,7 +792,7 @@ impl ModelRef {
}
/// Reflectable reference to a saved `.scn.ron` dynamic scene under `assets/`.
/// Hydration turns this into a `DynamicWorldRoot`.
/// Hydration turns this into a `DynamicSceneRoot`.
#[derive(Component, Reflect, Default, Debug, Clone, Serialize, Deserialize)]
#[reflect(Component, Default, Debug, Serialize, Deserialize)]
pub struct PrefabRef {
@ -1256,13 +1258,10 @@ mod tests {
#[test]
fn point_spot_lumen_max_covers_bevy_cinema_reference() {
const {
assert!(
super::AUTHORING_POINT_SPOT_LUMENS_MAX
>= light_consts::lumens::VERY_LARGE_CINEMA_LIGHT
super::AUTHORING_POINT_SPOT_LUMENS_MAX >= light_consts::lumens::VERY_LARGE_CINEMA_LIGHT
);
}
}
#[test]
fn fbx_scene_asset_path_format() {

View File

@ -67,10 +67,6 @@ 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(
commands: &mut Commands,
asset_server: &AssetServer,
@ -247,12 +243,7 @@ fn append_face(
for vertex in &face.vertices {
positions.push([vertex.x, vertex.y, vertex.z]);
normals.push([normal.x, normal.y, normal.z]);
let uv = transform_face_uv(
Vec2::new(vertex.dot(u_axis), vertex.dot(v_axis)),
face.uv_scale,
face.uv_rotation,
face.uv_offset,
);
let uv = Vec2::new(vertex.dot(u_axis), vertex.dot(v_axis)) * face.uv_scale + face.uv_offset;
uvs.push([uv.x, uv.y]);
}
@ -271,29 +262,6 @@ fn append_face(
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) {
let tangent_seed = if normal.y.abs() > 0.9 {
Vec3::X
@ -351,27 +319,6 @@ 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]
fn invalid_brush_returns_none() {
let mut brush = BrushDesc::default();

View File

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

View File

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

View File

@ -29,10 +29,7 @@ use static_meshes::{
hydrate_static_mesh_renderers, spawn_static_mesh_parts, HydratedStaticMeshPart,
StaticMeshArtifactCache,
};
use visibility::{
ensure_level_object_visibility_hierarchy, init_editor_visibility_on_spawn,
sync_editor_visibility, visibility_from_editor,
};
use visibility::{init_editor_visibility_on_spawn, sync_editor_visibility};
use crate::{
inspector_component_active, BrushDesc, ColliderDesc, EditorVisibility, InspectorOrder,
@ -64,21 +61,12 @@ impl Plugin for HydrationPlugin {
)
.add_systems(
Update,
(
ensure_level_object_visibility_hierarchy,
sync_editor_visibility,
init_editor_visibility_on_spawn,
)
.chain(),
(sync_editor_visibility, init_editor_visibility_on_spawn).chain(),
);
}
}
/// 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) {
if !world.contains_resource::<StaticMeshArtifactCache>() {
world.insert_resource(StaticMeshArtifactCache::default());
@ -239,9 +227,8 @@ pub fn flush_level_object_hydration(world: &mut World) {
)> = SystemState::new(world);
{
let (mut commands, asset_server, mut meshes, mut materials, mut artifact_cache) = state
.get_mut(world)
.expect("hydrate_level_objects system params should be valid");
let (mut commands, asset_server, mut meshes, mut materials, mut artifact_cache) =
state.get_mut(world);
for (entity, primitive) in primitives {
let mesh = meshes.add(primitive_mesh(&primitive));
@ -298,21 +285,12 @@ pub fn flush_level_object_hydration(world: &mut World) {
state.apply(world);
for (entity, editor) in visibility_targets {
let visibility = visibility_from_editor(editor);
if let Some(mut vis) = world.get_mut::<Visibility>(entity) {
*vis = visibility;
} else if let Ok(mut entity_mut) = world.get_entity_mut(entity) {
entity_mut.insert(visibility);
}
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());
}
*vis = if editor.visible {
Visibility::Visible
} else {
Visibility::Hidden
};
}
}
}
@ -322,8 +300,8 @@ mod tests {
use super::flush_level_object_hydration;
use super::strip::strip_hydrated_entity;
use crate::{
AuthoringLightKind, ColorDesc, EditorVisibility, InspectorOrder, LevelObject, LightDesc,
MaterialDesc, Primitive, COMPONENT_LIGHT_DESC,
AuthoringLightKind, ColorDesc, InspectorOrder, LevelObject, LightDesc, MaterialDesc,
Primitive, COMPONENT_LIGHT_DESC,
};
use avian3d::prelude::*;
use bevy::prelude::*;
@ -346,29 +324,6 @@ mod tests {
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]
fn flush_hydration_skips_point_light_when_solari_is_active() {
let mut app = App::new();

View File

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

View File

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

View File

@ -202,10 +202,6 @@ 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(
commands: &mut Commands,
asset_server: &AssetServer,

View File

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

View File

@ -3,41 +3,6 @@
use crate::{EditorVisibility, LevelObject};
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(
mut query: Query<
(&EditorVisibility, &mut Visibility),
@ -48,14 +13,14 @@ pub fn sync_editor_visibility(
>,
) {
for (editor, mut visibility) in query.iter_mut() {
*visibility = visibility_from_editor(*editor);
*visibility = if editor.visible {
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(
mut query: Query<
(&EditorVisibility, &mut Visibility),
@ -67,39 +32,10 @@ pub fn init_editor_visibility_on_spawn(
>,
) {
for (editor, mut visibility) in query.iter_mut() {
*visibility = visibility_from_editor(*editor);
}
}
pub fn visibility_from_editor(editor: EditorVisibility) -> Visibility {
if editor.visible {
*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,10 +33,9 @@ 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 |
| [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 |
| [0019](adr/0019-local-bevy-render-timeout-patch.md) | Superseded local `bevy_render` patch for transient Linux swapchain timeouts |
| [0019](adr/0019-local-bevy-render-timeout-patch.md) | 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 |
| [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

View File

@ -6,7 +6,7 @@ Accepted
## Context
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.
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.
Unplanned upgrades can break editor work, rendering, scene serialization, physics, and later networking. Delayed upgrades can also make migrations larger and riskier.
@ -18,11 +18,9 @@ Before merging a Bevy upgrade:
- Confirm matching versions exist for editor, physics, and networking-related crates used by the active milestone.
- 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.
- Smoke-test the game and editor manually after CI is green.
- 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

View File

@ -6,7 +6,7 @@ Accepted
## Context
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.
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.
## 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.
ADR 0016 supersedes the ambiguous `active_gi_path` wording with explicit requested/effective GI fields and `EffectiveRenderStack` fallback reasons.
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.
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.
### 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`.
- 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/`.
- 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.
- 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.

View File

@ -21,7 +21,7 @@ used by `ProjectSun`.
## Decision
1. **`game_hot::rendering::viewport_camera`** is the only module that adds/removes the viewport
render stack (`ProjectRenderCamera`, exposure, GI, camera atmosphere settings, bloom, fog, etc.).
render stack (`ProjectRenderCamera`, exposure, GI, atmosphere, bloom, fog, etc.).
2. **Editor** `scene_view` and `play/session` set `RenderTarget`, `Camera::is_active`, and
`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
@ -41,5 +41,3 @@ used by `ProjectSun`.
- 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
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
volume intent, but GI fallback decisions were repeated across camera sync, Solari systems, and UI.
Bevy 0.19 Solari is still experimental. It needs raytracing-capable hardware, eligible
Bevy 0.18 Solari is still experimental. It needs raytracing-capable hardware, eligible
`Mesh3d + MeshMaterial3d<StandardMaterial>` scene geometry, Solari-compatible mesh assets
(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
@ -52,5 +52,5 @@ point/spot lighting.
required Solari mesh attributes or U32 indices.
- Existing scenes and material assets remain compatible through serde defaults for new emissive
fields.
- Future Bevy rendering upgrades remain a separate migration track under ADR 0002, not part of this
- Bevy 0.19+ rendering upgrades remain a separate migration track under ADR 0002, not part of this
contract change.

View File

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

View File

@ -1,33 +0,0 @@
# 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

@ -44,8 +44,8 @@ Docs for the in-process egui editor (`crates/editor/`). Update this index when a
- **Asset browser** mirrors the on-disk `assets/` directory with project tree, breadcrumb, search/filter/sort, grid/list views, texture/model/material thumbnails, a details pane, and context-aware row/menu actions; narrow docks prioritize content, keep the root panel fixed, switch list view to a compact single-column layout, and hide tree/details panes when they would crowd the content area. The footer stays pinned while only the project tree, asset content, and details panels scroll. **Built-ins** holds spawnable primitives and lights. **Materials** folder scans `assets/materials/*.ron`, renders material thumbnails on a sphere using `MaterialDesc`, and exposes shader-schema-driven parameters/textures in the details editor; **Shaders** holds shader schema RON files. glTF/GLB/FBX rows can expand into a shelf of normalized embedded mesh, material, and texture subassets with independent generated thumbnails. Mesh subassets can be selected, dragged into the viewport, or placed from details/context menus; material subassets render source-material spheres; texture subassets can be applied to the selected actor. Model import settings are staged with **Apply** / **Revert**, asset context menus can regenerate thumbnails, material asset details edit shared `MaterialAsset` fields, and file asset deletion moves sources/generated artifacts into `assets/.trash/`.
- **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).
- **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 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.
- **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 creates an additive prism brush through history, and Esc/right-click cancels.
- **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.
- **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.
- **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.
- **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()`.
- **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.
- **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.
- **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`.
- **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,12 +59,6 @@ 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.
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
See [ADR 0014](../adr/0014-unified-viewport-model.md) for the unified viewport decision.
@ -121,7 +115,7 @@ The egui layer lives under `crates/editor/src/ui/`:
| `diagnostics.rs` | Detailed stats (Window → Diagnostics, Asset Browser footer) |
| `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 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.
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.
**Follow-up (Phase 4b):** hierarchy expand/collapse persistence, inspector search, and richer per-asset previews.
@ -152,7 +146,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.
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.
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.
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.
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)
@ -162,7 +156,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.
- **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.
- **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`.
- **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`.
- **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
@ -187,7 +181,7 @@ PIE stop restores player simulation state only; authored `LevelObject` edits mad
| `viewport/` | Camera, selection, gizmos, render views, panel settings |
| `play/` | PIE session, editor mode, net editor profiles |
| `assets/` | Catalog, asset DB, static mesh artifacts, prefab overrides |
| `project/` | Project I/O and settings UI |
| `project/` | Workspace, project I/O, settings UI |
| `ext/` | Command palette, BRP, game panel adapters |
| `history/` | Undo commands + plugin |
| `ui/` | egui dock shell |
@ -198,7 +192,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.
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/`.
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.
## Extensibility (phase 6)

View File

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

View File

@ -12,17 +12,17 @@ Brushes are persisted blockout geometry stored as `ActorKind::Brush + BrushDesc`
- The Brush card runs shared validation and reports invalid faces inline. Fatal geometry errors prevent hydration; warnings call out authoring issues that can still render. **Reset Cube Brush** is the MVP repair path.
- Scene save runs the same fatal brush validation and blocks writing unrecoverable invalid brush geometry.
- **Window → Brush Diagnostics** lists all brush actors, surfaces validation counts/messages, can select the affected brush, and provides an undoable **Reset Cube** repair for invalid brushes.
- Draw Brush mode (`B`, toolbar pencil, or command `brush.draw`) creates additive prism brushes from snapped floor points. While active, the viewport shows phase-specific quick hints. In outline phase, LMB places points, Backspace removes the last point, and Enter locks the outline for height editing. In height phase, mouse up/down adjusts brush height, Enter or LMB commits through undo history, and Backspace returns to outline editing. Esc/right-click cancels without scene mutation. Simple concave outlines are decomposed into multiple convex brush parts with one undo entry, and the preview shows the generated part boundaries. Adjacent duplicate clicks and a closing duplicate point are merged before commit. Invalid outlines, including self-intersections, preview in red, report the reason in the status/operator text, log the failed Enter attempt, and leave the scene unchanged.
- Draw Brush mode (`B`, toolbar pencil, or command `brush.draw`) creates additive prism brushes from snapped floor points. LMB places points, Backspace removes the last point, Enter commits through undo history, and Esc/right-click cancels without scene mutation.
## 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 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.
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.
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.
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.
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 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.
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.
## Boolean Operations
@ -36,7 +36,7 @@ These operations validate selected brushes before previewing results and validat
## Current Limits
- Only convex authored faces are stored and hydrated by the mesh builder. Draw Brush can accept a simple concave floor outline by decomposing it into multiple convex brush actors.
- Only convex authored faces are supported by the mesh builder.
- Brush diagnostics currently cover face validity, plane normals, inverted face normals, finite vertices, duplicate vertices, degenerate area, open/non-manifold edges, and UV scale warnings.
- Subtractive brush markers are stored but not automatically evaluated.
- Arbitrary plane clipping, split-into-two output, and arbitrary-face CSG remain future roadmap work.

View File

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

View File

@ -8,8 +8,6 @@ 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).
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.
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.
@ -62,7 +60,7 @@ Viewport overlays: **GI badge** (Forward / Solari), **volume HUD** when inside a
| Forward | Bevy Forward PBR with HDR/TAA/SSAO/atmosphere/shadows |
| Solari | Request Solari; the camera attaches Bevy Solari when RT support is available |
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).
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).
## Emissive lighting
@ -105,6 +103,5 @@ 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 |
| 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 |
| 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`.

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 |
| User prefs (`~/.config/bevy-fps/editor_prefs.ron`) | Done | Recent levels, load on startup |
| Window title from project + scene | Done | `project_io::window_title` |
| 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 |
| New/Open project menu | Done | File → Project; distinct from level New/Open |
| Dock layout persistence | Done | RON in `editor_prefs.ron`; View → Reset Layout |
## Phase 2 — Viewport excellence
@ -101,7 +101,7 @@ Phased work for the in-process editor (`crates/editor`). Status reflects the cur
| Item | Status | Notes |
|------|--------|-------|
| 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; exposes scene I/O and operator feedback alongside scene/mode/selection/history |
| Fixed status bar | Done | Replaces Status dock tab |
| 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) |
| Pause in PIE | Done | `PlayPaused` freezes sim while staying in Play (`F6`) |

296
third_party/bevy_render/Cargo.toml vendored Normal file
View File

@ -0,0 +1,296 @@
# 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)"]

155
third_party/bevy_render/Cargo.toml.orig vendored Normal file
View File

@ -0,0 +1,155 @@
[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

@ -174,28 +174,3 @@
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@ -1,7 +1,5 @@
MIT License
Copyright (c) 2025 FizzWizZleDazzle
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights

7
third_party/bevy_render/README.md vendored Normal file
View File

@ -0,0 +1,7 @@
# 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)

62
third_party/bevy_render/src/alpha.rs vendored Normal file
View File

@ -0,0 +1,62 @@
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

@ -0,0 +1,225 @@
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

@ -0,0 +1,182 @@
//! 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

@ -0,0 +1,37 @@
// 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

702
third_party/bevy_render/src/camera.rs vendored Normal file
View File

@ -0,0 +1,702 @@
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

@ -0,0 +1,47 @@
#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

@ -0,0 +1,81 @@
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

@ -0,0 +1,711 @@
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

@ -0,0 +1,91 @@
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);
}

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