This commit is contained in:
commit
f29712d158
8
.cargo/config.toml
Normal file
8
.cargo/config.toml
Normal file
@ -0,0 +1,8 @@
|
||||
# Use clang + the mold linker to dramatically cut link times during iteration.
|
||||
# Both `clang` and `mold` are available on this machine.
|
||||
[target.x86_64-unknown-linux-gnu]
|
||||
linker = "clang"
|
||||
rustflags = ["-C", "link-arg=-fuse-ld=mold"]
|
||||
|
||||
[alias]
|
||||
clean-target = "run -p xtask --bin clean-target --"
|
||||
23
.cursor/plans/component_system_refactor_2026-06-05.plan.md
Normal file
23
.cursor/plans/component_system_refactor_2026-06-05.plan.md
Normal file
@ -0,0 +1,23 @@
|
||||
# Component System Refactor Plan - 2026-06-05
|
||||
|
||||
## Goal
|
||||
|
||||
Align the actor inspector and static mesh authoring model with
|
||||
`docs/editor/Bevy_Editor_Component_System_Spec.md`.
|
||||
|
||||
## Implemented Scope
|
||||
|
||||
- Stable actor and component item IDs: `ActorId`, `ActorName`, `ComponentInstanceId`.
|
||||
- Editor-visible component registry powering Add Component metadata.
|
||||
- `StaticMeshRenderer` slots using imported `EditorAssetRef` mesh/material refs instead of source model paths.
|
||||
- Static mesh artifact part IDs and resolver fallback for older generated artifacts.
|
||||
- Separate `RigidBodyDesc` and `ColliderDesc`, including static mesh collider sources.
|
||||
- Shader-aware `MaterialDesc`, shader schema assets, and per-actor `MaterialOverride`.
|
||||
- Actor inspector footer and component cards updated for static mesh slots, collider, rigid body, and shader controls.
|
||||
- Docs/ADR updates for the architecture and workflow changes.
|
||||
|
||||
## Follow-Up
|
||||
|
||||
- Content-browser material asset inspector should become the shared-material editing surface.
|
||||
- Custom WGSL material rendering should be promoted from schema/data support to a dedicated runtime material pipeline.
|
||||
- Multi-selection mixed-value editing and prefab override indicators should use the new stable component/slot IDs.
|
||||
215
.cursor/plans/project_roadmap_8a452d43.plan.md
Normal file
215
.cursor/plans/project_roadmap_8a452d43.plan.md
Normal file
@ -0,0 +1,215 @@
|
||||
---
|
||||
name: Project roadmap
|
||||
overview: A long-term, milestone-based roadmap ("plan of plans") for evolving the current game/shared/editor workspace into a flagship multiplayer FPS that doubles as a reusable, modular Bevy engine/editor toolkit, architected for server-authoritative networking and eventual web support, with each milestone spawning its own detailed sub-plan.
|
||||
todos:
|
||||
- id: m0-foundations
|
||||
content: "M0 Foundations: fix window visibility, CI (fmt/clippy/build), test scaffolding, ADRs, documented Bevy version/migration policy."
|
||||
status: completed
|
||||
- id: m1-editor
|
||||
content: "M1 Editor usability (contained sub-plan, see M1 detail section). Exit: an actually usable level editor."
|
||||
status: completed
|
||||
- id: m1-infra-tagging
|
||||
content: M1 - Add EditorOnly marker (crates/editor/src/infra.rs); tag editor + egui cameras and helpers; exclude from hierarchy, picking, and scene save.
|
||||
status: completed
|
||||
- id: m1-scene-hierarchy
|
||||
content: M1 - Custom scene Hierarchy panel in ui/mod.rs listing only LevelObject root+children, with select, F2 rename, and right-click context menu.
|
||||
status: completed
|
||||
- id: m1-selection-fixes
|
||||
content: "M1 - Fix selection.rs: pick only LevelObjects, editor infra non-pickable, click-empty/Esc to deselect, keep gizmo-focus gating."
|
||||
status: completed
|
||||
- id: m1-delete-duplicate
|
||||
content: M1 - Delete/Backspace and Ctrl+D duplicate for the current selection.
|
||||
status: completed
|
||||
- id: m1-menu-toolbar
|
||||
content: M1 - Add top menu bar (File/Edit/View/Play), revamp toolbar, gizmo hotkeys W/E/R + world/local toggle.
|
||||
status: completed
|
||||
- id: m1-undo-redo
|
||||
content: M1 - Add history.rs EditorHistory command stack (Spawn/Despawn/Duplicate/SetTransform/Rename/SetMaterial) wired to ops and gizmo drag-end; Ctrl+Z/Ctrl+Shift+Z/Ctrl+Y.
|
||||
status: completed
|
||||
- id: m1-shared-authoring-ext
|
||||
content: "M1 - Extend shared components+hydration: ModelRef (GLTF), MaterialDesc texture refs, PrefabRef; register new types."
|
||||
status: completed
|
||||
- id: m1-asset-browser
|
||||
content: M1 - Add assets.rs + Asset Browser panel scanning assets/{models,textures,materials,levels} with primitives/lights palette and refresh.
|
||||
status: completed
|
||||
- id: m1-drag-drop-materials
|
||||
content: M1 - Drag-and-drop from browser into viewport with ground-raycast placement; material/texture assignment + basic material editor in Inspector.
|
||||
status: completed
|
||||
- id: m1-import-export
|
||||
content: M1 - rfd import (copy into assets/ + hot reload) and export selection as prefab .scn.ron.
|
||||
status: completed
|
||||
- id: m1-play-mode
|
||||
content: "M1 - Snapshot/restore Play mode in state.rs: snapshot LevelObjects, activate player+FX camera on Play, restore authored scene on Stop."
|
||||
status: completed
|
||||
- id: m1-scene-io-polish
|
||||
content: M1 - New/Open/Save/Save As via rfd with dirty tracking and window-title update in scene_io.rs.
|
||||
status: completed
|
||||
- id: m1-deps-verify-docs
|
||||
content: M1 - Add rfd dep, verify GLTF/texture features, cargo check/clippy workspace, update README controls + checklist.
|
||||
status: completed
|
||||
- id: m2-sim-determinism
|
||||
content: "M2 Sim/authoring split + determinism: extract crates/sim and crates/protocol, move gameplay to FixedUpdate, input-as-intent, determinism test harness."
|
||||
status: completed
|
||||
- id: m3-netcode
|
||||
content: "M3 Networking slice: Lightyear spike + integration, headless crates/server, client prediction/reconciliation + remote interpolation for player movement."
|
||||
status: pending
|
||||
- id: m4-fps-core
|
||||
content: "M4 FPS gameplay core: weapons (hitscan/projectile) with lag compensation, health/damage/respawn, a basic game mode in crates/sim."
|
||||
status: pending
|
||||
- id: m5-asset-pipeline
|
||||
content: "M5 Asset/content pipeline: robust GLTF/material/prefab pipeline, scene schema versioning + migrations, level streaming, import validation."
|
||||
status: pending
|
||||
- id: m6-modding-scripting
|
||||
content: "M6 Modding/scripting: stable plugin API, data-driven definitions, scripting layer (Lua/Rhai or WASM components), BRP external tooling."
|
||||
status: pending
|
||||
- id: m7-web-platform
|
||||
content: "M7 Web/platform: WASM client (WebGPU + WebTransport), build matrix, networked asset loading, perf/input adaptation."
|
||||
status: pending
|
||||
- id: m8-production-polish
|
||||
content: "M8 Production polish: perf budgets/profiling, metrics, settings/save, accessibility, packaging/distribution and update cadence."
|
||||
status: pending
|
||||
isProject: false
|
||||
---
|
||||
|
||||
## Project Roadmap (Plan of Plans)
|
||||
|
||||
This is the top-level strategy. Each milestone (M0-M8) becomes its own detailed sub-plan when started. Milestone M1 (Editor Build-Out) is fully contained inline in the "M1 detail" section below; later milestones get their detailed plans spun out as they begin.
|
||||
|
||||
### Vision
|
||||
A flagship, server-authoritative multiplayer FPS built on Bevy, whose engine/editor systems are factored as a reusable, modular toolkit. Desktop-first (Linux/Windows), architected so a WASM/WebGPU client is a later additive step, not a rewrite.
|
||||
|
||||
### Guiding principles (apply from now, retrofit is expensive)
|
||||
- Deterministic, fixed-timestep simulation. Gameplay logic lives in `FixedUpdate`/`FixedMain`, decoupled from render framerate.
|
||||
- Input as intent. All world mutation flows through input/command/message events (never ad-hoc mutation from raw input). This is the single highest-leverage thing for cheap multiplayer retrofit and replays.
|
||||
- Reflection-first, data-driven content. Authoring components + scenes (already in `crates/shared`), with a versioned, migratable schema.
|
||||
- Strict crate boundaries: reusable engine/toolkit code separate from game-specific content; deterministic sim separate from rendering.
|
||||
- WASM-friendly core. Native-only deps (rfd, mold, OS dialogs, BRP HTTP) are quarantined behind features or in tooling/editor crates only.
|
||||
- BRP-aligned tooling so the custom editor can interoperate with the future official Bevy editor.
|
||||
- Continuous quality: CI, lints, determinism tests, benchmarks, and a per-release Bevy migration discipline.
|
||||
|
||||
### Target architecture (north star)
|
||||
Evolve the current `crates/{game,shared,editor}` into:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph core [Shared deterministic core - wasm-safe]
|
||||
protocol[protocol: net types, inputs, messages, replication]
|
||||
sim[sim: deterministic gameplay in FixedUpdate]
|
||||
scene[scene: authoring data + hydration]
|
||||
end
|
||||
server[server: headless authoritative bin] --> sim
|
||||
server --> protocol
|
||||
client[game: render, input, audio, ui, glue] --> sim
|
||||
client --> protocol
|
||||
client --> scene
|
||||
editor[editor: egui tooling + BRP] --> scene
|
||||
editor --> client
|
||||
netcode[Lightyear] --- server
|
||||
netcode --- client
|
||||
xtask[xtask: build, codegen, CI] -.-> core
|
||||
```
|
||||
|
||||
- `crates/protocol` (new): networked types, input commands, messages, replication registration. Shared by client + server.
|
||||
- `crates/sim` (extracted from `crates/game`): deterministic gameplay (movement, combat, rules), no rendering/audio.
|
||||
- `crates/game` -> client app: rendering/FX (current `rendering/`), input, audio, UI, presentation glue.
|
||||
- `crates/server` (new): headless authoritative binary.
|
||||
- `crates/shared` -> `crates/scene`: authoring components + hydration (current files), now also used to seed networked world.
|
||||
- `crates/editor`: stays the egui toolkit; native-only deps isolated here.
|
||||
- `xtask` (new): build/codegen/CI helper.
|
||||
|
||||
### Milestone roadmap
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
M0[M0 Foundations] --> M1[M1 Editor usability]
|
||||
M1 --> M2[M2 Sim split + determinism]
|
||||
M2 --> M3[M3 Netcode slice]
|
||||
M3 --> M4[M4 FPS gameplay]
|
||||
M4 --> M5[M5 Asset pipeline]
|
||||
M5 --> M6[M6 Modding + scripting]
|
||||
M6 --> M7[M7 Web + platform]
|
||||
M7 --> M8[M8 Production polish]
|
||||
```
|
||||
|
||||
- M0 - Foundations and hardening: resolve the window-visibility issue, quiet/understand wgpu validation noise, add CI (fmt/clippy/build), test scaffolding, ADRs (architecture decision records), and a documented Bevy version policy. Exit: green CI, reproducible builds, ADR-0001 (architecture) + ADR-0002 (netcode choice spike planned).
|
||||
- M1 - Editor usability: the existing Editor Build-Out sub-plan (selection/hierarchy fixes, delete/duplicate, undo/redo, asset browser, drag-and-drop, materials/GLTF, save/load, snapshot Play mode). Exit: an actually usable level editor.
|
||||
- M2 - Simulation/authoring split + determinism: extract `crates/sim`, move gameplay to `FixedUpdate`, introduce `crates/protocol`, convert input to intent/command events, add a determinism test harness (same inputs -> same state hash). Exit: gameplay deterministic in fixed timestep; no ad-hoc world mutation from input.
|
||||
- M3 - Networking vertical slice: spike then integrate Lightyear (server-authoritative) with client-side prediction + reconciliation for player movement and snapshot interpolation for remotes; stand up `crates/server` (headless). Exit: server + 2 clients, responsive predicted local movement, smooth remote players.
|
||||
- M4 - FPS gameplay core: weapons (hitscan + projectiles) with lag compensation, health/damage/respawn, a basic game mode, all in `crates/sim`. Exit: playable networked skirmish.
|
||||
- M5 - Asset and content pipeline maturity: robust GLTF/material/prefab pipeline, scene schema versioning + migrations, level streaming/partitioning, import validation tooling. Exit: author a real level end-to-end from assets in the editor.
|
||||
- M6 - Modding and scripting extensibility: stable plugin API, data-driven definitions, and a scripting/modding layer (Lua/Rhai or WASM components) plus BRP-based external tooling. Exit: a mod adds a new entity/weapon without recompiling core.
|
||||
- M7 - Web and platform expansion: WASM client (WebGPU + WebTransport via Lightyear), build matrix, networked asset loading, perf/input adaptation. Exit: a browser client connects to the live server.
|
||||
- M8 - Production polish and release engineering: performance budgets + profiling, metrics/observability, settings/save systems, accessibility, packaging/distribution and update cadence. Exit: a shippable demo build.
|
||||
|
||||
### M1 detail - Editor Build-Out (contained sub-plan)
|
||||
Phased; each phase is independently testable and leaves the editor working. Maps directly to the reported issues (cluttered hierarchy, broken selection, no delete, button-spawning, weak save, no undo, no asset browser, no real play mode).
|
||||
|
||||
Architectural decisions:
|
||||
- Add an `EditorOnly` marker (new `crates/editor/src/infra.rs`) for editor infrastructure (editor camera, egui camera, gizmo/selection helpers). Hierarchy, picking, and scene save all filter off it.
|
||||
- Replace `bevy-inspector-egui`'s `hierarchy_ui` with a custom scene hierarchy that only walks `LevelObject` roots + children.
|
||||
- Keep authoring data the source of truth: extend `crates/shared/src/components.rs` so GLTF models and textured materials are reflectable/serializable and round-trip through `.scn.ron` via the existing hydration system.
|
||||
- Undo/redo via an explicit command stack (`EditorHistory`), except Play mode which uses a scene snapshot for restore.
|
||||
- Add `rfd` (editor-only dep) for native Open/Save/Import dialogs.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Browser[Asset Browser] -->|drag payload| Viewport
|
||||
Viewport -->|ground raycast place| Spawn[Spawn authoring entity]
|
||||
Spawn --> History[EditorHistory cmd]
|
||||
Hierarchy -->|select| Selection
|
||||
Selection --> Gizmo[Transform gizmo]
|
||||
Gizmo -->|drag end| History
|
||||
History -->|undo or redo| World
|
||||
```
|
||||
|
||||
M1 Phase 1 - Core usability (the reported bugs):
|
||||
- New `infra.rs`: `EditorOnly` marker; mark editor camera + egui camera (`crates/editor/src/camera.rs`) and helper entities.
|
||||
- Custom Hierarchy panel in `crates/editor/src/ui/mod.rs`: only `LevelObject` entities (root + children), click to select, `F2` rename, right-click context menu (Rename / Duplicate / Delete / Focus).
|
||||
- Selection fixes in `crates/editor/src/selection.rs`: only `LevelObject`s pickable (editor infra opts out via `Pickable::IGNORE`), click empty to deselect, `Esc` to clear, keep gizmo-focus gating.
|
||||
- Delete (`Delete`/`Backspace`) and Duplicate (`Ctrl+D`).
|
||||
- Gizmo hotkeys in `crates/editor/src/gizmos.rs` (`W/E/R`) + world/local toggle.
|
||||
|
||||
M1 Phase 2 - Undo / Redo:
|
||||
- New `crates/editor/src/history.rs`: `EditorHistory` with undo/redo stacks and an `EditorCommand` enum (`Spawn`, `Despawn`, `Duplicate`, `SetTransform`, `Rename`, `SetMaterial`).
|
||||
- Capture gizmo transform edits on drag-end; structural ops push commands; `Ctrl+Z` / `Ctrl+Shift+Z` (+ `Ctrl+Y`).
|
||||
- Inspector field-edit undo is out of scope for v1 (README follow-up).
|
||||
|
||||
M1 Phase 3 - Asset browser, authoring extensions, drag-and-drop, materials:
|
||||
- Extend `crates/shared/src/components.rs` + `hydration.rs`: `ModelRef { path, scene_index }` -> `SceneRoot`/`Mesh3d` from GLTF; `MaterialDesc` optional texture paths (base color / normal / metallic-roughness); `PrefabRef` for dragging in saved `.scn.ron`.
|
||||
- New `crates/editor/src/assets.rs` + Asset Browser panel: scan `assets/{models,textures,materials,levels}`, grid/tree with labels (thumbnails best-effort), refresh, primitives+lights palette.
|
||||
- Drag-and-drop: egui drag payload from browser -> drop in Viewport -> place via ground raycast -> spawn matching authoring entity (push to history). Keep click-to-spawn fallback.
|
||||
- Material assignment from the browser to the selection; basic material editor fields in the Inspector.
|
||||
- Import via `rfd`: copy external GLTF/GLB/texture into `assets/` and hot-reload; export selection as a prefab `.scn.ron`.
|
||||
|
||||
M1 Phase 4 - Working Play mode + scene IO polish:
|
||||
- `crates/editor/src/state.rs`: on Edit->Play, snapshot `LevelObject`s to an in-memory `DynamicScene`; activate player + high-fidelity camera, enable game input/physics; on Play->Edit, despawn runtime entities and restore the snapshot. Optional pause/step.
|
||||
- `crates/editor/src/scene_io.rs`: `New`/`Open`/`Save`/`Save As` with `rfd`, dirty tracking, recent path, window-title update; keep filtering to `LevelObject` + new authoring components.
|
||||
|
||||
M1 Phase 5 - Dependencies, verification, docs:
|
||||
- Add `rfd` to `crates/editor/Cargo.toml`; confirm `bevy` GLTF/texture features (default).
|
||||
- `cargo check`/`cargo clippy` across the workspace; fix 0.18 API drift.
|
||||
- Update `README.md` controls + Implementation Checklist.
|
||||
|
||||
M1 out of scope (later): per-field inspector undo, multi-scene tabs, full PBR material graph, terrain, lightmap baking. (These align with M5/M6 of this roadmap.)
|
||||
|
||||
### Cross-cutting tracks (run continuously, not milestones)
|
||||
- Rendering fidelity (build on current `crates/game/src/rendering`), UX/UI polish, Tooling/CI, Docs + ADRs, Performance budgets, Security (server authority + anti-cheat posture).
|
||||
|
||||
### Key technology decisions (validated May 2026)
|
||||
- Bevy: stay on the latest stable (0.18.1 now; adopt 0.19 when it ships, imminent). Migrate every ~3-month release on a scheduled cadence; gate upgrades on ecosystem (esp. netcode) support. Capture as ADR.
|
||||
- Netcode: Lightyear (server-authoritative; prediction/rollback, interpolation, lag compensation; WASM/WebTransport). Lightyear tracks Bevy by roughly one version, which constrains upgrade timing. Fallback: bevy_replicon if Lightyear complexity outweighs benefit. Decide via M3 spike.
|
||||
- Physics: keep Avian for static world + spatial queries; keep the player controller custom and deterministic; movement is server-authoritative. Avoid full physics rollback initially (predict player movement only); revisit if needed. Determinism across machines is the top technical risk.
|
||||
- Editor: continue the custom egui editor; align scene IO and inspection with BRP so we can interoperate with the official Bevy editor later. Quarantine native-only editor deps.
|
||||
- WASM readiness: keep `protocol`/`sim`/`scene`/`game` wasm-compatible; isolate native-only code behind features/editor.
|
||||
|
||||
### Risks and mitigations
|
||||
- Cross-platform determinism (floats/physics): fixed timestep + deterministic controller + server authority + state-hash tests; constrain prediction scope.
|
||||
- Bevy churn (every ~3 months): migration checklist, pinned versions, upgrades gated on Lightyear/ecosystem readiness.
|
||||
- Scope (game + toolkit + MP): enforce milestone gating and vertical slices; resist premature generality until M5/M6.
|
||||
- Toolkit/game coupling: keep game content out of reusable crates; reusable code must not depend on `crates/game`.
|
||||
|
||||
### How the plan-of-plans operates
|
||||
This roadmap is the index, and M1's full detail is contained inline above. Starting a later milestone expands its detailed scope/files/todos the same way (either inline or as a spun-out plan). M0 and M2 are the immediate next planning targets; M3 begins with a time-boxed Lightyear spike before committing.
|
||||
|
||||
### Out of scope for now
|
||||
- Concrete release dates/staffing (cadence assumptions only), console ports, peer-to-peer topology, and full anti-cheat beyond server authority.
|
||||
27
.cursor/plans/rendering_unification_2026-06-05.plan.md
Normal file
27
.cursor/plans/rendering_unification_2026-06-05.plan.md
Normal file
@ -0,0 +1,27 @@
|
||||
# Rendering Unification Plan
|
||||
|
||||
## Summary
|
||||
|
||||
Unify the Bevy 0.18 rendering framework around a single effective render-stack contract. Preserve
|
||||
Hybrid Auto GI: requested Solari provides realtime indirect lighting when ready, while Forward PBR
|
||||
remains the correctness fallback for unsupported RT hardware, Solari warmup, and local point/spot
|
||||
shadow lights.
|
||||
|
||||
## Implementation
|
||||
|
||||
- Add `settings::EffectiveRenderStack` and `RenderFallbackReason` as the authoritative GI fallback
|
||||
decision.
|
||||
- Route editor camera stack sync and diagnostics through the shared resolver.
|
||||
- Track requested and effective GI separately in `RenderingCapabilities`.
|
||||
- Keep requested Solari raytracing mesh tagging alive while the camera temporarily renders Forward.
|
||||
- Extend material authoring with emissive color, intensity, and texture fields for Solari-compatible
|
||||
emissive scene lighting.
|
||||
- Add diagnostics for requested/effective GI, fallback reason, and Solari mesh eligibility counts.
|
||||
|
||||
## Verification
|
||||
|
||||
- `cargo fmt --check`
|
||||
- `cargo check --workspace`
|
||||
- `cargo clippy --workspace`
|
||||
- Focused tests for settings, shared hydration, game rendering, and editor camera stack behavior.
|
||||
- `cargo run -p xtask -- validate-levels`
|
||||
26
.cursor/plans/static_mesh_asset_refactor_2026-06-05.plan.md
Normal file
26
.cursor/plans/static_mesh_asset_refactor_2026-06-05.plan.md
Normal file
@ -0,0 +1,26 @@
|
||||
# Static Mesh Asset Refactor Plan
|
||||
|
||||
Date: 2026-06-05
|
||||
|
||||
## Scope
|
||||
|
||||
- Normalize glTF/GLB and FBX model placement through one static mesh renderer authoring component.
|
||||
- Generate project static mesh manifests under `assets/meshes/generated/`.
|
||||
- Preserve explicit scene-instance placement for full `SceneRoot` behavior.
|
||||
- Redesign the actor inspector around component cards and add renderer slot controls.
|
||||
|
||||
## Implementation Slices
|
||||
|
||||
1. Add `StaticMeshRenderer` shared authoring data, validation, scene save allowlist, history snapshots, and hydration.
|
||||
2. Extend the asset registry import settings with placement mode, hierarchy mode, material policy, collider generation, and generated manifest path.
|
||||
3. Add glTF/FBX static mesh manifest generation and route model drag/drop through renderer placement by default.
|
||||
4. Expose asset import settings and static mesh renderer controls in editor UI.
|
||||
5. Update ADR/docs/README and verify with focused checks.
|
||||
|
||||
## Shipped Behavior
|
||||
|
||||
- `StaticAsset` placement creates `ActorKind::StaticMesh + StaticMeshRenderer`.
|
||||
- `SceneInstance` placement keeps `ActorKind::ImportedModel + ModelRef`.
|
||||
- `SingleActor` hierarchy mode stores all mesh parts on one renderer.
|
||||
- `SourceHierarchy` mode creates a root with child static mesh actors.
|
||||
- Static mesh hydration spawns generated child `Mesh3d` parts with source materials, shadow flags, and optional Avian mesh collider constructors.
|
||||
42
.cursor/rules/documentation.mdc
Normal file
42
.cursor/rules/documentation.mdc
Normal file
@ -0,0 +1,42 @@
|
||||
---
|
||||
description: Keep project documentation current—ADRs, mission, editor docs, README checklist
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Documentation Maintenance
|
||||
|
||||
When changing behavior, architecture, or user-facing workflows, **update docs in the same task**—do not defer.
|
||||
|
||||
## Doc map (single source of truth: [docs/README.md](../../docs/README.md))
|
||||
|
||||
| Change type | Update |
|
||||
|-------------|--------|
|
||||
| Architecture / crate boundary / sim-network split | New or updated ADR in `docs/adr/` |
|
||||
| Editor framework intent, principles, non-goals | `docs/mission.md` |
|
||||
| Editor feature design or phase status | `docs/editor/` (see its README) |
|
||||
| User controls, run commands, troubleshooting | Root `README.md` |
|
||||
| Shipped feature completion | Root `README.md` Implementation Checklist |
|
||||
| Bevy upgrade policy | ADR 0002 + migration notes in `docs/adr/` |
|
||||
| Milestone scope (pre-implementation) | `.cursor/plans/` plan file |
|
||||
|
||||
## ADRs
|
||||
|
||||
Create `docs/adr/NNNN-short-title.md` when a decision is **hard to reverse** or affects multiple crates (rendering path, scene format, PIE semantics, settings schema, netcode choice).
|
||||
|
||||
Format: Status, Context, Decision, Consequences. Number sequentially. Link from `docs/README.md` and root README.
|
||||
|
||||
## Coherence rules
|
||||
|
||||
- **One home per fact.** Link across docs; do not copy paragraphs between README, mission, and ADRs.
|
||||
- **Intent vs implementation:** mission/ADRs = *why*; README = *how to use*; crate module docs = *local API*.
|
||||
- **Plans vs permanent docs:** `.cursor/plans/` holds working roadmaps; promote stable decisions into `docs/` when implemented.
|
||||
- **Stale docs are bugs.** If code contradicts docs, fix both or fix docs in the same PR/task.
|
||||
- **New subsystems** get a short entry in `docs/README.md` and, if editor-facing, `docs/editor/README.md`.
|
||||
|
||||
## Agent checklist (end of relevant tasks)
|
||||
|
||||
1. Did behavior or UX change? → README controls / checklist
|
||||
2. Did architecture or policy change? → ADR or mission
|
||||
3. Did editor workflow change? → `docs/editor/`
|
||||
4. Added a new doc file? → index in `docs/README.md`
|
||||
5. Left a deliberate gap? → "Future work" in README or an ADR Consequences section—not silent omission
|
||||
34
.gitattributes
vendored
Normal file
34
.gitattributes
vendored
Normal file
@ -0,0 +1,34 @@
|
||||
* text=auto eol=lf
|
||||
|
||||
*.rs text eol=lf
|
||||
*.toml text eol=lf
|
||||
*.ron text eol=lf
|
||||
*.md text eol=lf
|
||||
*.wgsl text eol=lf
|
||||
*.json text eol=lf
|
||||
*.yml text eol=lf
|
||||
*.yaml text eol=lf
|
||||
|
||||
*.fbx filter=lfs diff=lfs merge=lfs -text
|
||||
*.glb filter=lfs diff=lfs merge=lfs -text
|
||||
*.gltf filter=lfs diff=lfs merge=lfs -text
|
||||
*.blend filter=lfs diff=lfs merge=lfs -text
|
||||
*.png filter=lfs diff=lfs merge=lfs -text
|
||||
*.jpg filter=lfs diff=lfs merge=lfs -text
|
||||
*.jpeg filter=lfs diff=lfs merge=lfs -text
|
||||
*.webp filter=lfs diff=lfs merge=lfs -text
|
||||
*.tga filter=lfs diff=lfs merge=lfs -text
|
||||
*.exr filter=lfs diff=lfs merge=lfs -text
|
||||
*.hdr filter=lfs diff=lfs merge=lfs -text
|
||||
*.wav filter=lfs diff=lfs merge=lfs -text
|
||||
*.ogg filter=lfs diff=lfs merge=lfs -text
|
||||
*.mp3 filter=lfs diff=lfs merge=lfs -text
|
||||
*.flac filter=lfs diff=lfs merge=lfs -text
|
||||
*.mp4 filter=lfs diff=lfs merge=lfs -text
|
||||
*.mov filter=lfs diff=lfs merge=lfs -text
|
||||
*.zip filter=lfs diff=lfs merge=lfs -text
|
||||
*.7z filter=lfs diff=lfs merge=lfs -text
|
||||
*.rar filter=lfs diff=lfs merge=lfs -text
|
||||
*.psd filter=lfs diff=lfs merge=lfs -text
|
||||
*.kra filter=lfs diff=lfs merge=lfs -text
|
||||
*.clip filter=lfs diff=lfs merge=lfs -text
|
||||
76
.github/workflows/ci.yml
vendored
Normal file
76
.github/workflows/ci.yml
vendored
Normal file
@ -0,0 +1,76 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
WGPU_VALIDATION: "0"
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
name: Format, lint, test, build
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Linux dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
clang \
|
||||
mold \
|
||||
pkg-config \
|
||||
libasound2-dev \
|
||||
libudev-dev \
|
||||
libwayland-dev \
|
||||
libx11-dev \
|
||||
libxkbcommon-dev \
|
||||
libxi-dev \
|
||||
libxcursor-dev \
|
||||
libxrandr-dev \
|
||||
libxinerama-dev \
|
||||
libgl1-mesa-dev \
|
||||
libvulkan-dev
|
||||
|
||||
- name: Install Rust toolchain
|
||||
run: rustup show
|
||||
|
||||
- name: Check formatting
|
||||
run: cargo fmt --check
|
||||
|
||||
- name: Check workspace
|
||||
run: cargo check --workspace
|
||||
|
||||
- name: Clippy
|
||||
run: cargo clippy --workspace
|
||||
|
||||
- name: Clippy foundation crates
|
||||
run: cargo clippy -p game -p shared -- -D warnings
|
||||
|
||||
- name: Test foundation helpers
|
||||
run: cargo test -p game --lib
|
||||
|
||||
- name: Test shared hydration and actor validation
|
||||
run: cargo test -p shared
|
||||
|
||||
- name: Test sim crate
|
||||
run: cargo test -p sim
|
||||
|
||||
- name: Validate level scenes
|
||||
run: cargo run -p xtask --bin validate-levels
|
||||
|
||||
- name: Test scene schema crate
|
||||
run: cargo test -p scene
|
||||
|
||||
- name: Check editor (dev)
|
||||
run: cargo check -p editor --features dev
|
||||
|
||||
- name: Build binaries
|
||||
run: |
|
||||
cargo build -p game
|
||||
cargo build -p editor
|
||||
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal file
@ -0,0 +1,26 @@
|
||||
# Rust build output
|
||||
/target/
|
||||
|
||||
# Local automation and assistant state
|
||||
/.agents/
|
||||
/.codex/
|
||||
|
||||
# Local environment files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Editor/runtime generated session state
|
||||
/assets/levels/.pie_session.scn.ron
|
||||
|
||||
# Backup, temporary, and log files
|
||||
**/*.rs.bk
|
||||
Cargo.lock.bak
|
||||
*.bak
|
||||
*.tmp
|
||||
*.temp
|
||||
*.log
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
9
.vscode/extensions.json
vendored
Normal file
9
.vscode/extensions.json
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"rust-lang.rust-analyzer",
|
||||
"tamasfe.even-better-toml",
|
||||
"vadimcn.vscode-lldb",
|
||||
"serayuzgur.crates",
|
||||
"usernamehw.errorlens"
|
||||
]
|
||||
}
|
||||
51
.vscode/launch.json
vendored
Normal file
51
.vscode/launch.json
vendored
Normal file
@ -0,0 +1,51 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"type": "lldb",
|
||||
"request": "launch",
|
||||
"name": "Debug editor",
|
||||
"preLaunchTask": "build editor (dev fast-link)",
|
||||
"program": "${workspaceFolder}/target/debug/editor",
|
||||
"args": [],
|
||||
"env": {
|
||||
"BEVY_FPS_HDR": "0",
|
||||
"WGPU_VALIDATION": "0",
|
||||
"CARGO_TARGET_DIR": "${workspaceFolder}/target",
|
||||
"LD_LIBRARY_PATH": "${workspaceFolder}/target/debug/deps:${env:LD_LIBRARY_PATH}"
|
||||
},
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
{
|
||||
"type": "lldb",
|
||||
"request": "launch",
|
||||
"name": "Debug game",
|
||||
"preLaunchTask": "build game (dev fast-link)",
|
||||
"program": "${workspaceFolder}/target/debug/game",
|
||||
"args": [],
|
||||
"env": {
|
||||
"BEVY_FPS_HDR": "0",
|
||||
"WGPU_VALIDATION": "0",
|
||||
"CARGO_TARGET_DIR": "${workspaceFolder}/target",
|
||||
"LD_LIBRARY_PATH": "${workspaceFolder}/target/debug/deps:${env:LD_LIBRARY_PATH}"
|
||||
},
|
||||
"cwd": "${workspaceFolder}"
|
||||
},
|
||||
{
|
||||
"type": "lldb",
|
||||
"request": "launch",
|
||||
"name": "Run editor (release)",
|
||||
"cargo": {
|
||||
"args": ["build", "--release", "-p", "editor"],
|
||||
"filter": { "name": "editor", "kind": "bin" }
|
||||
},
|
||||
"args": [],
|
||||
"env": {
|
||||
"BEVY_FPS_HDR": "0",
|
||||
"WGPU_VALIDATION": "0",
|
||||
"LD_LIBRARY_PATH": "${workspaceFolder}/target/release/deps:${env:LD_LIBRARY_PATH}"
|
||||
},
|
||||
"cwd": "${workspaceFolder}"
|
||||
}
|
||||
]
|
||||
}
|
||||
27
.vscode/settings.json
vendored
Normal file
27
.vscode/settings.json
vendored
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
// Use clippy for richer diagnostics on save.
|
||||
"rust-analyzer.check.command": "clippy",
|
||||
"rust-analyzer.check.allTargets": true,
|
||||
|
||||
// Bevy/wgpu generate a lot of proc-macro code; keep these on for good IntelliSense.
|
||||
"rust-analyzer.cargo.buildScripts.enable": true,
|
||||
"rust-analyzer.procMacro.enable": true,
|
||||
|
||||
// Surface compile errors inline as you type.
|
||||
"rust-analyzer.diagnostics.enable": true,
|
||||
|
||||
"editor.formatOnSave": true,
|
||||
"[rust]": {
|
||||
"editor.defaultFormatter": "rust-lang.rust-analyzer",
|
||||
"editor.formatOnSave": true
|
||||
},
|
||||
|
||||
// Keep the large /target directory out of search and file watching.
|
||||
"files.watcherExclude": {
|
||||
"**/target/**": true
|
||||
},
|
||||
"search.exclude": {
|
||||
"**/target/**": true,
|
||||
"**/Cargo.lock": true
|
||||
}
|
||||
}
|
||||
185
.vscode/tasks.json
vendored
Normal file
185
.vscode/tasks.json
vendored
Normal file
@ -0,0 +1,185 @@
|
||||
{
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "cargo fmt (check)",
|
||||
"type": "cargo",
|
||||
"command": "fmt",
|
||||
"args": ["--check"],
|
||||
"problemMatcher": [],
|
||||
"group": "build"
|
||||
},
|
||||
{
|
||||
"label": "cargo check (workspace)",
|
||||
"type": "cargo",
|
||||
"command": "check",
|
||||
"args": ["--workspace"],
|
||||
"problemMatcher": ["$rustc"],
|
||||
"group": "build"
|
||||
},
|
||||
{
|
||||
"label": "cargo clippy (workspace)",
|
||||
"type": "cargo",
|
||||
"command": "clippy",
|
||||
"args": ["--workspace"],
|
||||
"problemMatcher": ["$rustc"],
|
||||
"group": "build"
|
||||
},
|
||||
{
|
||||
"label": "cargo clippy (foundation strict)",
|
||||
"type": "cargo",
|
||||
"command": "clippy",
|
||||
"args": ["-p", "game", "-p", "shared", "--", "-D", "warnings"],
|
||||
"problemMatcher": ["$rustc"],
|
||||
"group": "build"
|
||||
},
|
||||
{
|
||||
"label": "cargo test (foundation)",
|
||||
"type": "cargo",
|
||||
"command": "test",
|
||||
"args": ["-p", "game", "--lib"],
|
||||
"problemMatcher": ["$rustc"],
|
||||
"group": "test"
|
||||
},
|
||||
{
|
||||
"label": "build game (debug)",
|
||||
"type": "cargo",
|
||||
"command": "build",
|
||||
"args": ["-p", "game"],
|
||||
"problemMatcher": ["$rustc"],
|
||||
"group": "build"
|
||||
},
|
||||
{
|
||||
"label": "build editor (debug)",
|
||||
"type": "cargo",
|
||||
"command": "build",
|
||||
"args": ["-p", "editor"],
|
||||
"problemMatcher": ["$rustc"],
|
||||
"group": { "kind": "build", "isDefault": true }
|
||||
},
|
||||
{
|
||||
"label": "build editor (dev fast-link)",
|
||||
"type": "cargo",
|
||||
"command": "build",
|
||||
"args": ["-p", "editor", "--features", "dev"],
|
||||
"options": {
|
||||
"env": {
|
||||
"CARGO_TARGET_DIR": "${workspaceFolder}/target"
|
||||
}
|
||||
},
|
||||
"problemMatcher": ["$rustc"],
|
||||
"group": "build"
|
||||
},
|
||||
{
|
||||
"label": "build game (dev fast-link)",
|
||||
"type": "cargo",
|
||||
"command": "build",
|
||||
"args": ["-p", "game", "--features", "dev"],
|
||||
"options": {
|
||||
"env": {
|
||||
"CARGO_TARGET_DIR": "${workspaceFolder}/target"
|
||||
}
|
||||
},
|
||||
"problemMatcher": ["$rustc"],
|
||||
"group": "build"
|
||||
},
|
||||
{
|
||||
"label": "clean build editor (dev)",
|
||||
"type": "shell",
|
||||
"command": "cargo clean -p editor && cargo build -p editor --features dev",
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}",
|
||||
"env": {
|
||||
"CARGO_TARGET_DIR": "${workspaceFolder}/target"
|
||||
}
|
||||
},
|
||||
"problemMatcher": ["$rustc"],
|
||||
"group": "build"
|
||||
},
|
||||
{
|
||||
"label": "target cleanup (dry run)",
|
||||
"type": "shell",
|
||||
"command": "cargo clean-target",
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}",
|
||||
"env": {
|
||||
"CARGO_TARGET_DIR": "${workspaceFolder}/target"
|
||||
}
|
||||
},
|
||||
"problemMatcher": [],
|
||||
"group": "build"
|
||||
},
|
||||
{
|
||||
"label": "target cleanup (safe apply)",
|
||||
"type": "shell",
|
||||
"command": "cargo clean-target --apply",
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}",
|
||||
"env": {
|
||||
"CARGO_TARGET_DIR": "${workspaceFolder}/target"
|
||||
}
|
||||
},
|
||||
"problemMatcher": [],
|
||||
"group": "build"
|
||||
},
|
||||
{
|
||||
"label": "run editor (dev fast-link)",
|
||||
"type": "cargo",
|
||||
"command": "run",
|
||||
"args": ["-p", "editor", "--features", "dev"],
|
||||
"options": {
|
||||
"env": {
|
||||
"BEVY_FPS_HDR": "0",
|
||||
"WGPU_VALIDATION": "0",
|
||||
"CARGO_TARGET_DIR": "${workspaceFolder}/target"
|
||||
}
|
||||
},
|
||||
"problemMatcher": ["$rustc"],
|
||||
"group": "build"
|
||||
},
|
||||
{
|
||||
"label": "run game (dev fast-link)",
|
||||
"type": "cargo",
|
||||
"command": "run",
|
||||
"args": ["-p", "game", "--features", "dev"],
|
||||
"options": {
|
||||
"env": {
|
||||
"BEVY_FPS_HDR": "0",
|
||||
"WGPU_VALIDATION": "0",
|
||||
"CARGO_TARGET_DIR": "${workspaceFolder}/target"
|
||||
}
|
||||
},
|
||||
"problemMatcher": ["$rustc"],
|
||||
"group": "build"
|
||||
},
|
||||
{
|
||||
"label": "watch game_hot (hot reload)",
|
||||
"type": "shell",
|
||||
"command": "cargo watch -w crates/game_hot -w crates/sim -x \"build -p game_hot --features dylib\"",
|
||||
"options": {
|
||||
"cwd": "${workspaceFolder}",
|
||||
"env": {
|
||||
"CARGO_TARGET_DIR": "${workspaceFolder}/target"
|
||||
}
|
||||
},
|
||||
"problemMatcher": ["$rustc"],
|
||||
"group": "build",
|
||||
"isBackground": true
|
||||
},
|
||||
{
|
||||
"label": "verify foundation",
|
||||
"dependsOrder": "sequence",
|
||||
"dependsOn": [
|
||||
"cargo fmt (check)",
|
||||
"cargo check (workspace)",
|
||||
"cargo clippy (workspace)",
|
||||
"cargo clippy (foundation strict)",
|
||||
"cargo test (foundation)",
|
||||
"build game (debug)",
|
||||
"build editor (debug)"
|
||||
],
|
||||
"problemMatcher": [],
|
||||
"group": { "kind": "build", "isDefault": true }
|
||||
}
|
||||
]
|
||||
}
|
||||
35
AGENTS.md
Normal file
35
AGENTS.md
Normal file
@ -0,0 +1,35 @@
|
||||
# Agent Instructions
|
||||
|
||||
## Documentation Maintenance
|
||||
|
||||
When changing behavior, architecture, or user-facing workflows, update documentation in the same task. Do not defer documentation updates for implemented work.
|
||||
|
||||
Use `docs/README.md` as the documentation map and keep each fact in one home:
|
||||
|
||||
| Change type | Update |
|
||||
|-------------|--------|
|
||||
| Architecture, crate boundaries, or sim/network split | New or updated ADR in `docs/adr/` |
|
||||
| Editor framework intent, principles, or non-goals | `docs/mission.md` |
|
||||
| Editor feature design or phase status | `docs/editor/` |
|
||||
| User controls, run commands, or troubleshooting | Root `README.md` |
|
||||
| Shipped feature completion | Root `README.md` implementation checklist |
|
||||
| Bevy upgrade policy | ADR 0002 and migration notes in `docs/adr/` |
|
||||
| Milestone scope before implementation | `.cursor/plans/` plan file |
|
||||
|
||||
Create an ADR in `docs/adr/NNNN-short-title.md` when a decision is hard to reverse or affects multiple crates, such as rendering path, scene format, PIE semantics, settings schema, or netcode choice. Use this format: Status, Context, Decision, Consequences. Number ADRs sequentially and link new ADRs from `docs/README.md` and the root README when relevant.
|
||||
|
||||
Coherence rules:
|
||||
|
||||
- One home per fact. Link across docs; do not copy paragraphs between README, mission, and ADRs.
|
||||
- Intent vs implementation: mission/ADRs explain why, README explains how to use, crate module docs explain local API.
|
||||
- Plans vs permanent docs: `.cursor/plans/` holds working roadmaps; promote stable decisions into `docs/` when implemented.
|
||||
- Stale docs are bugs. If code contradicts docs, fix both or fix docs in the same PR/task.
|
||||
- New subsystems get a short entry in `docs/README.md` and, if editor-facing, `docs/editor/README.md`.
|
||||
|
||||
End-of-task checklist for relevant implementation work:
|
||||
|
||||
1. Did behavior or UX change? Update README controls or checklist.
|
||||
2. Did architecture or policy change? Update or create an ADR or mission note.
|
||||
3. Did an editor workflow change? Update `docs/editor/`.
|
||||
4. Added a new doc file? Index it in `docs/README.md`.
|
||||
5. Left a deliberate gap? Record it as future work in README or an ADR Consequences section.
|
||||
8217
Cargo.lock
generated
Normal file
8217
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
63
Cargo.toml
Normal file
63
Cargo.toml
Normal file
@ -0,0 +1,63 @@
|
||||
[workspace]
|
||||
members = [
|
||||
"crates/game",
|
||||
"crates/game_hot",
|
||||
"crates/shared",
|
||||
"crates/editor",
|
||||
"crates/protocol",
|
||||
"crates/sim",
|
||||
"crates/settings",
|
||||
"crates/scene",
|
||||
"xtask",
|
||||
]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
[workspace.dependencies]
|
||||
avian3d = { version = "0.6", default-features = false, features = [
|
||||
"3d",
|
||||
"f32",
|
||||
"parry-f32",
|
||||
"bevy_scene",
|
||||
"collider-from-mesh",
|
||||
"serialize",
|
||||
] }
|
||||
bevy = { version = "0.18", features = ["serialize"] }
|
||||
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" }
|
||||
game = { path = "crates/game" }
|
||||
game_hot = { path = "crates/game_hot" }
|
||||
protocol = { path = "crates/protocol" }
|
||||
sim = { path = "crates/sim" }
|
||||
settings = { path = "crates/settings" }
|
||||
scene = { path = "crates/scene" }
|
||||
|
||||
[patch.crates-io]
|
||||
# Atmosphere-aware mesh view bind groups (WYSIWYG editor + transform gizmos).
|
||||
transform-gizmo-bevy = { path = "third_party/transform-gizmo-bevy" }
|
||||
|
||||
# Compile our own crate with light optimizations, but fully optimize dependencies
|
||||
# (the bevy/wgpu stack) so debug builds are not unbearably slow.
|
||||
[profile.dev]
|
||||
opt-level = 1
|
||||
rpath = true
|
||||
|
||||
[profile.dev.package."*"]
|
||||
opt-level = 3
|
||||
|
||||
# A snappy release profile for shipping builds.
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
codegen-units = 1
|
||||
314
README.md
Normal file
314
README.md
Normal file
@ -0,0 +1,314 @@
|
||||
# Bevy FPS Foundation
|
||||
|
||||
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
|
||||
raytraced indirect lighting where supported, and local input/presentation glue for a fixed-step
|
||||
kinematic FPS simulation. The editor provides dockable egui panels, a
|
||||
filtered scene hierarchy, click selection, transform gizmos, undo/redo, asset import/placement,
|
||||
native Bevy scene save/load, and BRP support for external tooling.
|
||||
|
||||
## Requirements
|
||||
|
||||
- Rust (stable; pinned via `rust-toolchain.toml`)
|
||||
- Git LFS for binary game/source assets: run `git lfs install` once, then `git lfs pull` after clone.
|
||||
- A Vulkan-capable GPU + drivers (developed against an NVIDIA RTX 3080 Ti)
|
||||
- Linux build deps for `winit`/`wgpu` (ALSA, udev, etc.) if not already present
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
# Check everything
|
||||
cargo fmt --check
|
||||
cargo check --workspace
|
||||
cargo clippy --workspace
|
||||
cargo clippy -p game -p shared -p protocol -p sim -- -D warnings
|
||||
cargo test -p game --lib
|
||||
cargo test -p sim
|
||||
|
||||
# Runtime game
|
||||
cargo run -p game
|
||||
cargo run -p game --features dev
|
||||
|
||||
# In-engine editor
|
||||
cargo run -p editor
|
||||
cargo run -p editor --features dev
|
||||
```
|
||||
|
||||
### Hot reload (gameplay iteration)
|
||||
|
||||
`--features dev` turns on Bevy **dynamic linking** for faster rebuilds (recommended day-to-day).
|
||||
|
||||
Add `--features hot-reload` for **in-process hot reload** of [`game_hot`](crates/game_hot) (sim movement, player input/camera, world bootstrap). The editor process stays open; only gameplay system bodies swap when the dylib rebuilds.
|
||||
|
||||
**Two-terminal workflow (hot reload):**
|
||||
|
||||
```bash
|
||||
# Terminal 1 — rebuild the hot dylib on save
|
||||
cargo watch -w crates/game_hot -w crates/sim -x "build -p game_hot --features dylib"
|
||||
|
||||
# Terminal 2 — run the editor once
|
||||
cargo run -p editor --features dev,hot-reload
|
||||
```
|
||||
|
||||
Or use the VS Code task **watch game_hot (hot reload)** alongside **run editor (dev fast-link)** (enable the `hot-reload` feature on the editor run task when using dylib iteration).
|
||||
|
||||
| Input | Action |
|
||||
|-------|--------|
|
||||
| Save `game_hot` / `sim` gameplay code | Dylib rebuilds; status bar shows **building…** then **reloaded** |
|
||||
| **Ctrl+Shift+R** | Manual refresh (`on_hot_reload` bootstrap: camera FX, ambient light) |
|
||||
|
||||
**Safe to hot reload:** system logic, movement tuning in code, input mapping.
|
||||
|
||||
**Requires full editor restart:** new/changed component fields, new components, plugin schedule changes, editor UI code.
|
||||
|
||||
> Note: the workspace uses the `mold` linker via `clang` (see `.cargo/config.toml`) for fast link times.
|
||||
|
||||
### Target Cache Cleanup
|
||||
|
||||
Cargo/Bevy debug artifacts can grow quickly. Use the workspace cleanup task before reaching for a full `cargo clean`:
|
||||
|
||||
| Command | Effect |
|
||||
|---------|--------|
|
||||
| `cargo clean-target` | Dry run: prints reclaimable `target/` cache. |
|
||||
| `cargo clean-target --apply` | Safe cleanup: removes incremental and rust-analyzer flycheck cache. |
|
||||
| `cargo clean-target --include-artifacts --days 3 --apply` | Deeper cleanup: also removes stale hashed `deps`, `build`, `.fingerprint`, and example artifacts older than 3 days. Cargo will rebuild anything still needed. |
|
||||
|
||||
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.
|
||||
- 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.
|
||||
|
||||
## Editor Controls
|
||||
|
||||
| Input | Action |
|
||||
|-------|--------|
|
||||
| `F5` | Toggle Edit / Play in the same viewport |
|
||||
| `F6` | While playing: pause / resume simulation (stay in Play mode) |
|
||||
| `F8` | While playing: possess player / eject to editor camera |
|
||||
| `G` | Toggle clean game-view overlay (hide editor widgets/proxies/gizmos) |
|
||||
| `Ctrl+G` | Toggle viewport grid |
|
||||
| `F` | Focus editor camera on selection |
|
||||
| `Ctrl+Shift+1` / `Ctrl+Shift+2` | Save / recall viewport camera bookmark |
|
||||
| RMB + mouse | Editor camera look |
|
||||
| RMB + `W` `A` `S` `D` | Editor camera fly |
|
||||
| RMB + `Q` / `E` | Editor camera down / up |
|
||||
| Mouse wheel | Dolly editor camera |
|
||||
| MMB + mouse | Pan editor camera |
|
||||
| Click authored object, actor root icon, or visualizer in viewport | Select source entity |
|
||||
| Click empty viewport / `Esc` | Deselect |
|
||||
| `Delete` / `Backspace` | Delete selection |
|
||||
| `Ctrl+D` | Duplicate selection |
|
||||
| `Ctrl+Z` / `Ctrl+Shift+Z` / `Ctrl+Y` | Undo / redo |
|
||||
| `F2` in Hierarchy | Rename selection |
|
||||
| `W` / `E` / `R` | Translate / rotate / scale gizmo |
|
||||
| `X` | Toggle world/local gizmo orientation |
|
||||
| Viewport toolbar (sun / brush / box icons) | Shading: Lit, Unlit (albedo), Colliders (mesh off) |
|
||||
| Viewport eye/options | Toggle actor root icon categories, colliders, lights, spawns, prefab/model anchors, and runtime player/camera visualizers |
|
||||
| `Tab` in viewport | Cycle selection through overlapping objects at last click |
|
||||
| Click Player visualizer in Edit mode | Select or create the authored `PlayerSpawn` (`Player Start`) |
|
||||
| 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, inspect import settings; drag assets into the viewport to place |
|
||||
| Asset Browser texture action | Assign base-color texture to selection |
|
||||
| `Ctrl+P` | Command palette (`scene.reset_lighting`, `selection.group`, `selection.focus`, play commands) |
|
||||
| `F7` | While paused in Play: advance one sim tick |
|
||||
| Shift/Ctrl + click (Hierarchy) | Additive selection |
|
||||
| Hierarchy context | Reparent to other selection / Unparent |
|
||||
| File menu | New, Open, Save, Save As, Import Assets, Export Selection, Save Selection As Prefab, Recent Scenes |
|
||||
| Inspector component card | Collapse with caret, toggle active with status dot, or use triple-dot menu for reset/copy/paste/move/remove actions |
|
||||
| Inspector footer → Add Component | Pinned search/add control for registered authoring, rendering, physics, and gameplay components with undo |
|
||||
| Edit → Project Settings… | Edit `assets/project.ron` (rendering, physics, input) |
|
||||
|
||||
### Play Mode
|
||||
|
||||
- Press **F5** (or Play menu) to run the **real game** in-process: same `GamePlugin`, player,
|
||||
fixed-step `sim`, and rendering stack as the standalone `game` binary.
|
||||
- The unified **Viewport** stays in place on play enter. The editor fly camera and player camera
|
||||
render to the same offscreen HDR texture depending on Edit/Play possession state.
|
||||
- While editing, gameplay simulation and input are disabled (`SimEnabled` / `GameInputEnabled`).
|
||||
Entering Play enables simulation; **F6** pauses/resumes the sim without leaving Play; **F8** toggles **possess** (player input + camera) vs **eject**
|
||||
(editor fly camera while sim keeps running).
|
||||
- Transport controls (Play, Pause, Stop, Eject) live on the **main toolbar** below the menu bar.
|
||||
- The viewport uses the full project rendering stack from **`assets/project.ron`**
|
||||
(Edit → Project Settings…). Only one 3D camera carries that stack at a
|
||||
time (editor fly cam while editing/ejected, player cam while possessed) so GPU bind limits are
|
||||
respected.
|
||||
- Add a `PlayerSpawn` marker component (via Inspector) on a level object to choose where Play
|
||||
starts; otherwise the default game spawn `(0, 1.5, 10)` is used.
|
||||
- Clean game-view overlay (`G`) hides editor widgets, actor root icons, visualizers, selectable proxies, gizmos,
|
||||
selection outlines, and grid without changing the active camera.
|
||||
- **F5** stops Play and restores **player sim state** only; authored scene edits made during PIE
|
||||
are kept. **Esc** frees the cursor mid-play.
|
||||
|
||||
## Game Controls
|
||||
|
||||
| Input | Action |
|
||||
|-------|--------|
|
||||
| `W` `A` `S` `D` | Move |
|
||||
| Mouse | Look |
|
||||
| `Space` | Jump (coyote-time + buffered) |
|
||||
| `Left Shift` | Sprint |
|
||||
| `Left Ctrl` | Crouch |
|
||||
| `Esc` | Release / re-grab mouse cursor |
|
||||
|
||||
## Scene Workflow
|
||||
|
||||
- Levels are saved as native Bevy dynamic scenes under `assets/levels/`.
|
||||
- Editable entities use reflectable authoring components from `crates/shared`.
|
||||
- The editable player start is an authored `PlayerSpawn`; moving it with the transform gizmo changes
|
||||
where Play mode boots the runtime player.
|
||||
- Project settings provide default ambient/sun lighting. A scene-authored directional `LightDesc`
|
||||
acts as a per-scene sun override and disables the project sun contribution.
|
||||
- Hydration systems turn authoring data into runtime meshes, GLTF scenes, materials, lights, and
|
||||
Avian colliders. Persisted `ActorId` / `ComponentInstanceId` values are stable; raw Bevy `Entity`
|
||||
IDs are runtime-only.
|
||||
- Inspector component active toggles are saved in `InspectorOrder`. Inactive authoring components
|
||||
keep their data but do not hydrate runtime meshes, lights, physics, or post-process effects.
|
||||
- Dragging glTF/GLB/FBX assets creates `StaticMeshRenderer` actors by default from normalized
|
||||
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 `SceneRoot` loading for animation/skinning/scene data.
|
||||
- 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
|
||||
`LevelObject` edits made during PIE **remain** in the scene (the level may show as dirty).
|
||||
- Play mode swaps the unified viewport between the player camera (possessed) and editor fly camera
|
||||
(ejected).
|
||||
- Gameplay movement runs in a fixed timestep in `crates/sim`. Raw keyboard/mouse input is translated
|
||||
by `crates/game` into `protocol::PlayerInputIntent` before it mutates simulation state.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Viewport is black or wireframe-only
|
||||
|
||||
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.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)).
|
||||
8. Scene **Open** / **Recent** runs hydration immediately; **zero runtime lights** after load usually means missing `LightDesc` on light actors (see Rendering panel).
|
||||
|
||||
### Hierarchy sort jumps when clicking
|
||||
|
||||
**Sort: Name** / **Type** now use stable tie-breakers (duplicate names like several `Pillar` rows stay put). Use **Sort: Manual** and drag-and-drop for explicit sibling order.
|
||||
|
||||
### Hierarchy scrolls during drag
|
||||
|
||||
Scroll-to-drag is disabled while dragging actors. Release the mouse to finish or cancel the drag.
|
||||
|
||||
## Cursor / VSCode Setup
|
||||
|
||||
The `.vscode/` folder is preconfigured:
|
||||
|
||||
- `extensions.json` recommends rust-analyzer, Even Better TOML, CodeLLDB, crates, and Error Lens.
|
||||
- `settings.json` runs `clippy` on save, enables proc-macro/build-script support, formats on save,
|
||||
and excludes `target/` from search/watch.
|
||||
- `tasks.json` provides workspace check/clippy plus game/editor build/run tasks.
|
||||
- `launch.json` provides CodeLLDB launch configs for the editor and game.
|
||||
- `.github/workflows/ci.yml` mirrors local formatting, check, clippy, test, and binary build verification.
|
||||
|
||||
## Architecture Decisions
|
||||
|
||||
- [Documentation index](docs/README.md)
|
||||
- [Mission — editor framework & principles](docs/mission.md)
|
||||
- [Editor framework docs](docs/editor/README.md)
|
||||
- [ADR 0001: Roadmap Architecture](docs/adr/0001-roadmap-architecture.md)
|
||||
- [ADR 0002: Bevy Version And Migration Policy](docs/adr/0002-bevy-version-and-migration-policy.md)
|
||||
- [ADR 0003: Editor Framework Mission](docs/adr/0003-editor-framework-mission.md)
|
||||
- [ADR 0014: Unified Viewport Model](docs/adr/0014-unified-viewport-model.md)
|
||||
- [ADR 0016: Unified Rendering Contract](docs/adr/0016-unified-rendering-contract.md)
|
||||
- [ADR 0017: Normalized Static Mesh Assets](docs/adr/0017-normalized-static-mesh-assets.md)
|
||||
|
||||
## Project Layout
|
||||
|
||||
```
|
||||
crates/
|
||||
protocol/ Shared tick, input intent, command, and message/event protocol types
|
||||
sim/ Fixed-step gameplay simulation and determinism test scaffolding
|
||||
shared/ Reflectable authoring components + hydration systems
|
||||
game/ Runtime FPS game library + standalone game binary
|
||||
editor/ In-process egui editor binary
|
||||
```
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
- [x] Workspace: `game`, `shared`, `editor`, `protocol`, `sim`
|
||||
- [x] Shared authoring components + hydration
|
||||
- [x] Protocol tick, input intent, command, and basic message/event types
|
||||
- [x] Fixed-step deterministic sim crate for player movement/controller state
|
||||
- [x] Runtime game refactored into `GamePlugin`
|
||||
- [x] Raw game input translated into protocol intent before simulation
|
||||
- [x] Docked egui editor scaffold
|
||||
- [x] Editor fly camera
|
||||
- [x] Mesh picking selection + highlights
|
||||
- [x] Transform gizmos
|
||||
- [x] Editor-side scene visualizers for colliders, lights, player spawns, prefab/model anchors, and runtime player/cameras
|
||||
- [x] Selectable actor root icons in the 3D viewport with per-category visibility controls
|
||||
- [x] Filtered hierarchy, inspector, viewport, toolbar, asset browser, and status panels
|
||||
- [x] Delete, duplicate, rename, and structural/material undo-redo
|
||||
- [x] Native Bevy scene New/Open/Save/Save As with dirty title tracking
|
||||
- [x] Asset import, static mesh/prefab placement, texture assignment, and selection export
|
||||
- [x] PIE player-only snapshot/restore (authored `LevelObject` edits persist on stop)
|
||||
- [x] Unified viewport render-to-texture target + Play session bootstrap
|
||||
- [x] `PlayerSpawn` authoring marker for editor Play start location
|
||||
- [x] BRP enabled in the editor
|
||||
- [x] Cursor/VSCode workspace tasks + launch configs
|
||||
- [x] Opaque Wayland window launch defaults + SDR/HDR runtime toggle
|
||||
- [x] CI workflow for format, check, clippy, tests, and binary builds
|
||||
- [x] ADRs for roadmap architecture and Bevy migration policy
|
||||
- [x] Determinism harness: same inputs over same ticks produce the same state summary/hash
|
||||
- [x] Verify: `cargo fmt --check` / `cargo check --workspace` / `cargo clippy --workspace` / strict foundation clippy / `cargo test -p sim`
|
||||
- [x] Stable asset registry with UUIDs + import settings in asset browser details
|
||||
- [x] Prefab instances (`PrefabInstance`) + save-as-prefab + unpack
|
||||
- [x] Unsaved-scene confirm on New/Open/Recent
|
||||
- [x] Hierarchy multi-select, reparent undo, multi-entity gizmo transform
|
||||
- [x] Typed inspector undo (light, rigid body, collider, primitive, material, static mesh renderer) + registry-driven Add Component footer
|
||||
- [x] Gameplay authoring markers + visualizers (`WeaponSpawn`, `TriggerVolume`, etc.)
|
||||
- [x] Command palette execution; PIE sim step (F7); `xtask validate-levels`
|
||||
- [x] ADRs 0005–0012 (prefab/registry, scene schema, EditorPlugin, editor structure, authoring/hydration, ActorKind, sun policy, zero-debt)
|
||||
- [x] `ActorInspectorSection` registry + game demo section (`game::editor_ext`)
|
||||
- [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); 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`)
|
||||
- [x] Material assets (`assets/materials/*.ron`, inspector picker, drag-drop to selection)
|
||||
- [x] Prefab instance overrides v2 (transform/material/child visibility apply-revert groups)
|
||||
- [x] Advanced rendering: `GiMode`, post-process volumes, Rendering panel, requested/effective render stack, Solari integration, emissive materials, post FX assets ([ADR 0013](docs/adr/0013-rendering-tiers-and-post-process-volumes.md), [ADR 0016](docs/adr/0016-unified-rendering-contract.md), [rendering guide](docs/editor/rendering.md))
|
||||
- [x] Static mesh renderer component, generated normalized mesh artifacts, source/one-actor hierarchy placement, and inspector renderer slots ([ADR 0017](docs/adr/0017-normalized-static-mesh-assets.md))
|
||||
- [x] Componentized actor inspector, unified component cards, thumbnail static mesh slots, imported asset Browse/Locate/Clear refs with inherited source defaults, collider split, shader-aware material data, and per-actor material overrides ([ADR 0018](docs/adr/0018-componentized-actor-inspector-and-materials.md))
|
||||
|
||||
## Notes / Future Work
|
||||
|
||||
- Crouch lowers the camera and movement speed; the collider stays full-height for stability.
|
||||
- The editor asset browser is filesystem-backed with folder/tree navigation, grid/list views,
|
||||
texture and model thumbnails (glTF albedo fast-path; offscreen render studio for FBX and
|
||||
untextured models), search/filter/sort controls, and a details pane. **File → Import Assets**
|
||||
accepts glTF/GLB and **FBX** (binary; copies sibling `.fbm` texture folders when present).
|
||||
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. Asset details can switch placement to **Scene Instance** for `ModelRef`/`SceneRoot`
|
||||
playback. 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.
|
||||
- Material assets are RON files under `assets/materials/`; shader schemas live under `assets/shaders/`. `MaterialDesc` stores shader kind, typed parameters, texture bindings, and StandardMaterial fields. Actor inspector edits create per-actor `MaterialOverride` data for static mesh slots instead of mutating shared material assets.
|
||||
- Per-field reflect undo for all components remains future work; typed `shared` inspectors cover the common authoring path.
|
||||
- The authoring/hydration layer is intentionally small so richer asset workflows (terrain,
|
||||
material graphs, lighting profiles) can be added without changing the scene format foundation.
|
||||
- The M2 sim split keeps camera pitch as local presentation state; body yaw and movement are
|
||||
fixed-step intent consumers. M3 should map the existing protocol envelopes onto Lightyear and add
|
||||
server-authoritative snapshots/prediction rather than broadening the local input path ad hoc.
|
||||
- The editor renders egui on a dedicated full-window `Camera2d` (`PrimaryEguiContext`,
|
||||
`RenderLayers::none()`, `auto_create_primary_context = false`) so the 3D viewport camera can be
|
||||
cropped to the viewport panel without cropping egui itself. Attaching egui to a viewport-cropped
|
||||
3D camera makes `egui_dock` lay out into a NaN rect and panics in `advance_cursor_after_rect`.
|
||||
226
assets/.index/registry.ron
Normal file
226
assets/.index/registry.ron
Normal file
@ -0,0 +1,226 @@
|
||||
[
|
||||
(
|
||||
id: ("31963299-d4c1-45dc-8f67-2f469ca1ad6d"),
|
||||
path: "assets/post_fx/chromatic_aberration.ron",
|
||||
label: "chromatic_aberration",
|
||||
kind_tag: "PostProcessEffect",
|
||||
import_settings: (
|
||||
scale: 1.0,
|
||||
generate_collider: true,
|
||||
lod0_only: true,
|
||||
placement_mode: StaticAsset,
|
||||
hierarchy_mode: SingleActor,
|
||||
material_policy: SourceMaterials,
|
||||
static_mesh_manifest_path: None,
|
||||
),
|
||||
dependencies: [],
|
||||
),
|
||||
(
|
||||
id: ("e14a9bc3-9a4e-47b0-901f-b31c004e1836"),
|
||||
path: "assets/post_fx/vignette.ron",
|
||||
label: "vignette",
|
||||
kind_tag: "PostProcessEffect",
|
||||
import_settings: (
|
||||
scale: 1.0,
|
||||
generate_collider: true,
|
||||
lod0_only: true,
|
||||
placement_mode: StaticAsset,
|
||||
hierarchy_mode: SingleActor,
|
||||
material_policy: SourceMaterials,
|
||||
static_mesh_manifest_path: None,
|
||||
),
|
||||
dependencies: [],
|
||||
),
|
||||
(
|
||||
id: ("b8b43f26-0eee-4ee8-9e16-53aedbc69205"),
|
||||
path: "assets/models/Room3.fbx",
|
||||
label: "Room3",
|
||||
kind_tag: "Model",
|
||||
import_settings: (
|
||||
scale: 1.7,
|
||||
generate_collider: true,
|
||||
lod0_only: true,
|
||||
placement_mode: StaticAsset,
|
||||
hierarchy_mode: SingleActor,
|
||||
material_policy: AuthoringOverride,
|
||||
static_mesh_manifest_path: Some("assets/meshes/generated/b8b43f26-0eee-4ee8-9e16-53aedbc69205.static_mesh.ron"),
|
||||
),
|
||||
dependencies: [],
|
||||
),
|
||||
(
|
||||
id: ("582cd326-c32d-49da-9424-ae38b3d60648"),
|
||||
path: "assets/models/Dumpster.fbx",
|
||||
label: "Dumpster",
|
||||
kind_tag: "Model",
|
||||
import_settings: (
|
||||
scale: 1.0,
|
||||
generate_collider: true,
|
||||
lod0_only: true,
|
||||
placement_mode: StaticAsset,
|
||||
hierarchy_mode: SourceHierarchy,
|
||||
material_policy: AuthoringOverride,
|
||||
static_mesh_manifest_path: Some("assets/meshes/generated/582cd326-c32d-49da-9424-ae38b3d60648.static_mesh.ron"),
|
||||
),
|
||||
dependencies: [],
|
||||
),
|
||||
(
|
||||
id: ("61802b85-fda9-4aff-94d9-fe5f0860c516"),
|
||||
path: "assets/levels/rendering_showcase.scn.ron",
|
||||
label: "rendering_showcase.scn",
|
||||
kind_tag: "Prefab",
|
||||
import_settings: (
|
||||
scale: 1.0,
|
||||
generate_collider: true,
|
||||
lod0_only: true,
|
||||
placement_mode: StaticAsset,
|
||||
hierarchy_mode: SingleActor,
|
||||
material_policy: SourceMaterials,
|
||||
static_mesh_manifest_path: None,
|
||||
),
|
||||
dependencies: [],
|
||||
),
|
||||
(
|
||||
id: ("48bcef13-e6a6-4260-bb1b-c29787bd8c70"),
|
||||
path: "assets/levels/editor_scene 3.scn.ron",
|
||||
label: "editor_scene 3.scn",
|
||||
kind_tag: "Prefab",
|
||||
import_settings: (
|
||||
scale: 1.0,
|
||||
generate_collider: true,
|
||||
lod0_only: true,
|
||||
placement_mode: StaticAsset,
|
||||
hierarchy_mode: SingleActor,
|
||||
material_policy: SourceMaterials,
|
||||
static_mesh_manifest_path: None,
|
||||
),
|
||||
dependencies: [],
|
||||
),
|
||||
(
|
||||
id: ("5a2307d0-48e5-40d3-a5c9-527c0cfd30f4"),
|
||||
path: "assets/levels/editor_scene.scn.ron",
|
||||
label: "editor_scene.scn",
|
||||
kind_tag: "Prefab",
|
||||
import_settings: (
|
||||
scale: 1.0,
|
||||
generate_collider: true,
|
||||
lod0_only: true,
|
||||
placement_mode: StaticAsset,
|
||||
hierarchy_mode: SingleActor,
|
||||
material_policy: SourceMaterials,
|
||||
static_mesh_manifest_path: None,
|
||||
),
|
||||
dependencies: [],
|
||||
),
|
||||
(
|
||||
id: ("dc13ce01-c7ce-43c5-974c-0e659ae49ab9"),
|
||||
path: "assets/levels/editor_scene 2.scn.ron",
|
||||
label: "editor_scene 2.scn",
|
||||
kind_tag: "Prefab",
|
||||
import_settings: (
|
||||
scale: 1.0,
|
||||
generate_collider: true,
|
||||
lod0_only: true,
|
||||
placement_mode: StaticAsset,
|
||||
hierarchy_mode: SingleActor,
|
||||
material_policy: SourceMaterials,
|
||||
static_mesh_manifest_path: None,
|
||||
),
|
||||
dependencies: [],
|
||||
),
|
||||
(
|
||||
id: ("802471df-8295-4997-80e2-db4191850fc9"),
|
||||
path: "assets/rendering_profiles/outdoor_haze.ron",
|
||||
label: "outdoor_haze",
|
||||
kind_tag: "RenderingProfile",
|
||||
import_settings: (
|
||||
scale: 1.0,
|
||||
generate_collider: true,
|
||||
lod0_only: true,
|
||||
placement_mode: StaticAsset,
|
||||
hierarchy_mode: SingleActor,
|
||||
material_policy: SourceMaterials,
|
||||
static_mesh_manifest_path: None,
|
||||
),
|
||||
dependencies: [],
|
||||
),
|
||||
(
|
||||
id: ("2c4a9866-17b5-4797-9a8f-726b2ffacdb9"),
|
||||
path: "assets/rendering_profiles/cave_dark.ron",
|
||||
label: "cave_dark",
|
||||
kind_tag: "RenderingProfile",
|
||||
import_settings: (
|
||||
scale: 1.0,
|
||||
generate_collider: true,
|
||||
lod0_only: true,
|
||||
placement_mode: StaticAsset,
|
||||
hierarchy_mode: SingleActor,
|
||||
material_policy: SourceMaterials,
|
||||
static_mesh_manifest_path: None,
|
||||
),
|
||||
dependencies: [],
|
||||
),
|
||||
(
|
||||
id: ("a57f2891-8536-47b8-b476-01c08b36ac43"),
|
||||
path: "assets/materials/concrete.ron",
|
||||
label: "concrete",
|
||||
kind_tag: "Material",
|
||||
import_settings: (
|
||||
scale: 1.0,
|
||||
generate_collider: true,
|
||||
lod0_only: true,
|
||||
placement_mode: StaticAsset,
|
||||
hierarchy_mode: SingleActor,
|
||||
material_policy: SourceMaterials,
|
||||
static_mesh_manifest_path: None,
|
||||
),
|
||||
dependencies: [],
|
||||
),
|
||||
(
|
||||
id: ("4f684592-50b1-47e4-888c-8ba537e4c29d"),
|
||||
path: "assets/materials/emissive_panel.ron",
|
||||
label: "emissive_panel",
|
||||
kind_tag: "Material",
|
||||
import_settings: (
|
||||
scale: 1.0,
|
||||
generate_collider: true,
|
||||
lod0_only: true,
|
||||
placement_mode: StaticAsset,
|
||||
hierarchy_mode: SingleActor,
|
||||
material_policy: SourceMaterials,
|
||||
static_mesh_manifest_path: None,
|
||||
),
|
||||
dependencies: [],
|
||||
),
|
||||
(
|
||||
id: ("7b143cfb-a9e9-45d4-8f01-cfaa66fa9811"),
|
||||
path: "assets/shaders/unlit.shader.ron",
|
||||
label: "unlit.shader",
|
||||
kind_tag: "ShaderSchema",
|
||||
import_settings: (
|
||||
scale: 1.0,
|
||||
generate_collider: true,
|
||||
lod0_only: true,
|
||||
placement_mode: StaticAsset,
|
||||
hierarchy_mode: SingleActor,
|
||||
material_policy: SourceMaterials,
|
||||
static_mesh_manifest_path: None,
|
||||
),
|
||||
dependencies: [],
|
||||
),
|
||||
(
|
||||
id: ("addf6f7f-8114-45f3-8566-70d1eaa1fec3"),
|
||||
path: "assets/shaders/standard_lit.shader.ron",
|
||||
label: "standard_lit.shader",
|
||||
kind_tag: "ShaderSchema",
|
||||
import_settings: (
|
||||
scale: 1.0,
|
||||
generate_collider: true,
|
||||
lod0_only: true,
|
||||
placement_mode: StaticAsset,
|
||||
hierarchy_mode: SingleActor,
|
||||
material_policy: SourceMaterials,
|
||||
static_mesh_manifest_path: None,
|
||||
),
|
||||
dependencies: [],
|
||||
),
|
||||
]
|
||||
0
assets/audio/.gitkeep
Normal file
0
assets/audio/.gitkeep
Normal file
0
assets/levels/.gitkeep
Normal file
0
assets/levels/.gitkeep
Normal file
1178
assets/levels/editor_scene 2.scn.ron
Normal file
1178
assets/levels/editor_scene 2.scn.ron
Normal file
File diff suppressed because it is too large
Load Diff
1504
assets/levels/editor_scene 3.scn.ron
Normal file
1504
assets/levels/editor_scene 3.scn.ron
Normal file
File diff suppressed because it is too large
Load Diff
959
assets/levels/editor_scene.scn.ron
Normal file
959
assets/levels/editor_scene.scn.ron
Normal file
@ -0,0 +1,959 @@
|
||||
(schema_version: 2,
|
||||
resources: {},
|
||||
entities: {
|
||||
4294967133: (
|
||||
components: {
|
||||
"bevy_ecs::name::Name": "Player Start",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (0.0, 0.9900649, 12.46175),
|
||||
rotation: (0.0, 0.0, 0.0, 1.0),
|
||||
scale: (1.0, 1.0, 1.0),
|
||||
),
|
||||
"shared::components::LevelObject": (),
|
||||
"shared::components::PlayerSpawn": (),
|
||||
"shared::components::ActorKind": PlayerSpawn,
|
||||
},
|
||||
),
|
||||
4294967162: (
|
||||
components: {
|
||||
"bevy_ecs::name::Name": "ShowcaseSphere",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (-1.3999996, 0.6, 4.0),
|
||||
rotation: (0.0, 0.0, 0.0, 1.0),
|
||||
scale: (1.0, 1.0, 1.0),
|
||||
),
|
||||
"shared::components::LevelObject": (),
|
||||
"shared::components::MaterialDesc": (
|
||||
base_color: (
|
||||
r: 0.9,
|
||||
g: 0.9,
|
||||
b: 0.92,
|
||||
a: 1.0,
|
||||
),
|
||||
metallic: 1.0,
|
||||
roughness: 0.95,
|
||||
base_color_texture: None,
|
||||
normal_map_texture: None,
|
||||
metallic_roughness_texture: None,
|
||||
),
|
||||
"shared::components::PhysicsBody": (
|
||||
body: Static,
|
||||
collider: Sphere(
|
||||
radius: 0.6,
|
||||
),
|
||||
),
|
||||
"shared::components::Primitive": (
|
||||
shape: Sphere,
|
||||
size: (1.2, 1.2, 1.2),
|
||||
),
|
||||
"shared::components::ActorKind": StaticMesh,
|
||||
},
|
||||
),
|
||||
4294967163: (
|
||||
components: {
|
||||
"bevy_ecs::name::Name": "ShowcaseSphere",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (-3.6, 0.6, 4.0),
|
||||
rotation: (0.0, 0.0, 0.0, 1.0),
|
||||
scale: (1.0, 1.0, 1.0),
|
||||
),
|
||||
"shared::components::LevelObject": (),
|
||||
"shared::components::MaterialDesc": (
|
||||
base_color: (
|
||||
r: 0.9,
|
||||
g: 0.9,
|
||||
b: 0.92,
|
||||
a: 1.0,
|
||||
),
|
||||
metallic: 1.0,
|
||||
roughness: 0.6,
|
||||
base_color_texture: None,
|
||||
normal_map_texture: None,
|
||||
metallic_roughness_texture: None,
|
||||
),
|
||||
"shared::components::PhysicsBody": (
|
||||
body: Static,
|
||||
collider: Sphere(
|
||||
radius: 0.6,
|
||||
),
|
||||
),
|
||||
"shared::components::Primitive": (
|
||||
shape: Sphere,
|
||||
size: (1.2, 1.2, 1.2),
|
||||
),
|
||||
"shared::components::ActorKind": StaticMesh,
|
||||
},
|
||||
),
|
||||
4294967164: (
|
||||
components: {
|
||||
"bevy_ecs::name::Name": "ShowcaseSphere",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (-5.8, 0.6, 4.0),
|
||||
rotation: (0.0, 0.0, 0.0, 1.0),
|
||||
scale: (1.0, 1.0, 1.0),
|
||||
),
|
||||
"shared::components::LevelObject": (),
|
||||
"shared::components::MaterialDesc": (
|
||||
base_color: (
|
||||
r: 0.9,
|
||||
g: 0.9,
|
||||
b: 0.92,
|
||||
a: 1.0,
|
||||
),
|
||||
metallic: 1.0,
|
||||
roughness: 0.3,
|
||||
base_color_texture: None,
|
||||
normal_map_texture: None,
|
||||
metallic_roughness_texture: None,
|
||||
),
|
||||
"shared::components::PhysicsBody": (
|
||||
body: Static,
|
||||
collider: Sphere(
|
||||
radius: 0.6,
|
||||
),
|
||||
),
|
||||
"shared::components::Primitive": (
|
||||
shape: Sphere,
|
||||
size: (1.2, 1.2, 1.2),
|
||||
),
|
||||
"shared::components::ActorKind": StaticMesh,
|
||||
},
|
||||
),
|
||||
4294967165: (
|
||||
components: {
|
||||
"bevy_ecs::name::Name": "ShowcaseSphere",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (-8.0, 0.6, 4.0),
|
||||
rotation: (0.0, 0.0, 0.0, 1.0),
|
||||
scale: (1.0, 1.0, 1.0),
|
||||
),
|
||||
"shared::components::LevelObject": (),
|
||||
"shared::components::MaterialDesc": (
|
||||
base_color: (
|
||||
r: 0.9,
|
||||
g: 0.9,
|
||||
b: 0.92,
|
||||
a: 1.0,
|
||||
),
|
||||
metallic: 1.0,
|
||||
roughness: 0.05,
|
||||
base_color_texture: None,
|
||||
normal_map_texture: None,
|
||||
metallic_roughness_texture: None,
|
||||
),
|
||||
"shared::components::PhysicsBody": (
|
||||
body: Static,
|
||||
collider: Sphere(
|
||||
radius: 0.6,
|
||||
),
|
||||
),
|
||||
"shared::components::Primitive": (
|
||||
shape: Sphere,
|
||||
size: (1.2, 1.2, 1.2),
|
||||
),
|
||||
"shared::components::ActorKind": StaticMesh,
|
||||
},
|
||||
),
|
||||
4294967166: (
|
||||
components: {
|
||||
"bevy_ecs::name::Name": "Stair",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (12.0, 0.90000004, 9.5),
|
||||
rotation: (0.0, 0.0, 0.0, 1.0),
|
||||
scale: (1.0, 1.0, 1.0),
|
||||
),
|
||||
"shared::components::LevelObject": (),
|
||||
"shared::components::MaterialDesc": (
|
||||
base_color: (
|
||||
r: 0.55,
|
||||
g: 0.52,
|
||||
b: 0.48,
|
||||
a: 1.0,
|
||||
),
|
||||
metallic: 0.0,
|
||||
roughness: 0.8,
|
||||
base_color_texture: None,
|
||||
normal_map_texture: None,
|
||||
metallic_roughness_texture: None,
|
||||
),
|
||||
"shared::components::PhysicsBody": (
|
||||
body: Static,
|
||||
collider: Cuboid(
|
||||
x_length: 5.0,
|
||||
y_length: 1.8000001,
|
||||
z_length: 0.5,
|
||||
),
|
||||
),
|
||||
"shared::components::Primitive": (
|
||||
shape: Box,
|
||||
size: (5.0, 1.8000001, 0.5),
|
||||
),
|
||||
"shared::components::ActorKind": StaticMesh,
|
||||
},
|
||||
),
|
||||
4294967167: (
|
||||
components: {
|
||||
"bevy_ecs::name::Name": "Stair",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (12.0, 0.81000006, 10.0),
|
||||
rotation: (0.0, 0.0, 0.0, 1.0),
|
||||
scale: (1.0, 1.0, 1.0),
|
||||
),
|
||||
"shared::components::LevelObject": (),
|
||||
"shared::components::MaterialDesc": (
|
||||
base_color: (
|
||||
r: 0.55,
|
||||
g: 0.52,
|
||||
b: 0.48,
|
||||
a: 1.0,
|
||||
),
|
||||
metallic: 0.0,
|
||||
roughness: 0.8,
|
||||
base_color_texture: None,
|
||||
normal_map_texture: None,
|
||||
metallic_roughness_texture: None,
|
||||
),
|
||||
"shared::components::PhysicsBody": (
|
||||
body: Static,
|
||||
collider: Cuboid(
|
||||
x_length: 5.0,
|
||||
y_length: 1.6200001,
|
||||
z_length: 0.5,
|
||||
),
|
||||
),
|
||||
"shared::components::Primitive": (
|
||||
shape: Box,
|
||||
size: (5.0, 1.6200001, 0.5),
|
||||
),
|
||||
"shared::components::ActorKind": StaticMesh,
|
||||
},
|
||||
),
|
||||
4294967168: (
|
||||
components: {
|
||||
"bevy_ecs::name::Name": "Stair",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (12.0, 0.72, 10.5),
|
||||
rotation: (0.0, 0.0, 0.0, 1.0),
|
||||
scale: (1.0, 1.0, 1.0),
|
||||
),
|
||||
"shared::components::LevelObject": (),
|
||||
"shared::components::MaterialDesc": (
|
||||
base_color: (
|
||||
r: 0.55,
|
||||
g: 0.52,
|
||||
b: 0.48,
|
||||
a: 1.0,
|
||||
),
|
||||
metallic: 0.0,
|
||||
roughness: 0.8,
|
||||
base_color_texture: None,
|
||||
normal_map_texture: None,
|
||||
metallic_roughness_texture: None,
|
||||
),
|
||||
"shared::components::PhysicsBody": (
|
||||
body: Static,
|
||||
collider: Cuboid(
|
||||
x_length: 5.0,
|
||||
y_length: 1.44,
|
||||
z_length: 0.5,
|
||||
),
|
||||
),
|
||||
"shared::components::Primitive": (
|
||||
shape: Box,
|
||||
size: (5.0, 1.44, 0.5),
|
||||
),
|
||||
"shared::components::ActorKind": StaticMesh,
|
||||
},
|
||||
),
|
||||
4294967169: (
|
||||
components: {
|
||||
"bevy_ecs::name::Name": "Stair",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (12.0, 0.63, 11.0),
|
||||
rotation: (0.0, 0.0, 0.0, 1.0),
|
||||
scale: (1.0, 1.0, 1.0),
|
||||
),
|
||||
"shared::components::LevelObject": (),
|
||||
"shared::components::MaterialDesc": (
|
||||
base_color: (
|
||||
r: 0.55,
|
||||
g: 0.52,
|
||||
b: 0.48,
|
||||
a: 1.0,
|
||||
),
|
||||
metallic: 0.0,
|
||||
roughness: 0.8,
|
||||
base_color_texture: None,
|
||||
normal_map_texture: None,
|
||||
metallic_roughness_texture: None,
|
||||
),
|
||||
"shared::components::PhysicsBody": (
|
||||
body: Static,
|
||||
collider: Cuboid(
|
||||
x_length: 5.0,
|
||||
y_length: 1.26,
|
||||
z_length: 0.5,
|
||||
),
|
||||
),
|
||||
"shared::components::Primitive": (
|
||||
shape: Box,
|
||||
size: (5.0, 1.26, 0.5),
|
||||
),
|
||||
"shared::components::ActorKind": StaticMesh,
|
||||
},
|
||||
),
|
||||
4294967170: (
|
||||
components: {
|
||||
"bevy_ecs::name::Name": "Stair",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (12.0, 0.54, 11.5),
|
||||
rotation: (0.0, 0.0, 0.0, 1.0),
|
||||
scale: (1.0, 1.0, 1.0),
|
||||
),
|
||||
"shared::components::LevelObject": (),
|
||||
"shared::components::MaterialDesc": (
|
||||
base_color: (
|
||||
r: 0.55,
|
||||
g: 0.52,
|
||||
b: 0.48,
|
||||
a: 1.0,
|
||||
),
|
||||
metallic: 0.0,
|
||||
roughness: 0.8,
|
||||
base_color_texture: None,
|
||||
normal_map_texture: None,
|
||||
metallic_roughness_texture: None,
|
||||
),
|
||||
"shared::components::PhysicsBody": (
|
||||
body: Static,
|
||||
collider: Cuboid(
|
||||
x_length: 5.0,
|
||||
y_length: 1.08,
|
||||
z_length: 0.5,
|
||||
),
|
||||
),
|
||||
"shared::components::Primitive": (
|
||||
shape: Box,
|
||||
size: (5.0, 1.08, 0.5),
|
||||
),
|
||||
"shared::components::ActorKind": StaticMesh,
|
||||
},
|
||||
),
|
||||
4294967171: (
|
||||
components: {
|
||||
"bevy_ecs::name::Name": "Stair",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (12.0, 0.45000002, 12.0),
|
||||
rotation: (0.0, 0.0, 0.0, 1.0),
|
||||
scale: (1.0, 1.0, 1.0),
|
||||
),
|
||||
"shared::components::LevelObject": (),
|
||||
"shared::components::MaterialDesc": (
|
||||
base_color: (
|
||||
r: 0.55,
|
||||
g: 0.52,
|
||||
b: 0.48,
|
||||
a: 1.0,
|
||||
),
|
||||
metallic: 0.0,
|
||||
roughness: 0.8,
|
||||
base_color_texture: None,
|
||||
normal_map_texture: None,
|
||||
metallic_roughness_texture: None,
|
||||
),
|
||||
"shared::components::PhysicsBody": (
|
||||
body: Static,
|
||||
collider: Cuboid(
|
||||
x_length: 5.0,
|
||||
y_length: 0.90000004,
|
||||
z_length: 0.5,
|
||||
),
|
||||
),
|
||||
"shared::components::Primitive": (
|
||||
shape: Box,
|
||||
size: (5.0, 0.90000004, 0.5),
|
||||
),
|
||||
"shared::components::ActorKind": StaticMesh,
|
||||
},
|
||||
),
|
||||
4294967172: (
|
||||
components: {
|
||||
"bevy_ecs::name::Name": "Stair",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (12.0, 0.36, 12.5),
|
||||
rotation: (0.0, 0.0, 0.0, 1.0),
|
||||
scale: (1.0, 1.0, 1.0),
|
||||
),
|
||||
"shared::components::LevelObject": (),
|
||||
"shared::components::MaterialDesc": (
|
||||
base_color: (
|
||||
r: 0.55,
|
||||
g: 0.52,
|
||||
b: 0.48,
|
||||
a: 1.0,
|
||||
),
|
||||
metallic: 0.0,
|
||||
roughness: 0.8,
|
||||
base_color_texture: None,
|
||||
normal_map_texture: None,
|
||||
metallic_roughness_texture: None,
|
||||
),
|
||||
"shared::components::PhysicsBody": (
|
||||
body: Static,
|
||||
collider: Cuboid(
|
||||
x_length: 5.0,
|
||||
y_length: 0.72,
|
||||
z_length: 0.5,
|
||||
),
|
||||
),
|
||||
"shared::components::Primitive": (
|
||||
shape: Box,
|
||||
size: (5.0, 0.72, 0.5),
|
||||
),
|
||||
"shared::components::ActorKind": StaticMesh,
|
||||
},
|
||||
),
|
||||
4294967173: (
|
||||
components: {
|
||||
"bevy_ecs::name::Name": "Stair",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (12.0, 0.27, 13.0),
|
||||
rotation: (0.0, 0.0, 0.0, 1.0),
|
||||
scale: (1.0, 1.0, 1.0),
|
||||
),
|
||||
"shared::components::LevelObject": (),
|
||||
"shared::components::MaterialDesc": (
|
||||
base_color: (
|
||||
r: 0.55,
|
||||
g: 0.52,
|
||||
b: 0.48,
|
||||
a: 1.0,
|
||||
),
|
||||
metallic: 0.0,
|
||||
roughness: 0.8,
|
||||
base_color_texture: None,
|
||||
normal_map_texture: None,
|
||||
metallic_roughness_texture: None,
|
||||
),
|
||||
"shared::components::PhysicsBody": (
|
||||
body: Static,
|
||||
collider: Cuboid(
|
||||
x_length: 5.0,
|
||||
y_length: 0.54,
|
||||
z_length: 0.5,
|
||||
),
|
||||
),
|
||||
"shared::components::Primitive": (
|
||||
shape: Box,
|
||||
size: (5.0, 0.54, 0.5),
|
||||
),
|
||||
"shared::components::ActorKind": StaticMesh,
|
||||
},
|
||||
),
|
||||
4294967174: (
|
||||
components: {
|
||||
"bevy_ecs::name::Name": "Stair",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (12.0, 0.18, 13.5),
|
||||
rotation: (0.0, 0.0, 0.0, 1.0),
|
||||
scale: (1.0, 1.0, 1.0),
|
||||
),
|
||||
"shared::components::LevelObject": (),
|
||||
"shared::components::MaterialDesc": (
|
||||
base_color: (
|
||||
r: 0.55,
|
||||
g: 0.52,
|
||||
b: 0.48,
|
||||
a: 1.0,
|
||||
),
|
||||
metallic: 0.0,
|
||||
roughness: 0.8,
|
||||
base_color_texture: None,
|
||||
normal_map_texture: None,
|
||||
metallic_roughness_texture: None,
|
||||
),
|
||||
"shared::components::PhysicsBody": (
|
||||
body: Static,
|
||||
collider: Cuboid(
|
||||
x_length: 5.0,
|
||||
y_length: 0.36,
|
||||
z_length: 0.5,
|
||||
),
|
||||
),
|
||||
"shared::components::Primitive": (
|
||||
shape: Box,
|
||||
size: (5.0, 0.36, 0.5),
|
||||
),
|
||||
"shared::components::ActorKind": StaticMesh,
|
||||
},
|
||||
),
|
||||
4294967175: (
|
||||
components: {
|
||||
"bevy_ecs::name::Name": "Stair",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (12.0, 0.09, 14.0),
|
||||
rotation: (0.0, 0.0, 0.0, 1.0),
|
||||
scale: (1.0, 1.0, 1.0),
|
||||
),
|
||||
"shared::components::LevelObject": (),
|
||||
"shared::components::MaterialDesc": (
|
||||
base_color: (
|
||||
r: 0.55,
|
||||
g: 0.52,
|
||||
b: 0.48,
|
||||
a: 1.0,
|
||||
),
|
||||
metallic: 0.0,
|
||||
roughness: 0.8,
|
||||
base_color_texture: None,
|
||||
normal_map_texture: None,
|
||||
metallic_roughness_texture: None,
|
||||
),
|
||||
"shared::components::PhysicsBody": (
|
||||
body: Static,
|
||||
collider: Cuboid(
|
||||
x_length: 5.0,
|
||||
y_length: 0.18,
|
||||
z_length: 0.5,
|
||||
),
|
||||
),
|
||||
"shared::components::Primitive": (
|
||||
shape: Box,
|
||||
size: (5.0, 0.18, 0.5),
|
||||
),
|
||||
"shared::components::ActorKind": StaticMesh,
|
||||
},
|
||||
),
|
||||
4294967176: (
|
||||
components: {
|
||||
"bevy_ecs::name::Name": "Ramp",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (-14.0, 1.6, 0.0),
|
||||
rotation: (-0.1736482, 0.0, 0.0, 0.9848078),
|
||||
scale: (1.0, 1.0, 1.0),
|
||||
),
|
||||
"shared::components::LevelObject": (),
|
||||
"shared::components::MaterialDesc": (
|
||||
base_color: (
|
||||
r: 0.3,
|
||||
g: 0.45,
|
||||
b: 0.7,
|
||||
a: 1.0,
|
||||
),
|
||||
metallic: 0.1,
|
||||
roughness: 0.55,
|
||||
base_color_texture: None,
|
||||
normal_map_texture: None,
|
||||
metallic_roughness_texture: None,
|
||||
),
|
||||
"shared::components::PhysicsBody": (
|
||||
body: Static,
|
||||
collider: Cuboid(
|
||||
x_length: 5.0,
|
||||
y_length: 0.4,
|
||||
z_length: 9.0,
|
||||
),
|
||||
),
|
||||
"shared::components::Primitive": (
|
||||
shape: Box,
|
||||
size: (5.0, 0.4, 9.0),
|
||||
),
|
||||
"shared::components::ActorKind": StaticMesh,
|
||||
},
|
||||
),
|
||||
4294967177: (
|
||||
components: {
|
||||
"bevy_ecs::name::Name": "Crate",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (5.4, 0.5, 2.4),
|
||||
rotation: (0.0, 0.94948566, 0.0, -0.31381062),
|
||||
scale: (1.0, 1.0, 1.0),
|
||||
),
|
||||
"shared::components::LevelObject": (),
|
||||
"shared::components::MaterialDesc": (
|
||||
base_color: (
|
||||
r: 0.72,
|
||||
g: 0.45,
|
||||
b: 0.2,
|
||||
a: 1.0,
|
||||
),
|
||||
metallic: 0.0,
|
||||
roughness: 0.85,
|
||||
base_color_texture: None,
|
||||
normal_map_texture: None,
|
||||
metallic_roughness_texture: None,
|
||||
),
|
||||
"shared::components::PhysicsBody": (
|
||||
body: Static,
|
||||
collider: Cuboid(
|
||||
x_length: 1.0,
|
||||
y_length: 1.0,
|
||||
z_length: 1.0,
|
||||
),
|
||||
),
|
||||
"shared::components::Primitive": (
|
||||
shape: Box,
|
||||
size: (1.0, 1.0, 1.0),
|
||||
),
|
||||
"shared::components::ActorKind": StaticMesh,
|
||||
},
|
||||
),
|
||||
4294967178: (
|
||||
components: {
|
||||
"bevy_ecs::name::Name": "Crate",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (4.0, 0.75, 2.0),
|
||||
rotation: (0.0, 0.9854498, 0.0, 0.16996716),
|
||||
scale: (1.0, 1.0, 1.0),
|
||||
),
|
||||
"shared::components::LevelObject": (),
|
||||
"shared::components::MaterialDesc": (
|
||||
base_color: (
|
||||
r: 0.72,
|
||||
g: 0.45,
|
||||
b: 0.2,
|
||||
a: 1.0,
|
||||
),
|
||||
metallic: 0.0,
|
||||
roughness: 0.85,
|
||||
base_color_texture: None,
|
||||
normal_map_texture: None,
|
||||
metallic_roughness_texture: None,
|
||||
),
|
||||
"shared::components::PhysicsBody": (
|
||||
body: Static,
|
||||
collider: Cuboid(
|
||||
x_length: 1.5,
|
||||
y_length: 1.5,
|
||||
z_length: 1.5,
|
||||
),
|
||||
),
|
||||
"shared::components::Primitive": (
|
||||
shape: Box,
|
||||
size: (1.5, 1.5, 1.5),
|
||||
),
|
||||
"shared::components::ActorKind": StaticMesh,
|
||||
},
|
||||
),
|
||||
4294967179: (
|
||||
components: {
|
||||
"bevy_ecs::name::Name": "Crate",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (-2.3, 0.5, -3.1),
|
||||
rotation: (0.0, 0.72083056, 0.0, -0.6931113),
|
||||
scale: (1.0, 1.0, 1.0),
|
||||
),
|
||||
"shared::components::LevelObject": (),
|
||||
"shared::components::MaterialDesc": (
|
||||
base_color: (
|
||||
r: 0.72,
|
||||
g: 0.45,
|
||||
b: 0.2,
|
||||
a: 1.0,
|
||||
),
|
||||
metallic: 0.0,
|
||||
roughness: 0.85,
|
||||
base_color_texture: None,
|
||||
normal_map_texture: None,
|
||||
metallic_roughness_texture: None,
|
||||
),
|
||||
"shared::components::PhysicsBody": (
|
||||
body: Static,
|
||||
collider: Cuboid(
|
||||
x_length: 1.0,
|
||||
y_length: 1.0,
|
||||
z_length: 1.0,
|
||||
),
|
||||
),
|
||||
"shared::components::Primitive": (
|
||||
shape: Box,
|
||||
size: (1.0, 1.0, 1.0),
|
||||
),
|
||||
"shared::components::ActorKind": StaticMesh,
|
||||
},
|
||||
),
|
||||
4294967180: (
|
||||
components: {
|
||||
"bevy_ecs::name::Name": "Crate",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (-1.6, 0.5, -3.2),
|
||||
rotation: (0.0, -0.53118616, 0.0, 0.84725505),
|
||||
scale: (1.0, 1.0, 1.0),
|
||||
),
|
||||
"shared::components::LevelObject": (),
|
||||
"shared::components::MaterialDesc": (
|
||||
base_color: (
|
||||
r: 0.72,
|
||||
g: 0.45,
|
||||
b: 0.2,
|
||||
a: 1.0,
|
||||
),
|
||||
metallic: 0.0,
|
||||
roughness: 0.85,
|
||||
base_color_texture: None,
|
||||
normal_map_texture: None,
|
||||
metallic_roughness_texture: None,
|
||||
),
|
||||
"shared::components::PhysicsBody": (
|
||||
body: Static,
|
||||
collider: Cuboid(
|
||||
x_length: 1.0,
|
||||
y_length: 1.0,
|
||||
z_length: 1.0,
|
||||
),
|
||||
),
|
||||
"shared::components::Primitive": (
|
||||
shape: Box,
|
||||
size: (1.0, 1.0, 1.0),
|
||||
),
|
||||
"shared::components::ActorKind": StaticMesh,
|
||||
},
|
||||
),
|
||||
4294967181: (
|
||||
components: {
|
||||
"bevy_ecs::name::Name": "Crate",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (-3.0, 0.5, -3.0),
|
||||
rotation: (0.0, 0.86742324, 0.0, -0.4975711),
|
||||
scale: (1.0, 1.0, 1.0),
|
||||
),
|
||||
"shared::components::LevelObject": (),
|
||||
"shared::components::MaterialDesc": (
|
||||
base_color: (
|
||||
r: 0.72,
|
||||
g: 0.45,
|
||||
b: 0.2,
|
||||
a: 1.0,
|
||||
),
|
||||
metallic: 0.0,
|
||||
roughness: 0.85,
|
||||
base_color_texture: None,
|
||||
normal_map_texture: None,
|
||||
metallic_roughness_texture: None,
|
||||
),
|
||||
"shared::components::PhysicsBody": (
|
||||
body: Static,
|
||||
collider: Cuboid(
|
||||
x_length: 1.0,
|
||||
y_length: 1.0,
|
||||
z_length: 1.0,
|
||||
),
|
||||
),
|
||||
"shared::components::Primitive": (
|
||||
shape: Box,
|
||||
size: (1.0, 1.0, 1.0),
|
||||
),
|
||||
"shared::components::ActorKind": StaticMesh,
|
||||
},
|
||||
),
|
||||
4294967182: (
|
||||
components: {
|
||||
"bevy_ecs::name::Name": "Pillar",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (8.0, 3.5, 6.0),
|
||||
rotation: (0.0, 0.0, 0.0, 1.0),
|
||||
scale: (1.0, 1.0, 1.0),
|
||||
),
|
||||
"shared::components::LevelObject": (),
|
||||
"shared::components::MaterialDesc": (
|
||||
base_color: (
|
||||
r: 0.85,
|
||||
g: 0.85,
|
||||
b: 0.88,
|
||||
a: 1.0,
|
||||
),
|
||||
metallic: 0.0,
|
||||
roughness: 0.3,
|
||||
base_color_texture: None,
|
||||
normal_map_texture: None,
|
||||
metallic_roughness_texture: None,
|
||||
),
|
||||
"shared::components::PhysicsBody": (
|
||||
body: Static,
|
||||
collider: Cuboid(
|
||||
x_length: 1.2,
|
||||
y_length: 7.0,
|
||||
z_length: 1.2,
|
||||
),
|
||||
),
|
||||
"shared::components::Primitive": (
|
||||
shape: Box,
|
||||
size: (1.2, 7.0, 1.2),
|
||||
),
|
||||
"shared::components::ActorKind": StaticMesh,
|
||||
},
|
||||
),
|
||||
4294967183: (
|
||||
components: {
|
||||
"bevy_ecs::name::Name": "Pillar",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (-8.0, 3.5, 6.0),
|
||||
rotation: (0.0, 0.0, 0.0, 1.0),
|
||||
scale: (1.0, 1.0, 1.0),
|
||||
),
|
||||
"shared::components::LevelObject": (),
|
||||
"shared::components::MaterialDesc": (
|
||||
base_color: (
|
||||
r: 0.85,
|
||||
g: 0.85,
|
||||
b: 0.88,
|
||||
a: 1.0,
|
||||
),
|
||||
metallic: 0.0,
|
||||
roughness: 0.3,
|
||||
base_color_texture: None,
|
||||
normal_map_texture: None,
|
||||
metallic_roughness_texture: None,
|
||||
),
|
||||
"shared::components::PhysicsBody": (
|
||||
body: Static,
|
||||
collider: Cuboid(
|
||||
x_length: 1.2,
|
||||
y_length: 7.0,
|
||||
z_length: 1.2,
|
||||
),
|
||||
),
|
||||
"shared::components::Primitive": (
|
||||
shape: Box,
|
||||
size: (1.2, 7.0, 1.2),
|
||||
),
|
||||
"shared::components::ActorKind": StaticMesh,
|
||||
},
|
||||
),
|
||||
4294967184: (
|
||||
components: {
|
||||
"bevy_ecs::name::Name": "Pillar",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (8.0, 3.5, -6.0),
|
||||
rotation: (0.0, 0.0, 0.0, 1.0),
|
||||
scale: (1.0, 1.0, 1.0),
|
||||
),
|
||||
"shared::components::LevelObject": (),
|
||||
"shared::components::MaterialDesc": (
|
||||
base_color: (
|
||||
r: 0.85,
|
||||
g: 0.85,
|
||||
b: 0.88,
|
||||
a: 1.0,
|
||||
),
|
||||
metallic: 0.0,
|
||||
roughness: 0.3,
|
||||
base_color_texture: None,
|
||||
normal_map_texture: None,
|
||||
metallic_roughness_texture: None,
|
||||
),
|
||||
"shared::components::PhysicsBody": (
|
||||
body: Static,
|
||||
collider: Cuboid(
|
||||
x_length: 1.2,
|
||||
y_length: 7.0,
|
||||
z_length: 1.2,
|
||||
),
|
||||
),
|
||||
"shared::components::Primitive": (
|
||||
shape: Box,
|
||||
size: (1.2, 7.0, 1.2),
|
||||
),
|
||||
"shared::components::ActorKind": StaticMesh,
|
||||
},
|
||||
),
|
||||
4294967185: (
|
||||
components: {
|
||||
"bevy_ecs::name::Name": "Pillar",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (-8.0, 3.5, -6.0),
|
||||
rotation: (0.0, 0.0, 0.0, 1.0),
|
||||
scale: (1.0, 1.0, 1.0),
|
||||
),
|
||||
"shared::components::LevelObject": (),
|
||||
"shared::components::MaterialDesc": (
|
||||
base_color: (
|
||||
r: 0.85,
|
||||
g: 0.85,
|
||||
b: 0.88,
|
||||
a: 1.0,
|
||||
),
|
||||
metallic: 0.0,
|
||||
roughness: 0.3,
|
||||
base_color_texture: None,
|
||||
normal_map_texture: None,
|
||||
metallic_roughness_texture: None,
|
||||
),
|
||||
"shared::components::PhysicsBody": (
|
||||
body: Static,
|
||||
collider: Cuboid(
|
||||
x_length: 1.2,
|
||||
y_length: 7.0,
|
||||
z_length: 1.2,
|
||||
),
|
||||
),
|
||||
"shared::components::Primitive": (
|
||||
shape: Box,
|
||||
size: (1.2, 7.0, 1.2),
|
||||
),
|
||||
"shared::components::ActorKind": StaticMesh,
|
||||
},
|
||||
),
|
||||
4294967186: (
|
||||
components: {
|
||||
"bevy_ecs::name::Name": "Ground",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (0.0, -0.5, 0.0),
|
||||
rotation: (0.0, 0.0, 0.0, 1.0),
|
||||
scale: (1.0, 1.0, 1.0),
|
||||
),
|
||||
"shared::components::LevelObject": (),
|
||||
"shared::components::MaterialDesc": (
|
||||
base_color: (
|
||||
r: 0.42,
|
||||
g: 0.45,
|
||||
b: 0.48,
|
||||
a: 1.0,
|
||||
),
|
||||
metallic: 0.0,
|
||||
roughness: 0.92,
|
||||
base_color_texture: None,
|
||||
normal_map_texture: None,
|
||||
metallic_roughness_texture: None,
|
||||
),
|
||||
"shared::components::PhysicsBody": (
|
||||
body: Static,
|
||||
collider: Cuboid(
|
||||
x_length: 200.0,
|
||||
y_length: 1.0,
|
||||
z_length: 200.0,
|
||||
),
|
||||
),
|
||||
"shared::components::Primitive": (
|
||||
shape: Box,
|
||||
size: (200.0, 1.0, 200.0),
|
||||
),
|
||||
"shared::components::ActorKind": StaticMesh,
|
||||
},
|
||||
),
|
||||
25769803611: (
|
||||
components: {
|
||||
"bevy_ecs::name::Name": "Scene Sun",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (0.0, 5.316672, 0.0),
|
||||
rotation: (-0.21401538, 0.31161797, 0.67592126, 0.6326311),
|
||||
scale: (1.0, 1.0, 1.0),
|
||||
),
|
||||
"shared::components::LevelObject": (),
|
||||
"shared::components::LightDesc": (
|
||||
kind: Directional,
|
||||
color: (
|
||||
r: 1.0,
|
||||
g: 0.95,
|
||||
b: 0.85,
|
||||
a: 1.0,
|
||||
),
|
||||
intensity: 100000.0,
|
||||
range: 0.0,
|
||||
shadows: true,
|
||||
),
|
||||
"shared::components::ActorKind": Light,
|
||||
},
|
||||
),
|
||||
},
|
||||
)
|
||||
74
assets/levels/rendering_showcase.scn.ron
Normal file
74
assets/levels/rendering_showcase.scn.ron
Normal file
@ -0,0 +1,74 @@
|
||||
(schema_version: 2,
|
||||
resources: {},
|
||||
entities: {
|
||||
4294968001: (
|
||||
components: {
|
||||
"bevy_ecs::name::Name": "Fog Volume",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (0.0, 2.0, 0.0),
|
||||
rotation: (0.0, 0.0, 0.0, 1.0),
|
||||
scale: (1.0, 1.0, 1.0),
|
||||
),
|
||||
"shared::components::LevelObject": (),
|
||||
"shared::components::ActorKind": PostProcessVolume,
|
||||
"shared::components::PostProcessVolumeDesc": (
|
||||
half_extents: (6.0, 3.0, 6.0),
|
||||
priority: 0,
|
||||
blend_distance: 2.0,
|
||||
overrides: (
|
||||
fog_density: Some(0.004),
|
||||
fog_color: Some((r: 0.5, g: 0.55, b: 0.65, a: 1.0)),
|
||||
),
|
||||
fullscreen_effect: None,
|
||||
profile: None,
|
||||
label: Some("Foggy courtyard"),
|
||||
),
|
||||
},
|
||||
),
|
||||
4294968002: (
|
||||
components: {
|
||||
"bevy_ecs::name::Name": "Dark Exposure Volume",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (14.0, 2.0, 0.0),
|
||||
rotation: (0.0, 0.0, 0.0, 1.0),
|
||||
scale: (1.0, 1.0, 1.0),
|
||||
),
|
||||
"shared::components::LevelObject": (),
|
||||
"shared::components::ActorKind": PostProcessVolume,
|
||||
"shared::components::PostProcessVolumeDesc": (
|
||||
half_extents: (4.0, 2.5, 4.0),
|
||||
priority: 5,
|
||||
blend_distance: 1.5,
|
||||
overrides: (
|
||||
exposure_ev100: Some(9.5),
|
||||
bloom: Some(false),
|
||||
),
|
||||
fullscreen_effect: None,
|
||||
profile: Some("assets/rendering_profiles/cave_dark.ron"),
|
||||
label: Some("Cave mouth"),
|
||||
),
|
||||
},
|
||||
),
|
||||
4294968003: (
|
||||
components: {
|
||||
"bevy_ecs::name::Name": "Vignette FX Volume",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (-12.0, 2.0, 0.0),
|
||||
rotation: (0.0, 0.0, 0.0, 1.0),
|
||||
scale: (1.0, 1.0, 1.0),
|
||||
),
|
||||
"shared::components::LevelObject": (),
|
||||
"shared::components::ActorKind": PostProcessVolume,
|
||||
"shared::components::PostProcessVolumeDesc": (
|
||||
half_extents: (5.0, 2.0, 5.0),
|
||||
priority: 2,
|
||||
blend_distance: 2.0,
|
||||
overrides: (),
|
||||
fullscreen_effect: Some("assets/post_fx/vignette.ron"),
|
||||
profile: None,
|
||||
label: Some("Vignette demo"),
|
||||
),
|
||||
},
|
||||
),
|
||||
},
|
||||
)
|
||||
0
assets/materials/.gitkeep
Normal file
0
assets/materials/.gitkeep
Normal file
12
assets/materials/concrete.ron
Normal file
12
assets/materials/concrete.ron
Normal file
@ -0,0 +1,12 @@
|
||||
(
|
||||
label: "Concrete",
|
||||
material: (
|
||||
base_color: (r: 0.55, g: 0.54, b: 0.52, a: 1.0),
|
||||
metallic: 0.0,
|
||||
roughness: 0.85,
|
||||
base_color_texture: None,
|
||||
normal_map_texture: None,
|
||||
metallic_roughness_texture: None,
|
||||
material_asset_path: None,
|
||||
),
|
||||
)
|
||||
15
assets/materials/emissive_panel.ron
Normal file
15
assets/materials/emissive_panel.ron
Normal file
@ -0,0 +1,15 @@
|
||||
(
|
||||
label: "Emissive Panel",
|
||||
material: (
|
||||
base_color: (r: 0.15, g: 0.22, b: 0.28, a: 1.0),
|
||||
metallic: 0.0,
|
||||
roughness: 0.35,
|
||||
emissive_color: (r: 0.55, g: 0.85, b: 1.0, a: 1.0),
|
||||
emissive_intensity: 2500.0,
|
||||
base_color_texture: None,
|
||||
emissive_texture: None,
|
||||
normal_map_texture: None,
|
||||
metallic_roughness_texture: None,
|
||||
material_asset_path: None,
|
||||
),
|
||||
)
|
||||
@ -0,0 +1,82 @@
|
||||
(
|
||||
schema_version: 1,
|
||||
asset_id: "582cd326-c32d-49da-9424-ae38b3d60648",
|
||||
label: "Dumpster",
|
||||
source: (
|
||||
path: "assets/models/Dumpster.fbx",
|
||||
format: "fbx",
|
||||
fingerprint: (
|
||||
byte_len: 50940,
|
||||
modified_unix_secs: 1780338936,
|
||||
),
|
||||
dependencies: [],
|
||||
),
|
||||
import: (
|
||||
scale: 1.0,
|
||||
generate_collider: true,
|
||||
lod0_only: true,
|
||||
placement_mode: StaticAsset,
|
||||
hierarchy_mode: SourceHierarchy,
|
||||
material_policy: AuthoringOverride,
|
||||
),
|
||||
metadata: (
|
||||
mesh_count: 3,
|
||||
material_count: 2,
|
||||
node_count: 4,
|
||||
animation_count: 0,
|
||||
skin_count: 0,
|
||||
light_count: 0,
|
||||
camera_count: 0,
|
||||
),
|
||||
parts: [
|
||||
(
|
||||
id: "mesh:mesh1000",
|
||||
name: "Dumpster / Material 0",
|
||||
mesh_label: "Mesh1000",
|
||||
material_id: Some("material:material0"),
|
||||
material_slot_name: "Dumpster",
|
||||
material_label: Some("Material0"),
|
||||
local_transform: (
|
||||
translation: (8.342552, 0.06418517, -6.7325354),
|
||||
rotation: (0.70710677, 0.0, 0.0, -0.7071067),
|
||||
scale: (1.0, 1.0, 1.0),
|
||||
),
|
||||
source_node: Some("Node1"),
|
||||
source_mesh: Some("Mesh1"),
|
||||
source_material: Some("Dumpster"),
|
||||
),
|
||||
(
|
||||
id: "mesh:mesh2000",
|
||||
name: "Dumpster Lid / Material 0",
|
||||
mesh_label: "Mesh2000",
|
||||
material_id: Some("material:material1"),
|
||||
material_slot_name: "Dumpster Lid",
|
||||
material_label: Some("Material1"),
|
||||
local_transform: (
|
||||
translation: (7.362827, 2.5433145, -7.784438),
|
||||
rotation: (0.70710677, 0.000000000082318055, -0.00000000008231806, -0.7071067),
|
||||
scale: (1.0, 1.0, 1.0),
|
||||
),
|
||||
source_node: Some("Node2"),
|
||||
source_mesh: Some("Mesh2"),
|
||||
source_material: Some("Dumpster Lid"),
|
||||
),
|
||||
(
|
||||
id: "mesh:mesh3000",
|
||||
name: "Dumpster Lid_02 / Material 0",
|
||||
mesh_label: "Mesh3000",
|
||||
material_id: Some("material:material1"),
|
||||
material_slot_name: "Dumpster Lid",
|
||||
material_label: Some("Material1"),
|
||||
local_transform: (
|
||||
translation: (9.351767, 2.5433145, -7.784438),
|
||||
rotation: (0.70710677, 0.000000000082318055, -0.00000000008231806, -0.7071067),
|
||||
scale: (1.0, 1.0, 1.0),
|
||||
),
|
||||
source_node: Some("Node3"),
|
||||
source_mesh: Some("Mesh3"),
|
||||
source_material: Some("Dumpster Lid"),
|
||||
),
|
||||
],
|
||||
warnings: [],
|
||||
)
|
||||
@ -0,0 +1,770 @@
|
||||
(
|
||||
schema_version: 1,
|
||||
asset_id: "b8b43f26-0eee-4ee8-9e16-53aedbc69205",
|
||||
label: "Room3",
|
||||
source: (
|
||||
path: "assets/models/Room3.fbx",
|
||||
format: "fbx",
|
||||
fingerprint: (
|
||||
byte_len: 159836,
|
||||
modified_unix_secs: 1780196194,
|
||||
),
|
||||
dependencies: [],
|
||||
),
|
||||
import: (
|
||||
scale: 1.7,
|
||||
generate_collider: true,
|
||||
lod0_only: true,
|
||||
placement_mode: StaticAsset,
|
||||
hierarchy_mode: SingleActor,
|
||||
material_policy: AuthoringOverride,
|
||||
),
|
||||
metadata: (
|
||||
mesh_count: 23,
|
||||
material_count: 11,
|
||||
node_count: 24,
|
||||
animation_count: 0,
|
||||
skin_count: 0,
|
||||
light_count: 0,
|
||||
camera_count: 0,
|
||||
),
|
||||
parts: [
|
||||
(
|
||||
id: "mesh:mesh1000",
|
||||
name: "Windows Small.000 / Material 0",
|
||||
mesh_label: "Mesh1000",
|
||||
material_id: Some("material:material0"),
|
||||
material_slot_name: "Window Frame Metal.002",
|
||||
material_label: Some("Material0"),
|
||||
local_transform: (
|
||||
translation: (2.9808052, 1.9115452, -0.40523553),
|
||||
rotation: (0.70710677, 0.0, 0.0, -0.7071067),
|
||||
scale: (1.2387476, 0.9248338, 1.0),
|
||||
),
|
||||
source_node: Some("Node1"),
|
||||
source_mesh: Some("Mesh1"),
|
||||
source_material: Some("Window Frame Metal.002"),
|
||||
),
|
||||
(
|
||||
id: "mesh:mesh10000",
|
||||
name: "Window Right Up.000 / Material 0",
|
||||
mesh_label: "Mesh10000",
|
||||
material_id: Some("material:material0"),
|
||||
material_slot_name: "Window Frame Metal.002",
|
||||
material_label: Some("Material0"),
|
||||
local_transform: (
|
||||
translation: (-0.97121567, 1.8132615, 3.0487669),
|
||||
rotation: (0.70710677, 0.0, 0.0, -0.7071067),
|
||||
scale: (1.2387476, 0.9248341, 1.0),
|
||||
),
|
||||
source_node: Some("Node10"),
|
||||
source_mesh: Some("Mesh10"),
|
||||
source_material: Some("Window Frame Metal.002"),
|
||||
),
|
||||
(
|
||||
id: "mesh:mesh10001",
|
||||
name: "Window Right Up.000 / Material 1",
|
||||
mesh_label: "Mesh10001",
|
||||
material_id: Some("material:material1"),
|
||||
material_slot_name: "Window Glass.002",
|
||||
material_label: Some("Material1"),
|
||||
local_transform: (
|
||||
translation: (-0.97121567, 1.8132615, 3.0487669),
|
||||
rotation: (0.70710677, 0.0, 0.0, -0.7071067),
|
||||
scale: (1.2387476, 0.9248341, 1.0),
|
||||
),
|
||||
source_node: Some("Node10"),
|
||||
source_mesh: Some("Mesh10"),
|
||||
source_material: Some("Window Glass.002"),
|
||||
),
|
||||
(
|
||||
id: "mesh:mesh1001",
|
||||
name: "Windows Small.000 / Material 1",
|
||||
mesh_label: "Mesh1001",
|
||||
material_id: Some("material:material1"),
|
||||
material_slot_name: "Window Glass.002",
|
||||
material_label: Some("Material1"),
|
||||
local_transform: (
|
||||
translation: (2.9808052, 1.9115452, -0.40523553),
|
||||
rotation: (0.70710677, 0.0, 0.0, -0.7071067),
|
||||
scale: (1.2387476, 0.9248338, 1.0),
|
||||
),
|
||||
source_node: Some("Node1"),
|
||||
source_mesh: Some("Mesh1"),
|
||||
source_material: Some("Window Glass.002"),
|
||||
),
|
||||
(
|
||||
id: "mesh:mesh11000",
|
||||
name: "Window Right Low.000 / Material 0",
|
||||
mesh_label: "Mesh11000",
|
||||
material_id: Some("material:material0"),
|
||||
material_slot_name: "Window Frame Metal.002",
|
||||
material_label: Some("Material0"),
|
||||
local_transform: (
|
||||
translation: (-0.9734232, 1.4140832, 3.0185344),
|
||||
rotation: (0.70710677, 0.0, 0.0, -0.7071067),
|
||||
scale: (1.2387476, 0.9248341, 1.0),
|
||||
),
|
||||
source_node: Some("Node11"),
|
||||
source_mesh: Some("Mesh11"),
|
||||
source_material: Some("Window Frame Metal.002"),
|
||||
),
|
||||
(
|
||||
id: "mesh:mesh11001",
|
||||
name: "Window Right Low.000 / Material 1",
|
||||
mesh_label: "Mesh11001",
|
||||
material_id: Some("material:material1"),
|
||||
material_slot_name: "Window Glass.002",
|
||||
material_label: Some("Material1"),
|
||||
local_transform: (
|
||||
translation: (-0.9734232, 1.4140832, 3.0185344),
|
||||
rotation: (0.70710677, 0.0, 0.0, -0.7071067),
|
||||
scale: (1.2387476, 0.9248341, 1.0),
|
||||
),
|
||||
source_node: Some("Node11"),
|
||||
source_mesh: Some("Mesh11"),
|
||||
source_material: Some("Window Glass.002"),
|
||||
),
|
||||
(
|
||||
id: "mesh:mesh12000",
|
||||
name: "Window Left Up.000 / Material 0",
|
||||
mesh_label: "Mesh12000",
|
||||
material_id: Some("material:material0"),
|
||||
material_slot_name: "Window Frame Metal.002",
|
||||
material_label: Some("Material0"),
|
||||
local_transform: (
|
||||
translation: (1.583923, 1.8132616, 3.0487669),
|
||||
rotation: (0.70710677, 0.000000034125748, -0.000000025061802, -0.7071067),
|
||||
scale: (1.2387476, 0.9248341, 1.0),
|
||||
),
|
||||
source_node: Some("Node12"),
|
||||
source_mesh: Some("Mesh12"),
|
||||
source_material: Some("Window Frame Metal.002"),
|
||||
),
|
||||
(
|
||||
id: "mesh:mesh12001",
|
||||
name: "Window Left Up.000 / Material 1",
|
||||
mesh_label: "Mesh12001",
|
||||
material_id: Some("material:material1"),
|
||||
material_slot_name: "Window Glass.002",
|
||||
material_label: Some("Material1"),
|
||||
local_transform: (
|
||||
translation: (1.583923, 1.8132616, 3.0487669),
|
||||
rotation: (0.70710677, 0.000000034125748, -0.000000025061802, -0.7071067),
|
||||
scale: (1.2387476, 0.9248341, 1.0),
|
||||
),
|
||||
source_node: Some("Node12"),
|
||||
source_mesh: Some("Mesh12"),
|
||||
source_material: Some("Window Glass.002"),
|
||||
),
|
||||
(
|
||||
id: "mesh:mesh13000",
|
||||
name: "Window Left Low.000 / Material 0",
|
||||
mesh_label: "Mesh13000",
|
||||
material_id: Some("material:material0"),
|
||||
material_slot_name: "Window Frame Metal.002",
|
||||
material_label: Some("Material0"),
|
||||
local_transform: (
|
||||
translation: (1.5816854, 1.414083, 3.0185344),
|
||||
rotation: (0.70710677, 0.000000034125748, -0.000000025061802, -0.7071067),
|
||||
scale: (1.2387476, 0.9248341, 1.0),
|
||||
),
|
||||
source_node: Some("Node13"),
|
||||
source_mesh: Some("Mesh13"),
|
||||
source_material: Some("Window Frame Metal.002"),
|
||||
),
|
||||
(
|
||||
id: "mesh:mesh13001",
|
||||
name: "Window Left Low.000 / Material 1",
|
||||
mesh_label: "Mesh13001",
|
||||
material_id: Some("material:material1"),
|
||||
material_slot_name: "Window Glass.002",
|
||||
material_label: Some("Material1"),
|
||||
local_transform: (
|
||||
translation: (1.5816854, 1.414083, 3.0185344),
|
||||
rotation: (0.70710677, 0.000000034125748, -0.000000025061802, -0.7071067),
|
||||
scale: (1.2387476, 0.9248341, 1.0),
|
||||
),
|
||||
source_node: Some("Node13"),
|
||||
source_mesh: Some("Mesh13"),
|
||||
source_material: Some("Window Glass.002"),
|
||||
),
|
||||
(
|
||||
id: "mesh:mesh14000",
|
||||
name: "Window Center Low.000 / Material 0",
|
||||
mesh_label: "Mesh14000",
|
||||
material_id: Some("material:material0"),
|
||||
material_slot_name: "Window Frame Metal.002",
|
||||
material_label: Some("Material0"),
|
||||
local_transform: (
|
||||
translation: (0.29577515, 1.4927816, 3.0203688),
|
||||
rotation: (0.70710677, 0.0, 0.0, -0.7071067),
|
||||
scale: (1.2387476, 0.9248343, 1.0),
|
||||
),
|
||||
source_node: Some("Node14"),
|
||||
source_mesh: Some("Mesh14"),
|
||||
source_material: Some("Window Frame Metal.002"),
|
||||
),
|
||||
(
|
||||
id: "mesh:mesh14001",
|
||||
name: "Window Center Low.000 / Material 1",
|
||||
mesh_label: "Mesh14001",
|
||||
material_id: Some("material:material1"),
|
||||
material_slot_name: "Window Glass.002",
|
||||
material_label: Some("Material1"),
|
||||
local_transform: (
|
||||
translation: (0.29577515, 1.4927816, 3.0203688),
|
||||
rotation: (0.70710677, 0.0, 0.0, -0.7071067),
|
||||
scale: (1.2387476, 0.9248343, 1.0),
|
||||
),
|
||||
source_node: Some("Node14"),
|
||||
source_mesh: Some("Mesh14"),
|
||||
source_material: Some("Window Glass.002"),
|
||||
),
|
||||
(
|
||||
id: "mesh:mesh15000",
|
||||
name: "Window Cente Up.000 / Material 0",
|
||||
mesh_label: "Mesh15000",
|
||||
material_id: Some("material:material0"),
|
||||
material_slot_name: "Window Frame Metal.002",
|
||||
material_label: Some("Material0"),
|
||||
local_transform: (
|
||||
translation: (0.2986794, 1.7560294, 3.0472102),
|
||||
rotation: (0.70710677, 0.0, 0.0, -0.7071067),
|
||||
scale: (1.2387476, 0.9248343, 1.0),
|
||||
),
|
||||
source_node: Some("Node15"),
|
||||
source_mesh: Some("Mesh15"),
|
||||
source_material: Some("Window Frame Metal.002"),
|
||||
),
|
||||
(
|
||||
id: "mesh:mesh15001",
|
||||
name: "Window Cente Up.000 / Material 1",
|
||||
mesh_label: "Mesh15001",
|
||||
material_id: Some("material:material1"),
|
||||
material_slot_name: "Window Glass.002",
|
||||
material_label: Some("Material1"),
|
||||
local_transform: (
|
||||
translation: (0.2986794, 1.7560294, 3.0472102),
|
||||
rotation: (0.70710677, 0.0, 0.0, -0.7071067),
|
||||
scale: (1.2387476, 0.9248343, 1.0),
|
||||
),
|
||||
source_node: Some("Node15"),
|
||||
source_mesh: Some("Mesh15"),
|
||||
source_material: Some("Window Glass.002"),
|
||||
),
|
||||
(
|
||||
id: "mesh:mesh16000",
|
||||
name: "Lights.Potlight.004 / Material 0",
|
||||
mesh_label: "Mesh16000",
|
||||
material_id: Some("material:material7"),
|
||||
material_slot_name: "Light Metal.002",
|
||||
material_label: Some("Material7"),
|
||||
local_transform: (
|
||||
translation: (1.9999996, 2.9877658, 1.1368884),
|
||||
rotation: (0.70710677, 0.0, 0.0, -0.7071067),
|
||||
scale: (1.0, 1.0, 1.0),
|
||||
),
|
||||
source_node: Some("Node16"),
|
||||
source_mesh: Some("Mesh16"),
|
||||
source_material: Some("Light Metal.002"),
|
||||
),
|
||||
(
|
||||
id: "mesh:mesh16002",
|
||||
name: "Lights.Potlight.004 / Material 2",
|
||||
mesh_label: "Mesh16002",
|
||||
material_id: Some("material:material9"),
|
||||
material_slot_name: "Light.002",
|
||||
material_label: Some("Material9"),
|
||||
local_transform: (
|
||||
translation: (1.9999996, 2.9877658, 1.1368884),
|
||||
rotation: (0.70710677, 0.0, 0.0, -0.7071067),
|
||||
scale: (1.0, 1.0, 1.0),
|
||||
),
|
||||
source_node: Some("Node16"),
|
||||
source_mesh: Some("Mesh16"),
|
||||
source_material: Some("Light.002"),
|
||||
),
|
||||
(
|
||||
id: "mesh:mesh17000",
|
||||
name: "Lights.Potlight.003 / Material 0",
|
||||
mesh_label: "Mesh17000",
|
||||
material_id: Some("material:material7"),
|
||||
material_slot_name: "Light Metal.002",
|
||||
material_label: Some("Material7"),
|
||||
local_transform: (
|
||||
translation: (1.9999996, 2.9877653, -1.8631115),
|
||||
rotation: (0.70710677, 0.0, 0.0, -0.7071067),
|
||||
scale: (1.0, 1.0, 1.0),
|
||||
),
|
||||
source_node: Some("Node17"),
|
||||
source_mesh: Some("Mesh17"),
|
||||
source_material: Some("Light Metal.002"),
|
||||
),
|
||||
(
|
||||
id: "mesh:mesh17002",
|
||||
name: "Lights.Potlight.003 / Material 2",
|
||||
mesh_label: "Mesh17002",
|
||||
material_id: Some("material:material9"),
|
||||
material_slot_name: "Light.002",
|
||||
material_label: Some("Material9"),
|
||||
local_transform: (
|
||||
translation: (1.9999996, 2.9877653, -1.8631115),
|
||||
rotation: (0.70710677, 0.0, 0.0, -0.7071067),
|
||||
scale: (1.0, 1.0, 1.0),
|
||||
),
|
||||
source_node: Some("Node17"),
|
||||
source_mesh: Some("Mesh17"),
|
||||
source_material: Some("Light.002"),
|
||||
),
|
||||
(
|
||||
id: "mesh:mesh18000",
|
||||
name: "Lights.Potlight.002 / Material 0",
|
||||
mesh_label: "Mesh18000",
|
||||
material_id: Some("material:material7"),
|
||||
material_slot_name: "Light Metal.002",
|
||||
material_label: Some("Material7"),
|
||||
local_transform: (
|
||||
translation: (-1.8000002, 2.9877653, -1.8631115),
|
||||
rotation: (0.70710677, 0.0, 0.0, -0.7071067),
|
||||
scale: (1.0, 1.0, 1.0),
|
||||
),
|
||||
source_node: Some("Node18"),
|
||||
source_mesh: Some("Mesh18"),
|
||||
source_material: Some("Light Metal.002"),
|
||||
),
|
||||
(
|
||||
id: "mesh:mesh18002",
|
||||
name: "Lights.Potlight.002 / Material 2",
|
||||
mesh_label: "Mesh18002",
|
||||
material_id: Some("material:material9"),
|
||||
material_slot_name: "Light.002",
|
||||
material_label: Some("Material9"),
|
||||
local_transform: (
|
||||
translation: (-1.8000002, 2.9877653, -1.8631115),
|
||||
rotation: (0.70710677, 0.0, 0.0, -0.7071067),
|
||||
scale: (1.0, 1.0, 1.0),
|
||||
),
|
||||
source_node: Some("Node18"),
|
||||
source_mesh: Some("Mesh18"),
|
||||
source_material: Some("Light.002"),
|
||||
),
|
||||
(
|
||||
id: "mesh:mesh19000",
|
||||
name: "Lights.Potlight.001 / Material 0",
|
||||
mesh_label: "Mesh19000",
|
||||
material_id: Some("material:material7"),
|
||||
material_slot_name: "Light Metal.002",
|
||||
material_label: Some("Material7"),
|
||||
local_transform: (
|
||||
translation: (-1.8000002, 2.9877658, 1.1368884),
|
||||
rotation: (0.70710677, 0.0, 0.0, -0.7071067),
|
||||
scale: (1.0, 1.0, 1.0),
|
||||
),
|
||||
source_node: Some("Node19"),
|
||||
source_mesh: Some("Mesh19"),
|
||||
source_material: Some("Light Metal.002"),
|
||||
),
|
||||
(
|
||||
id: "mesh:mesh19002",
|
||||
name: "Lights.Potlight.001 / Material 2",
|
||||
mesh_label: "Mesh19002",
|
||||
material_id: Some("material:material9"),
|
||||
material_slot_name: "Light.002",
|
||||
material_label: Some("Material9"),
|
||||
local_transform: (
|
||||
translation: (-1.8000002, 2.9877658, 1.1368884),
|
||||
rotation: (0.70710677, 0.0, 0.0, -0.7071067),
|
||||
scale: (1.0, 1.0, 1.0),
|
||||
),
|
||||
source_node: Some("Node19"),
|
||||
source_mesh: Some("Mesh19"),
|
||||
source_material: Some("Light.002"),
|
||||
),
|
||||
(
|
||||
id: "mesh:mesh2000",
|
||||
name: "Window Right.000 / Material 0",
|
||||
mesh_label: "Mesh2000",
|
||||
material_id: Some("material:defaultmaterial"),
|
||||
material_slot_name: "Default Material",
|
||||
material_label: Some("DefaultMaterial"),
|
||||
local_transform: (
|
||||
translation: (-0.9659626, 1.613703, 3.0355246),
|
||||
rotation: (0.70710677, 0.0, 0.0, -0.7071067),
|
||||
scale: (1.2387476, 0.9248338, 1.0),
|
||||
),
|
||||
source_node: Some("Node2"),
|
||||
source_mesh: Some("Mesh2"),
|
||||
source_material: Some("Default Material"),
|
||||
),
|
||||
(
|
||||
id: "mesh:mesh20000",
|
||||
name: "Lights.Center.003 / Material 0",
|
||||
mesh_label: "Mesh20000",
|
||||
material_id: Some("material:material7"),
|
||||
material_slot_name: "Light Metal.002",
|
||||
material_label: Some("Material7"),
|
||||
local_transform: (
|
||||
translation: (0.24156909, 2.792474, -0.63835967),
|
||||
rotation: (0.70710677, 0.0, 0.0, -0.7071067),
|
||||
scale: (1.0, 1.0, 1.0),
|
||||
),
|
||||
source_node: Some("Node20"),
|
||||
source_mesh: Some("Mesh20"),
|
||||
source_material: Some("Light Metal.002"),
|
||||
),
|
||||
(
|
||||
id: "mesh:mesh20001",
|
||||
name: "Lights.Center.003 / Material 1",
|
||||
mesh_label: "Mesh20001",
|
||||
material_id: Some("material:material8"),
|
||||
material_slot_name: "Matte Black.002",
|
||||
material_label: Some("Material8"),
|
||||
local_transform: (
|
||||
translation: (0.24156909, 2.792474, -0.63835967),
|
||||
rotation: (0.70710677, 0.0, 0.0, -0.7071067),
|
||||
scale: (1.0, 1.0, 1.0),
|
||||
),
|
||||
source_node: Some("Node20"),
|
||||
source_mesh: Some("Mesh20"),
|
||||
source_material: Some("Matte Black.002"),
|
||||
),
|
||||
(
|
||||
id: "mesh:mesh20002",
|
||||
name: "Lights.Center.003 / Material 2",
|
||||
mesh_label: "Mesh20002",
|
||||
material_id: Some("material:material9"),
|
||||
material_slot_name: "Light.002",
|
||||
material_label: Some("Material9"),
|
||||
local_transform: (
|
||||
translation: (0.24156909, 2.792474, -0.63835967),
|
||||
rotation: (0.70710677, 0.0, 0.0, -0.7071067),
|
||||
scale: (1.0, 1.0, 1.0),
|
||||
),
|
||||
source_node: Some("Node20"),
|
||||
source_mesh: Some("Mesh20"),
|
||||
source_material: Some("Light.002"),
|
||||
),
|
||||
(
|
||||
id: "mesh:mesh21000",
|
||||
name: "Lights.Center.002 / Material 0",
|
||||
mesh_label: "Mesh21000",
|
||||
material_id: Some("material:material7"),
|
||||
material_slot_name: "Light Metal.002",
|
||||
material_label: Some("Material7"),
|
||||
local_transform: (
|
||||
translation: (0.25059184, 2.7924743, 0.36630905),
|
||||
rotation: (0.70710677, 0.0, 0.0, -0.7071067),
|
||||
scale: (1.0, 1.0, 1.0),
|
||||
),
|
||||
source_node: Some("Node21"),
|
||||
source_mesh: Some("Mesh21"),
|
||||
source_material: Some("Light Metal.002"),
|
||||
),
|
||||
(
|
||||
id: "mesh:mesh21001",
|
||||
name: "Lights.Center.002 / Material 1",
|
||||
mesh_label: "Mesh21001",
|
||||
material_id: Some("material:material8"),
|
||||
material_slot_name: "Matte Black.002",
|
||||
material_label: Some("Material8"),
|
||||
local_transform: (
|
||||
translation: (0.25059184, 2.7924743, 0.36630905),
|
||||
rotation: (0.70710677, 0.0, 0.0, -0.7071067),
|
||||
scale: (1.0, 1.0, 1.0),
|
||||
),
|
||||
source_node: Some("Node21"),
|
||||
source_mesh: Some("Mesh21"),
|
||||
source_material: Some("Matte Black.002"),
|
||||
),
|
||||
(
|
||||
id: "mesh:mesh21002",
|
||||
name: "Lights.Center.002 / Material 2",
|
||||
mesh_label: "Mesh21002",
|
||||
material_id: Some("material:material9"),
|
||||
material_slot_name: "Light.002",
|
||||
material_label: Some("Material9"),
|
||||
local_transform: (
|
||||
translation: (0.25059184, 2.7924743, 0.36630905),
|
||||
rotation: (0.70710677, 0.0, 0.0, -0.7071067),
|
||||
scale: (1.0, 1.0, 1.0),
|
||||
),
|
||||
source_node: Some("Node21"),
|
||||
source_mesh: Some("Mesh21"),
|
||||
source_material: Some("Light.002"),
|
||||
),
|
||||
(
|
||||
id: "mesh:mesh22000",
|
||||
name: "Lights.Center.001 / Material 0",
|
||||
mesh_label: "Mesh22000",
|
||||
material_id: Some("material:material7"),
|
||||
material_slot_name: "Light Metal.002",
|
||||
material_label: Some("Material7"),
|
||||
local_transform: (
|
||||
translation: (0.21980214, 2.7924743, -0.15153252),
|
||||
rotation: (0.70710677, 0.0, 0.0, -0.7071067),
|
||||
scale: (1.0, 1.0, 1.0),
|
||||
),
|
||||
source_node: Some("Node22"),
|
||||
source_mesh: Some("Mesh22"),
|
||||
source_material: Some("Light Metal.002"),
|
||||
),
|
||||
(
|
||||
id: "mesh:mesh22001",
|
||||
name: "Lights.Center.001 / Material 1",
|
||||
mesh_label: "Mesh22001",
|
||||
material_id: Some("material:material8"),
|
||||
material_slot_name: "Matte Black.002",
|
||||
material_label: Some("Material8"),
|
||||
local_transform: (
|
||||
translation: (0.21980214, 2.7924743, -0.15153252),
|
||||
rotation: (0.70710677, 0.0, 0.0, -0.7071067),
|
||||
scale: (1.0, 1.0, 1.0),
|
||||
),
|
||||
source_node: Some("Node22"),
|
||||
source_mesh: Some("Mesh22"),
|
||||
source_material: Some("Matte Black.002"),
|
||||
),
|
||||
(
|
||||
id: "mesh:mesh22002",
|
||||
name: "Lights.Center.001 / Material 2",
|
||||
mesh_label: "Mesh22002",
|
||||
material_id: Some("material:material9"),
|
||||
material_slot_name: "Light.002",
|
||||
material_label: Some("Material9"),
|
||||
local_transform: (
|
||||
translation: (0.21980214, 2.7924743, -0.15153252),
|
||||
rotation: (0.70710677, 0.0, 0.0, -0.7071067),
|
||||
scale: (1.0, 1.0, 1.0),
|
||||
),
|
||||
source_node: Some("Node22"),
|
||||
source_mesh: Some("Mesh22"),
|
||||
source_material: Some("Light.002"),
|
||||
),
|
||||
(
|
||||
id: "mesh:mesh23002",
|
||||
name: "Door_Knob.000 / Material 2",
|
||||
mesh_label: "Mesh23002",
|
||||
material_id: Some("material:material10"),
|
||||
material_slot_name: "Door Knob Gold.002",
|
||||
material_label: Some("Material10"),
|
||||
local_transform: (
|
||||
translation: (-3.0024662, 1.0415587, -1.6272027),
|
||||
rotation: (0.70710677, -0.0000000023899738, 0.00000000007193627, -0.7071067),
|
||||
scale: (0.07870353, 0.45364702, 0.45364702),
|
||||
),
|
||||
source_node: Some("Node23"),
|
||||
source_mesh: Some("Mesh23"),
|
||||
source_material: Some("Door Knob Gold.002"),
|
||||
),
|
||||
(
|
||||
id: "mesh:mesh3000",
|
||||
name: "Window Left.000 / Material 0",
|
||||
mesh_label: "Mesh3000",
|
||||
material_id: Some("material:defaultmaterial"),
|
||||
material_slot_name: "Default Material",
|
||||
material_label: Some("DefaultMaterial"),
|
||||
local_transform: (
|
||||
translation: (1.5851622, 1.613703, 3.0355246),
|
||||
rotation: (0.70710677, 0.0, 0.0, -0.7071067),
|
||||
scale: (1.2387476, 0.9248338, 1.0),
|
||||
),
|
||||
source_node: Some("Node3"),
|
||||
source_mesh: Some("Mesh3"),
|
||||
source_material: Some("Default Material"),
|
||||
),
|
||||
(
|
||||
id: "mesh:mesh4000",
|
||||
name: "Window Frames.000 / Material 0",
|
||||
mesh_label: "Mesh4000",
|
||||
material_id: Some("material:material2"),
|
||||
material_slot_name: "Painted Wooden Frame.002",
|
||||
material_label: Some("Material2"),
|
||||
local_transform: (
|
||||
translation: (0.0, 0.0027079582, 0.0),
|
||||
rotation: (0.5000001, -0.49999994, -0.5, -0.50000006),
|
||||
scale: (1.0, 1.0, 1.0),
|
||||
),
|
||||
source_node: Some("Node4"),
|
||||
source_mesh: Some("Mesh4"),
|
||||
source_material: Some("Painted Wooden Frame.002"),
|
||||
),
|
||||
(
|
||||
id: "mesh:mesh5000",
|
||||
name: "Window Center.000 / Material 0",
|
||||
mesh_label: "Mesh5000",
|
||||
material_id: Some("material:defaultmaterial"),
|
||||
material_slot_name: "Default Material",
|
||||
material_label: Some("DefaultMaterial"),
|
||||
local_transform: (
|
||||
translation: (0.3107944, 1.613703, 3.0355246),
|
||||
rotation: (0.70710677, 0.0, 0.0, -0.7071067),
|
||||
scale: (1.2387476, 0.9248338, 1.0),
|
||||
),
|
||||
source_node: Some("Node5"),
|
||||
source_mesh: Some("Mesh5"),
|
||||
source_material: Some("Default Material"),
|
||||
),
|
||||
(
|
||||
id: "mesh:mesh6000",
|
||||
name: "Room.000 / Material 0",
|
||||
mesh_label: "Mesh6000",
|
||||
material_id: Some("material:material2"),
|
||||
material_slot_name: "Painted Wooden Frame.002",
|
||||
material_label: Some("Material2"),
|
||||
local_transform: (
|
||||
translation: (0.0, 0.0027079582, 0.0),
|
||||
rotation: (0.70710677, 0.0, 0.0, -0.7071067),
|
||||
scale: (1.0, 1.0, 1.0),
|
||||
),
|
||||
source_node: Some("Node6"),
|
||||
source_mesh: Some("Mesh6"),
|
||||
source_material: Some("Painted Wooden Frame.002"),
|
||||
),
|
||||
(
|
||||
id: "mesh:mesh6001",
|
||||
name: "Room.000 / Material 1",
|
||||
mesh_label: "Mesh6001",
|
||||
material_id: Some("material:material3"),
|
||||
material_slot_name: "Ceiling.002",
|
||||
material_label: Some("Material3"),
|
||||
local_transform: (
|
||||
translation: (0.0, 0.0027079582, 0.0),
|
||||
rotation: (0.70710677, 0.0, 0.0, -0.7071067),
|
||||
scale: (1.0, 1.0, 1.0),
|
||||
),
|
||||
source_node: Some("Node6"),
|
||||
source_mesh: Some("Mesh6"),
|
||||
source_material: Some("Ceiling.002"),
|
||||
),
|
||||
(
|
||||
id: "mesh:mesh6002",
|
||||
name: "Room.000 / Material 2",
|
||||
mesh_label: "Mesh6002",
|
||||
material_id: Some("material:material4"),
|
||||
material_slot_name: "Hardwood Floor.002",
|
||||
material_label: Some("Material4"),
|
||||
local_transform: (
|
||||
translation: (0.0, 0.0027079582, 0.0),
|
||||
rotation: (0.70710677, 0.0, 0.0, -0.7071067),
|
||||
scale: (1.0, 1.0, 1.0),
|
||||
),
|
||||
source_node: Some("Node6"),
|
||||
source_mesh: Some("Mesh6"),
|
||||
source_material: Some("Hardwood Floor.002"),
|
||||
),
|
||||
(
|
||||
id: "mesh:mesh6003",
|
||||
name: "Room.000 / Material 3",
|
||||
mesh_label: "Mesh6003",
|
||||
material_id: Some("material:material5"),
|
||||
material_slot_name: "Painted Wall.002",
|
||||
material_label: Some("Material5"),
|
||||
local_transform: (
|
||||
translation: (0.0, 0.0027079582, 0.0),
|
||||
rotation: (0.70710677, 0.0, 0.0, -0.7071067),
|
||||
scale: (1.0, 1.0, 1.0),
|
||||
),
|
||||
source_node: Some("Node6"),
|
||||
source_mesh: Some("Mesh6"),
|
||||
source_material: Some("Painted Wall.002"),
|
||||
),
|
||||
(
|
||||
id: "mesh:mesh6004",
|
||||
name: "Room.000 / Material 4",
|
||||
mesh_label: "Mesh6004",
|
||||
material_id: Some("material:material6"),
|
||||
material_slot_name: "Brick Accent Wall.002",
|
||||
material_label: Some("Material6"),
|
||||
local_transform: (
|
||||
translation: (0.0, 0.0027079582, 0.0),
|
||||
rotation: (0.70710677, 0.0, 0.0, -0.7071067),
|
||||
scale: (1.0, 1.0, 1.0),
|
||||
),
|
||||
source_node: Some("Node6"),
|
||||
source_mesh: Some("Mesh6"),
|
||||
source_material: Some("Brick Accent Wall.002"),
|
||||
),
|
||||
(
|
||||
id: "mesh:mesh7000",
|
||||
name: "Lights.000 / Material 0",
|
||||
mesh_label: "Mesh7000",
|
||||
material_id: Some("material:material7"),
|
||||
material_slot_name: "Light Metal.002",
|
||||
material_label: Some("Material7"),
|
||||
local_transform: (
|
||||
translation: (0.24510838, 2.9453187, -0.14346208),
|
||||
rotation: (0.70710677, 0.0, 0.0, -0.7071067),
|
||||
scale: (1.0, 1.0, 1.0),
|
||||
),
|
||||
source_node: Some("Node7"),
|
||||
source_mesh: Some("Mesh7"),
|
||||
source_material: Some("Light Metal.002"),
|
||||
),
|
||||
(
|
||||
id: "mesh:mesh8000",
|
||||
name: "Door Frame.000 / Material 0",
|
||||
mesh_label: "Mesh8000",
|
||||
material_id: Some("material:material2"),
|
||||
material_slot_name: "Painted Wooden Frame.002",
|
||||
material_label: Some("Material2"),
|
||||
local_transform: (
|
||||
translation: (-3.0060542, 0.0027079582, -2.1066966),
|
||||
rotation: (0.70710677, 0.0, 0.0, -0.7071067),
|
||||
scale: (1.2365791, 1.0, 1.0),
|
||||
),
|
||||
source_node: Some("Node8"),
|
||||
source_mesh: Some("Mesh8"),
|
||||
source_material: Some("Painted Wooden Frame.002"),
|
||||
),
|
||||
(
|
||||
id: "mesh:mesh9000",
|
||||
name: "Door.000 / Material 0",
|
||||
mesh_label: "Mesh9000",
|
||||
material_id: Some("material:material2"),
|
||||
material_slot_name: "Painted Wooden Frame.002",
|
||||
material_label: Some("Material2"),
|
||||
local_transform: (
|
||||
translation: (-2.9729986, 0.22508162, -2.6792114),
|
||||
rotation: (0.70710677, 0.0, 0.0, -0.7071067),
|
||||
scale: (0.07870354, 0.45364702, 0.45364702),
|
||||
),
|
||||
source_node: Some("Node9"),
|
||||
source_mesh: Some("Mesh9"),
|
||||
source_material: Some("Painted Wooden Frame.002"),
|
||||
),
|
||||
(
|
||||
id: "mesh:mesh9001",
|
||||
name: "Door.000 / Material 1",
|
||||
mesh_label: "Mesh9001",
|
||||
material_id: Some("material:material7"),
|
||||
material_slot_name: "Light Metal.002",
|
||||
material_label: Some("Material7"),
|
||||
local_transform: (
|
||||
translation: (-2.9729986, 0.22508162, -2.6792114),
|
||||
rotation: (0.70710677, 0.0, 0.0, -0.7071067),
|
||||
scale: (0.07870354, 0.45364702, 0.45364702),
|
||||
),
|
||||
source_node: Some("Node9"),
|
||||
source_mesh: Some("Mesh9"),
|
||||
source_material: Some("Light Metal.002"),
|
||||
),
|
||||
(
|
||||
id: "mesh:mesh9002",
|
||||
name: "Door.000 / Material 2",
|
||||
mesh_label: "Mesh9002",
|
||||
material_id: Some("material:material10"),
|
||||
material_slot_name: "Door Knob Gold.002",
|
||||
material_label: Some("Material10"),
|
||||
local_transform: (
|
||||
translation: (-2.9729986, 0.22508162, -2.6792114),
|
||||
rotation: (0.70710677, 0.0, 0.0, -0.7071067),
|
||||
scale: (0.07870354, 0.45364702, 0.45364702),
|
||||
),
|
||||
source_node: Some("Node9"),
|
||||
source_mesh: Some("Mesh9"),
|
||||
source_material: Some("Door Knob Gold.002"),
|
||||
),
|
||||
],
|
||||
warnings: [],
|
||||
)
|
||||
0
assets/models/.gitkeep
Normal file
0
assets/models/.gitkeep
Normal file
BIN
assets/models/Dumpster.fbx
(Stored with Git LFS)
Executable file
BIN
assets/models/Dumpster.fbx
(Stored with Git LFS)
Executable file
Binary file not shown.
BIN
assets/models/Room3.fbx
(Stored with Git LFS)
Executable file
BIN
assets/models/Room3.fbx
(Stored with Git LFS)
Executable file
Binary file not shown.
7
assets/post_fx/chromatic_aberration.ron
Normal file
7
assets/post_fx/chromatic_aberration.ron
Normal file
@ -0,0 +1,7 @@
|
||||
(
|
||||
label: "Chromatic Aberration",
|
||||
kind: ChromaticAberration,
|
||||
shader: "post_fx/chromatic_aberration.wgsl",
|
||||
intensity: 0.8,
|
||||
radius: 0.5,
|
||||
)
|
||||
20
assets/post_fx/chromatic_aberration.wgsl
Normal file
20
assets/post_fx/chromatic_aberration.wgsl
Normal file
@ -0,0 +1,20 @@
|
||||
#import bevy_core_pipeline::fullscreen_vertex_shader::FullscreenVertexOutput
|
||||
|
||||
struct ChromaticSettings {
|
||||
intensity: f32,
|
||||
radius: f32,
|
||||
}
|
||||
|
||||
@group(0) @binding(2) var<uniform> settings: ChromaticSettings;
|
||||
@group(0) @binding(0) var screen_texture: texture_2d<f32>;
|
||||
@group(0) @binding(1) var texture_sampler: sampler;
|
||||
|
||||
@fragment
|
||||
fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4<f32> {
|
||||
let offset = settings.intensity * 0.004;
|
||||
let r = textureSample(screen_texture, texture_sampler, in.uv + vec2<f32>(offset, 0.0)).r;
|
||||
let g = textureSample(screen_texture, texture_sampler, in.uv).g;
|
||||
let b = textureSample(screen_texture, texture_sampler, in.uv - vec2<f32>(offset, 0.0)).b;
|
||||
let a = textureSample(screen_texture, texture_sampler, in.uv).a;
|
||||
return vec4<f32>(r, g, b, a);
|
||||
}
|
||||
7
assets/post_fx/vignette.ron
Normal file
7
assets/post_fx/vignette.ron
Normal file
@ -0,0 +1,7 @@
|
||||
(
|
||||
label: "Vignette",
|
||||
kind: Vignette,
|
||||
shader: "post_fx/vignette.wgsl",
|
||||
intensity: 0.65,
|
||||
radius: 0.75,
|
||||
)
|
||||
19
assets/post_fx/vignette.wgsl
Normal file
19
assets/post_fx/vignette.wgsl
Normal file
@ -0,0 +1,19 @@
|
||||
#import bevy_core_pipeline::fullscreen_vertex_shader::FullscreenVertexOutput
|
||||
|
||||
struct VignetteSettings {
|
||||
intensity: f32,
|
||||
radius: f32,
|
||||
}
|
||||
|
||||
@group(0) @binding(2) var<uniform> settings: VignetteSettings;
|
||||
@group(0) @binding(0) var screen_texture: texture_2d<f32>;
|
||||
@group(0) @binding(1) var texture_sampler: sampler;
|
||||
|
||||
@fragment
|
||||
fn fragment(in: FullscreenVertexOutput) -> @location(0) vec4<f32> {
|
||||
let color = textureSample(screen_texture, texture_sampler, in.uv);
|
||||
let dist = distance(in.uv, vec2<f32>(0.5, 0.5));
|
||||
let vignette = smoothstep(settings.radius, settings.radius - 0.35, dist);
|
||||
let strength = mix(1.0, vignette, settings.intensity);
|
||||
return vec4<f32>(color.rgb * strength, color.a);
|
||||
}
|
||||
58
assets/project.ron
Normal file
58
assets/project.ron
Normal file
@ -0,0 +1,58 @@
|
||||
(
|
||||
version: 1,
|
||||
name: "Bevy FPS Foundation",
|
||||
default_level: "assets/levels/editor_scene.scn.ron",
|
||||
asset_roots: [
|
||||
"assets/models",
|
||||
"assets/textures",
|
||||
"assets/materials",
|
||||
"assets/levels",
|
||||
],
|
||||
rendering: (
|
||||
gi_mode: Auto,
|
||||
hdr: true,
|
||||
exposure_mode: Manual,
|
||||
exposure_ev100: 12.5,
|
||||
auto_exposure_speed_brighten: 3.0,
|
||||
auto_exposure_speed_darken: 1.0,
|
||||
tonemapping_aces: true,
|
||||
bloom: true,
|
||||
taa: true,
|
||||
ssao: true,
|
||||
atmosphere: true,
|
||||
fog_color: (0.66, 0.75, 0.86),
|
||||
fog_density: 0.0012,
|
||||
ambient_color: (0.6, 0.7, 0.85),
|
||||
ambient_brightness: 60.0,
|
||||
sun_illuminance: 100000.0,
|
||||
shadow_cascades: 4,
|
||||
shadow_first_cascade: 6.0,
|
||||
shadow_max_distance: 220.0,
|
||||
),
|
||||
physics: (
|
||||
player_radius: 0.4,
|
||||
player_length: 1.0,
|
||||
eye_offset_stand: 0.6,
|
||||
eye_offset_crouch: 0.1,
|
||||
gravity: 22.0,
|
||||
walk_speed: 6.0,
|
||||
run_speed: 9.5,
|
||||
crouch_speed: 3.0,
|
||||
jump_speed: 7.5,
|
||||
ground_accel: 70.0,
|
||||
air_accel: 16.0,
|
||||
ground_check_dist: 0.2,
|
||||
skin: 0.02,
|
||||
max_slide_iter: 4,
|
||||
ground_normal_y: 0.5,
|
||||
coyote_time: 0.12,
|
||||
jump_buffer: 0.12,
|
||||
),
|
||||
input: (
|
||||
yaw_sensitivity: 0.003,
|
||||
pitch_sensitivity: 0.0022,
|
||||
editor_fly_speed: 14.0,
|
||||
editor_look_sensitivity: 0.003,
|
||||
editor_pan_speed: 0.015,
|
||||
),
|
||||
)
|
||||
9
assets/rendering_profiles/cave_dark.ron
Normal file
9
assets/rendering_profiles/cave_dark.ron
Normal file
@ -0,0 +1,9 @@
|
||||
(
|
||||
label: "Cave Dark",
|
||||
overrides: (
|
||||
exposure_ev100: Some(9.0),
|
||||
fog_density: Some(0.0025),
|
||||
bloom: Some(false),
|
||||
ssao: Some(true),
|
||||
),
|
||||
)
|
||||
9
assets/rendering_profiles/outdoor_haze.ron
Normal file
9
assets/rendering_profiles/outdoor_haze.ron
Normal file
@ -0,0 +1,9 @@
|
||||
(
|
||||
label: "Outdoor Haze",
|
||||
overrides: (
|
||||
exposure_ev100: Some(14.5),
|
||||
fog_density: Some(0.003),
|
||||
atmosphere: Some(true),
|
||||
bloom: Some(true),
|
||||
),
|
||||
)
|
||||
18
assets/shaders/standard_lit.shader.ron
Normal file
18
assets/shaders/standard_lit.shader.ron
Normal file
@ -0,0 +1,18 @@
|
||||
(
|
||||
label: "Standard Lit",
|
||||
kind: StandardLit,
|
||||
wgsl_path: None,
|
||||
parameters: [
|
||||
(name: "base_color", display_name: "Base Color", group: "Surface", property_type: Color),
|
||||
(name: "metallic", display_name: "Metallic", group: "Surface", property_type: Float(min: Some(0.0), max: Some(1.0))),
|
||||
(name: "roughness", display_name: "Roughness", group: "Surface", property_type: Float(min: Some(0.0), max: Some(1.0))),
|
||||
(name: "emissive_color", display_name: "Emissive Color", group: "Emission", property_type: Color),
|
||||
(name: "emissive_intensity", display_name: "Emissive Nits", group: "Emission", property_type: Float(min: Some(0.0), max: Some(20000.0))),
|
||||
(name: "base_color_texture", display_name: "Base Color Texture", group: "Textures", property_type: Texture),
|
||||
(name: "normal_map_texture", display_name: "Normal Map", group: "Textures", property_type: Texture),
|
||||
(name: "metallic_roughness_texture", display_name: "Metallic/Roughness Texture", group: "Textures", property_type: Texture),
|
||||
(name: "emissive_texture", display_name: "Emissive Texture", group: "Textures", property_type: Texture),
|
||||
],
|
||||
default_values: [],
|
||||
default_textures: [],
|
||||
)
|
||||
13
assets/shaders/unlit.shader.ron
Normal file
13
assets/shaders/unlit.shader.ron
Normal file
@ -0,0 +1,13 @@
|
||||
(
|
||||
label: "Unlit",
|
||||
kind: Unlit,
|
||||
wgsl_path: None,
|
||||
parameters: [
|
||||
(name: "base_color", display_name: "Base Color", group: "Surface", property_type: Color),
|
||||
(name: "base_color_texture", display_name: "Base Color Texture", group: "Textures", property_type: Texture),
|
||||
(name: "emissive_color", display_name: "Emissive Color", group: "Emission", property_type: Color),
|
||||
(name: "emissive_intensity", display_name: "Emissive Nits", group: "Emission", property_type: Float(min: Some(0.0), max: Some(20000.0))),
|
||||
],
|
||||
default_values: [],
|
||||
default_textures: [],
|
||||
)
|
||||
0
assets/textures/.gitkeep
Normal file
0
assets/textures/.gitkeep
Normal file
45
crates/editor/Cargo.toml
Normal file
45
crates/editor/Cargo.toml
Normal file
@ -0,0 +1,45 @@
|
||||
[package]
|
||||
name = "editor"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "In-process level editor for the Bevy FPS foundation"
|
||||
|
||||
[lib]
|
||||
name = "editor"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "editor"
|
||||
path = "src/bin/main.rs"
|
||||
|
||||
[features]
|
||||
dev = ["bevy/dynamic_linking", "game/dev"]
|
||||
hot-reload = ["game/hot-reload", "dep:hot-lib-reloader", "dep:notify"]
|
||||
|
||||
[dependencies]
|
||||
avian3d.workspace = true
|
||||
bevy = { workspace = true, default-features = true, features = ["bevy_remote"] }
|
||||
bevy_solari.workspace = true
|
||||
bevy_egui.workspace = true
|
||||
bevy-inspector-egui.workspace = true
|
||||
egui_dock.workspace = true
|
||||
egui_phosphor_icons.workspace = true
|
||||
bevy_ufbx.workspace = true
|
||||
ufbx = "0.9"
|
||||
game.workspace = true
|
||||
game_hot.workspace = true
|
||||
hot-lib-reloader = { version = "0.8.2", optional = true }
|
||||
notify = { version = "6.1", optional = true }
|
||||
protocol.workspace = true
|
||||
shared.workspace = true
|
||||
sim.workspace = true
|
||||
settings.workspace = true
|
||||
scene.workspace = true
|
||||
serde.workspace = true
|
||||
transform-gizmo-bevy.workspace = true
|
||||
rfd = "0.17.2"
|
||||
ron = "0.8"
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
walkdir = "2.5"
|
||||
gltf = "1.4"
|
||||
BIN
crates/editor/assets/icons/01_actor_empty.png
(Stored with Git LFS)
Normal file
BIN
crates/editor/assets/icons/01_actor_empty.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
crates/editor/assets/icons/02_actor_generic.png
(Stored with Git LFS)
Normal file
BIN
crates/editor/assets/icons/02_actor_generic.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
crates/editor/assets/icons/03_actor_missing.png
(Stored with Git LFS)
Normal file
BIN
crates/editor/assets/icons/03_actor_missing.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
crates/editor/assets/icons/04_actor_warning.png
(Stored with Git LFS)
Normal file
BIN
crates/editor/assets/icons/04_actor_warning.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
crates/editor/assets/icons/05_render_mesh.png
(Stored with Git LFS)
Normal file
BIN
crates/editor/assets/icons/05_render_mesh.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
crates/editor/assets/icons/06_render_decal.png
(Stored with Git LFS)
Normal file
BIN
crates/editor/assets/icons/06_render_decal.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
crates/editor/assets/icons/07_render_particle.png
(Stored with Git LFS)
Normal file
BIN
crates/editor/assets/icons/07_render_particle.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
crates/editor/assets/icons/08_render_billboard.png
(Stored with Git LFS)
Normal file
BIN
crates/editor/assets/icons/08_render_billboard.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
crates/editor/assets/icons/09_light_point.png
(Stored with Git LFS)
Normal file
BIN
crates/editor/assets/icons/09_light_point.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
crates/editor/assets/icons/10_light_spot.png
(Stored with Git LFS)
Normal file
BIN
crates/editor/assets/icons/10_light_spot.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
crates/editor/assets/icons/11_light_directional.png
(Stored with Git LFS)
Normal file
BIN
crates/editor/assets/icons/11_light_directional.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
crates/editor/assets/icons/12_light_area.png
(Stored with Git LFS)
Normal file
BIN
crates/editor/assets/icons/12_light_area.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
crates/editor/assets/icons/13_camera_main.png
(Stored with Git LFS)
Normal file
BIN
crates/editor/assets/icons/13_camera_main.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
crates/editor/assets/icons/14_camera_capture.png
(Stored with Git LFS)
Normal file
BIN
crates/editor/assets/icons/14_camera_capture.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
crates/editor/assets/icons/15_audio_source.png
(Stored with Git LFS)
Normal file
BIN
crates/editor/assets/icons/15_audio_source.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
crates/editor/assets/icons/16_audio_listener.png
(Stored with Git LFS)
Normal file
BIN
crates/editor/assets/icons/16_audio_listener.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
crates/editor/assets/icons/17_physics_collider.png
(Stored with Git LFS)
Normal file
BIN
crates/editor/assets/icons/17_physics_collider.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
crates/editor/assets/icons/18_physics_rigidbody.png
(Stored with Git LFS)
Normal file
BIN
crates/editor/assets/icons/18_physics_rigidbody.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
crates/editor/assets/icons/19_physics_trigger.png
(Stored with Git LFS)
Normal file
BIN
crates/editor/assets/icons/19_physics_trigger.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
crates/editor/assets/icons/20_physics_joint.png
(Stored with Git LFS)
Normal file
BIN
crates/editor/assets/icons/20_physics_joint.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
crates/editor/assets/icons/21_game_spawn.png
(Stored with Git LFS)
Normal file
BIN
crates/editor/assets/icons/21_game_spawn.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
crates/editor/assets/icons/22_game_trigger.png
(Stored with Git LFS)
Normal file
BIN
crates/editor/assets/icons/22_game_trigger.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
crates/editor/assets/icons/23_game_marker.png
(Stored with Git LFS)
Normal file
BIN
crates/editor/assets/icons/23_game_marker.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
crates/editor/assets/icons/24_game_interactable.png
(Stored with Git LFS)
Normal file
BIN
crates/editor/assets/icons/24_game_interactable.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
crates/editor/assets/icons/25_nav_waypoint.png
(Stored with Git LFS)
Normal file
BIN
crates/editor/assets/icons/25_nav_waypoint.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
crates/editor/assets/icons/26_nav_link.png
(Stored with Git LFS)
Normal file
BIN
crates/editor/assets/icons/26_nav_link.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
crates/editor/assets/icons/27_nav_region.png
(Stored with Git LFS)
Normal file
BIN
crates/editor/assets/icons/27_nav_region.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
crates/editor/assets/icons/28_volume_generic.png
(Stored with Git LFS)
Normal file
BIN
crates/editor/assets/icons/28_volume_generic.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
crates/editor/assets/icons/29_volume_post_process.png
(Stored with Git LFS)
Normal file
BIN
crates/editor/assets/icons/29_volume_post_process.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
crates/editor/assets/icons/30_environment_wind.png
(Stored with Git LFS)
Normal file
BIN
crates/editor/assets/icons/30_environment_wind.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
crates/editor/assets/icons/31_environment_sky.png
(Stored with Git LFS)
Normal file
BIN
crates/editor/assets/icons/31_environment_sky.png
(Stored with Git LFS)
Normal file
Binary file not shown.
BIN
crates/editor/assets/icons/32_debug_note.png
(Stored with Git LFS)
Normal file
BIN
crates/editor/assets/icons/32_debug_note.png
(Stored with Git LFS)
Normal file
Binary file not shown.
28
crates/editor/assets/shaders/selection_outline.wgsl
Normal file
28
crates/editor/assets/shaders/selection_outline.wgsl
Normal file
@ -0,0 +1,28 @@
|
||||
#import bevy_pbr::{
|
||||
forward_io::VertexOutput,
|
||||
mesh_view_bindings::view,
|
||||
}
|
||||
|
||||
struct OutlineMaterial {
|
||||
color: vec4<f32>,
|
||||
rim_power: f32,
|
||||
rim_mix: f32,
|
||||
pass_kind: u32,
|
||||
}
|
||||
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(0)
|
||||
var<uniform> material: OutlineMaterial;
|
||||
|
||||
@fragment
|
||||
fn fragment(in: VertexOutput) -> @location(0) vec4<f32> {
|
||||
let world_normal = normalize(in.world_normal);
|
||||
let view_dir = normalize(view.world_position.xyz - in.world_position.xyz);
|
||||
let fresnel = pow(1.0 - saturate(dot(world_normal, view_dir)), material.rim_power);
|
||||
|
||||
var alpha = fresnel * material.rim_mix;
|
||||
if material.pass_kind == 1u {
|
||||
alpha *= 0.8;
|
||||
}
|
||||
|
||||
return vec4(material.color.rgb, material.color.a * saturate(alpha));
|
||||
}
|
||||
257
crates/editor/src/assets/asset_db.rs
Normal file
257
crates/editor/src/assets/asset_db.rs
Normal file
@ -0,0 +1,257 @@
|
||||
//! Project asset registry (stable IDs + import metadata). Phase 5 foundation.
|
||||
|
||||
use bevy::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Stable asset identity for dependency tracking and prefab references.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
|
||||
pub struct AssetId(pub Uuid);
|
||||
|
||||
impl AssetId {
|
||||
pub fn new() -> Self {
|
||||
Self(Uuid::new_v4())
|
||||
}
|
||||
|
||||
pub fn as_string(&self) -> String {
|
||||
self.0.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AssetId {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum ModelPlacementMode {
|
||||
StaticAsset,
|
||||
SceneInstance,
|
||||
}
|
||||
|
||||
impl Default for ModelPlacementMode {
|
||||
fn default() -> Self {
|
||||
Self::StaticAsset
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum ModelHierarchyMode {
|
||||
SingleActor,
|
||||
SourceHierarchy,
|
||||
}
|
||||
|
||||
impl Default for ModelHierarchyMode {
|
||||
fn default() -> Self {
|
||||
Self::SingleActor
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum MaterialImportPolicy {
|
||||
SourceMaterials,
|
||||
AuthoringOverride,
|
||||
}
|
||||
|
||||
impl Default for MaterialImportPolicy {
|
||||
fn default() -> Self {
|
||||
Self::SourceMaterials
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ImportSettings {
|
||||
pub scale: f32,
|
||||
pub generate_collider: bool,
|
||||
pub lod0_only: bool,
|
||||
#[serde(default)]
|
||||
pub placement_mode: ModelPlacementMode,
|
||||
#[serde(default)]
|
||||
pub hierarchy_mode: ModelHierarchyMode,
|
||||
#[serde(default)]
|
||||
pub material_policy: MaterialImportPolicy,
|
||||
#[serde(default)]
|
||||
pub static_mesh_manifest_path: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for ImportSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
scale: 1.0,
|
||||
generate_collider: true,
|
||||
lod0_only: true,
|
||||
placement_mode: ModelPlacementMode::default(),
|
||||
hierarchy_mode: ModelHierarchyMode::default(),
|
||||
material_policy: MaterialImportPolicy::default(),
|
||||
static_mesh_manifest_path: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct AssetRecord {
|
||||
pub id: AssetId,
|
||||
pub path: String,
|
||||
pub label: String,
|
||||
pub kind_tag: String,
|
||||
#[serde(default)]
|
||||
pub import_settings: ImportSettings,
|
||||
#[serde(default)]
|
||||
pub dependencies: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Resource, Debug, Default)]
|
||||
pub struct AssetRegistry {
|
||||
pub records: Vec<AssetRecord>,
|
||||
pub index_dirty: bool,
|
||||
}
|
||||
|
||||
pub struct AssetDbPlugin;
|
||||
|
||||
impl Plugin for AssetDbPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.insert_resource(load_registry())
|
||||
.add_systems(Update, sync_registry_from_browser);
|
||||
}
|
||||
}
|
||||
|
||||
fn sync_registry_from_browser(
|
||||
assets: Res<crate::assets::EditorAssets>,
|
||||
mut registry: ResMut<AssetRegistry>,
|
||||
) {
|
||||
if !assets.is_changed() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut existing: HashMap<String, AssetRecord> = registry
|
||||
.records
|
||||
.drain(..)
|
||||
.map(|record| (record.path.clone(), record))
|
||||
.collect();
|
||||
|
||||
let mut next_records = Vec::new();
|
||||
let mut seen_paths = HashMap::new();
|
||||
|
||||
for asset in &assets.assets {
|
||||
let Some(path) = asset.path.clone() else {
|
||||
continue;
|
||||
};
|
||||
if path.is_empty() || seen_paths.contains_key(&path) {
|
||||
continue;
|
||||
}
|
||||
seen_paths.insert(path.clone(), ());
|
||||
|
||||
let kind_tag = format!("{:?}", asset.kind);
|
||||
let mut record = if let Some(mut prior) = existing.remove(&path) {
|
||||
prior.label = asset.label.clone();
|
||||
prior.kind_tag = kind_tag;
|
||||
prior
|
||||
} else {
|
||||
AssetRecord {
|
||||
id: AssetId::new(),
|
||||
path: path.clone(),
|
||||
label: asset.label.clone(),
|
||||
kind_tag,
|
||||
import_settings: ImportSettings::default(),
|
||||
dependencies: Vec::new(),
|
||||
}
|
||||
};
|
||||
if record.kind_tag == "Model" {
|
||||
if let Err(error) = super::static_mesh::refresh_static_mesh_artifact(&mut record) {
|
||||
warn!(
|
||||
"Static mesh processor failed for {} (asset id {}): {error}",
|
||||
record.path,
|
||||
record.id.as_string()
|
||||
);
|
||||
}
|
||||
}
|
||||
next_records.push(record);
|
||||
}
|
||||
|
||||
let changed = registry.records != next_records;
|
||||
registry.records = next_records;
|
||||
if changed {
|
||||
registry.index_dirty = true;
|
||||
}
|
||||
|
||||
if registry.index_dirty {
|
||||
if let Err(error) = save_registry(®istry) {
|
||||
warn!("Asset registry save failed: {error}");
|
||||
} else {
|
||||
registry.index_dirty = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn find_asset_by_path(registry: &AssetRegistry, path: &str) -> Option<AssetRecord> {
|
||||
registry
|
||||
.records
|
||||
.iter()
|
||||
.find(|record| record.path == path)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
pub fn find_asset_by_id(registry: &AssetRegistry, id: &str) -> Option<AssetRecord> {
|
||||
registry
|
||||
.records
|
||||
.iter()
|
||||
.find(|record| record.id.as_string() == id)
|
||||
.cloned()
|
||||
}
|
||||
|
||||
pub fn find_asset_mut_by_path<'a>(
|
||||
registry: &'a mut AssetRegistry,
|
||||
path: &str,
|
||||
) -> Option<&'a mut AssetRecord> {
|
||||
registry
|
||||
.records
|
||||
.iter_mut()
|
||||
.find(|record| record.path == path)
|
||||
}
|
||||
|
||||
pub fn update_import_settings(
|
||||
registry: &mut AssetRegistry,
|
||||
path: &str,
|
||||
settings: ImportSettings,
|
||||
) -> bool {
|
||||
let Some(record) = registry
|
||||
.records
|
||||
.iter_mut()
|
||||
.find(|record| record.path == path)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
record.import_settings = settings;
|
||||
registry.index_dirty = true;
|
||||
true
|
||||
}
|
||||
|
||||
pub fn registry_index_path() -> &'static str {
|
||||
"assets/.index/registry.ron"
|
||||
}
|
||||
|
||||
pub fn save_registry(registry: &AssetRegistry) -> Result<(), String> {
|
||||
let path = registry_index_path();
|
||||
if let Some(parent) = std::path::Path::new(path).parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||
}
|
||||
let text = ron::ser::to_string_pretty(®istry.records, ron::ser::PrettyConfig::default())
|
||||
.map_err(|e| e.to_string())?;
|
||||
std::fs::write(path, text).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn load_registry() -> AssetRegistry {
|
||||
let path = registry_index_path();
|
||||
match std::fs::read_to_string(path) {
|
||||
Ok(text) => {
|
||||
let records: Vec<AssetRecord> = ron::from_str(&text).unwrap_or_default();
|
||||
AssetRegistry {
|
||||
records,
|
||||
index_dirty: false,
|
||||
}
|
||||
}
|
||||
Err(_) => AssetRegistry::default(),
|
||||
}
|
||||
}
|
||||
711
crates/editor/src/assets/catalog.rs
Normal file
711
crates/editor/src/assets/catalog.rs
Normal file
@ -0,0 +1,711 @@
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use bevy::prelude::*;
|
||||
use settings::ProjectSettings;
|
||||
use shared::{
|
||||
ActorKind, AuthoringLightKind, ColliderDesc, ColorDesc, EditorVisibility, LightDesc,
|
||||
MaterialDesc, ModelRef, PostProcessVolumeDesc, PrefabInstance, PrefabRef, Primitive,
|
||||
PrimitiveShape, RigidBodyDesc,
|
||||
};
|
||||
use walkdir::WalkDir;
|
||||
|
||||
use crate::asset_db::{
|
||||
find_asset_by_path, find_asset_mut_by_path, AssetRegistry, ModelPlacementMode,
|
||||
};
|
||||
use crate::assets::static_mesh::{
|
||||
load_static_mesh_manifest, refresh_static_mesh_artifact, renderer_from_manifest,
|
||||
};
|
||||
use crate::history::{spawn_with_history, EditorEntitySnapshot};
|
||||
|
||||
pub const BUILTINS_FOLDER: &str = "__builtins__";
|
||||
pub const ASSETS_ROOT: &str = "assets";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub enum AssetSelection {
|
||||
Builtin(String),
|
||||
File(String),
|
||||
}
|
||||
|
||||
impl AssetSelection {
|
||||
pub fn from_asset(asset: &EditorAsset) -> Self {
|
||||
match &asset.path {
|
||||
Some(path) => AssetSelection::File(path.clone()),
|
||||
None => AssetSelection::Builtin(asset.label.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum EditorAssetKind {
|
||||
Primitive(PrimitiveShape),
|
||||
Light(AuthoringLightKind),
|
||||
Model,
|
||||
Texture,
|
||||
Material,
|
||||
Level,
|
||||
Prefab,
|
||||
PostProcessVolume,
|
||||
PostProcessEffect,
|
||||
RenderingProfile,
|
||||
ShaderSchema,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EditorAsset {
|
||||
pub label: String,
|
||||
pub path: Option<String>,
|
||||
pub folder_path: String,
|
||||
pub kind: EditorAssetKind,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AssetFolder {
|
||||
pub path: String,
|
||||
pub name: String,
|
||||
pub parent: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Resource, Debug)]
|
||||
pub struct EditorAssets {
|
||||
pub folders: Vec<AssetFolder>,
|
||||
pub assets: Vec<EditorAsset>,
|
||||
pub current_folder: String,
|
||||
pub selected: Option<AssetSelection>,
|
||||
pub dragging: Option<AssetSelection>,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
impl Default for EditorAssets {
|
||||
fn default() -> Self {
|
||||
let mut assets = Self {
|
||||
folders: Vec::new(),
|
||||
assets: Vec::new(),
|
||||
current_folder: ASSETS_ROOT.to_string(),
|
||||
selected: None,
|
||||
dragging: None,
|
||||
status: "Assets not scanned yet".to_string(),
|
||||
};
|
||||
assets.refresh();
|
||||
assets
|
||||
}
|
||||
}
|
||||
|
||||
impl EditorAssets {
|
||||
pub fn refresh(&mut self) {
|
||||
self.folders.clear();
|
||||
self.assets.clear();
|
||||
|
||||
self.folders.push(AssetFolder {
|
||||
path: BUILTINS_FOLDER.to_string(),
|
||||
name: "Built-ins".to_string(),
|
||||
parent: None,
|
||||
});
|
||||
|
||||
register_folder_tree(&mut self.folders, ASSETS_ROOT);
|
||||
self.assets.extend(builtin_assets());
|
||||
scan_assets_directory(&mut self.assets);
|
||||
|
||||
if !self.folder_exists(&self.current_folder) {
|
||||
self.current_folder = ASSETS_ROOT.to_string();
|
||||
}
|
||||
|
||||
self.selected = self
|
||||
.selected
|
||||
.take()
|
||||
.filter(|selection| self.asset_for_selection(selection).is_some());
|
||||
|
||||
let folder_count = self.folders.len();
|
||||
self.status = format!(
|
||||
"Scanned {} assets in {} folders",
|
||||
self.assets.len(),
|
||||
folder_count
|
||||
);
|
||||
}
|
||||
|
||||
fn folder_exists(&self, path: &str) -> bool {
|
||||
self.folders.iter().any(|folder| folder.path == path)
|
||||
}
|
||||
|
||||
pub fn asset_for_selection(&self, selection: &AssetSelection) -> Option<&EditorAsset> {
|
||||
match selection {
|
||||
AssetSelection::Builtin(label) => self
|
||||
.assets
|
||||
.iter()
|
||||
.find(|asset| asset.path.is_none() && asset.label == *label),
|
||||
AssetSelection::File(path) => self
|
||||
.assets
|
||||
.iter()
|
||||
.find(|asset| asset.path.as_deref() == Some(path.as_str())),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn selected_asset(&self) -> Option<&EditorAsset> {
|
||||
self.selected
|
||||
.as_ref()
|
||||
.and_then(|selection| self.asset_for_selection(selection))
|
||||
}
|
||||
|
||||
pub fn dragging_asset(&self) -> Option<&EditorAsset> {
|
||||
self.dragging
|
||||
.as_ref()
|
||||
.and_then(|selection| self.asset_for_selection(selection))
|
||||
}
|
||||
|
||||
pub fn select(&mut self, selection: AssetSelection) {
|
||||
if let Some(asset) = self.asset_for_selection(&selection).cloned() {
|
||||
self.selected = Some(selection);
|
||||
self.status = format!("Selected {}", asset.label);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn start_drag(&mut self, selection: AssetSelection) {
|
||||
if self.asset_for_selection(&selection).is_some() {
|
||||
self.dragging = Some(selection);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear_drag(&mut self) {
|
||||
self.dragging = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub struct EditorAssetsPlugin;
|
||||
|
||||
impl Plugin for EditorAssetsPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<EditorAssets>()
|
||||
.add_systems(Update, refresh_assets_on_settings_change);
|
||||
}
|
||||
}
|
||||
|
||||
fn refresh_assets_on_settings_change(
|
||||
settings: Res<settings::ProjectSettings>,
|
||||
mut assets: ResMut<EditorAssets>,
|
||||
) {
|
||||
if settings.is_changed() {
|
||||
assets.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
fn builtin_assets() -> Vec<EditorAsset> {
|
||||
vec![
|
||||
EditorAsset {
|
||||
label: "Cube".to_string(),
|
||||
path: None,
|
||||
folder_path: BUILTINS_FOLDER.to_string(),
|
||||
kind: EditorAssetKind::Primitive(PrimitiveShape::Box),
|
||||
},
|
||||
EditorAsset {
|
||||
label: "Sphere".to_string(),
|
||||
path: None,
|
||||
folder_path: BUILTINS_FOLDER.to_string(),
|
||||
kind: EditorAssetKind::Primitive(PrimitiveShape::Sphere),
|
||||
},
|
||||
EditorAsset {
|
||||
label: "Ramp".to_string(),
|
||||
path: None,
|
||||
folder_path: BUILTINS_FOLDER.to_string(),
|
||||
kind: EditorAssetKind::Primitive(PrimitiveShape::Ramp),
|
||||
},
|
||||
EditorAsset {
|
||||
label: "Point Light".to_string(),
|
||||
path: None,
|
||||
folder_path: BUILTINS_FOLDER.to_string(),
|
||||
kind: EditorAssetKind::Light(AuthoringLightKind::Point),
|
||||
},
|
||||
EditorAsset {
|
||||
label: "Spot Light".to_string(),
|
||||
path: None,
|
||||
folder_path: BUILTINS_FOLDER.to_string(),
|
||||
kind: EditorAssetKind::Light(AuthoringLightKind::Spot),
|
||||
},
|
||||
EditorAsset {
|
||||
label: "Directional Light".to_string(),
|
||||
path: None,
|
||||
folder_path: BUILTINS_FOLDER.to_string(),
|
||||
kind: EditorAssetKind::Light(AuthoringLightKind::Directional),
|
||||
},
|
||||
EditorAsset {
|
||||
label: "Post Process Volume".to_string(),
|
||||
path: None,
|
||||
folder_path: BUILTINS_FOLDER.to_string(),
|
||||
kind: EditorAssetKind::PostProcessVolume,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
fn register_folder_tree(folders: &mut Vec<AssetFolder>, root: &str) {
|
||||
let normalized = normalize_path(root);
|
||||
if folders.iter().any(|folder| folder.path == normalized) {
|
||||
return;
|
||||
}
|
||||
|
||||
let parent = parent_folder_path(&normalized);
|
||||
if let Some(ref parent_path) = parent {
|
||||
if !folders.iter().any(|folder| folder.path == *parent_path) {
|
||||
register_folder_tree(folders, parent_path);
|
||||
}
|
||||
}
|
||||
|
||||
folders.push(AssetFolder {
|
||||
name: folder_name(&normalized),
|
||||
path: normalized.clone(),
|
||||
parent,
|
||||
});
|
||||
|
||||
if Path::new(&normalized).is_dir() {
|
||||
let Ok(read_dir) = fs::read_dir(&normalized) else {
|
||||
return;
|
||||
};
|
||||
for entry in read_dir.flatten() {
|
||||
if entry.path().is_dir() {
|
||||
register_folder_tree(folders, &entry.path().to_string_lossy());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn scan_assets_directory(assets: &mut Vec<EditorAsset>) {
|
||||
let root_path = Path::new(ASSETS_ROOT);
|
||||
if !root_path.exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
for entry in WalkDir::new(root_path)
|
||||
.follow_links(false)
|
||||
.into_iter()
|
||||
.filter_map(Result::ok)
|
||||
{
|
||||
if !entry.file_type().is_file() {
|
||||
continue;
|
||||
}
|
||||
let path = entry.path();
|
||||
if should_skip_asset_path(path) {
|
||||
continue;
|
||||
}
|
||||
if let Some(asset) = asset_from_file_path(path) {
|
||||
assets.push(asset);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn should_skip_asset_path(path: &Path) -> bool {
|
||||
if path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| name.starts_with('.'))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
let normalized = normalize_asset_path(path);
|
||||
normalized == "assets/project.ron"
|
||||
|| normalized.starts_with("assets/.index/")
|
||||
|| normalized.starts_with(crate::assets::static_mesh::STATIC_MESH_ARTIFACT_DIR)
|
||||
}
|
||||
|
||||
fn asset_from_file_path(path: &Path) -> Option<EditorAsset> {
|
||||
let extension = path.extension()?.to_str()?.to_ascii_lowercase();
|
||||
let (kinds, extensions) = match extension.as_str() {
|
||||
"gltf" | "glb" | "fbx" => (vec![EditorAssetKind::Model], vec!["gltf", "glb", "fbx"]),
|
||||
"png" | "jpg" | "jpeg" | "webp" | "ktx2" => (
|
||||
vec![EditorAssetKind::Texture],
|
||||
vec!["png", "jpg", "jpeg", "webp", "ktx2"],
|
||||
),
|
||||
"ron" | "mat" | "material" => {
|
||||
if path.to_string_lossy().contains("/materials/") {
|
||||
(
|
||||
vec![EditorAssetKind::Material],
|
||||
vec!["ron", "mat", "material"],
|
||||
)
|
||||
} else if path.to_string_lossy().contains("/post_fx/") {
|
||||
(vec![EditorAssetKind::PostProcessEffect], vec!["ron"])
|
||||
} else if path.to_string_lossy().contains("/rendering_profiles/") {
|
||||
(vec![EditorAssetKind::RenderingProfile], vec!["ron"])
|
||||
} else if path.to_string_lossy().contains("/shaders/") {
|
||||
(vec![EditorAssetKind::ShaderSchema], vec!["ron"])
|
||||
} else {
|
||||
(
|
||||
vec![EditorAssetKind::Level, EditorAssetKind::Prefab],
|
||||
vec!["ron"],
|
||||
)
|
||||
}
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
if !has_extension(path, &extensions) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let path_string = normalize_asset_path(path);
|
||||
let folder_path = parent_folder_path(&path_string).unwrap_or_else(|| ASSETS_ROOT.to_string());
|
||||
let label = path
|
||||
.file_stem()
|
||||
.and_then(|stem| stem.to_str())
|
||||
.unwrap_or(&path_string)
|
||||
.to_string();
|
||||
let kind = if kinds.len() == 2 && path_string.ends_with(".scn.ron") {
|
||||
kinds[1].clone()
|
||||
} else {
|
||||
kinds[0].clone()
|
||||
};
|
||||
|
||||
Some(EditorAsset {
|
||||
label,
|
||||
path: Some(path_string),
|
||||
folder_path,
|
||||
kind,
|
||||
})
|
||||
}
|
||||
|
||||
fn normalize_path(path: &str) -> String {
|
||||
path.replace('\\', "/").trim_end_matches('/').to_string()
|
||||
}
|
||||
|
||||
fn normalize_asset_path(path: &Path) -> String {
|
||||
let raw = path.to_string_lossy().replace('\\', "/");
|
||||
raw.strip_prefix("assets/")
|
||||
.or_else(|| raw.strip_prefix("./assets/"))
|
||||
.map(|rest| format!("assets/{rest}"))
|
||||
.unwrap_or(raw)
|
||||
}
|
||||
|
||||
/// Path relative to Bevy's `assets/` folder for [`AssetServer::load`].
|
||||
pub fn asset_server_path(catalog_path: &str) -> String {
|
||||
shared::asset_server_path(catalog_path)
|
||||
}
|
||||
|
||||
fn parent_folder_path(path: &str) -> Option<String> {
|
||||
let normalized = normalize_path(path);
|
||||
normalized
|
||||
.rfind('/')
|
||||
.map(|index| normalized[..index].to_string())
|
||||
}
|
||||
|
||||
fn folder_name(path: &str) -> String {
|
||||
Path::new(path)
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or(path)
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn has_extension(path: &Path, extensions: &[&str]) -> bool {
|
||||
path.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.is_some_and(|ext| {
|
||||
extensions
|
||||
.iter()
|
||||
.any(|candidate| ext.eq_ignore_ascii_case(candidate))
|
||||
})
|
||||
}
|
||||
|
||||
pub fn snapshot_for_asset(asset: &EditorAsset, translation: Vec3) -> Option<EditorEntitySnapshot> {
|
||||
let mut snapshot = EditorEntitySnapshot {
|
||||
actor_id: None,
|
||||
actor_kind: ActorKind::Empty,
|
||||
actor_name: None,
|
||||
name: Some(asset.label.clone()),
|
||||
transform: Transform::from_translation(translation),
|
||||
primitive: None,
|
||||
static_mesh_renderer: None,
|
||||
material: None,
|
||||
material_override: None,
|
||||
rigid_body: None,
|
||||
collider: None,
|
||||
physics: None,
|
||||
light: None,
|
||||
player_spawn: false,
|
||||
model: None,
|
||||
prefab: None,
|
||||
prefab_instance: None,
|
||||
weapon_spawn: None,
|
||||
trigger_volume: None,
|
||||
post_process_volume: None,
|
||||
team_spawn: None,
|
||||
objective: None,
|
||||
hierarchy_sibling_index: 0,
|
||||
editor_visibility: EditorVisibility::default(),
|
||||
children: Vec::new(),
|
||||
};
|
||||
|
||||
match &asset.kind {
|
||||
EditorAssetKind::Primitive(shape) => {
|
||||
snapshot.actor_kind = ActorKind::StaticMesh;
|
||||
let size = match shape {
|
||||
PrimitiveShape::Box => Vec3::ONE,
|
||||
PrimitiveShape::Sphere => Vec3::splat(1.5),
|
||||
PrimitiveShape::Ramp => Vec3::new(4.0, 0.4, 5.0),
|
||||
};
|
||||
snapshot.primitive = Some(match shape {
|
||||
PrimitiveShape::Box => Primitive::cuboid(size),
|
||||
PrimitiveShape::Sphere => Primitive::sphere(size.x * 0.5),
|
||||
PrimitiveShape::Ramp => Primitive::ramp(size),
|
||||
});
|
||||
snapshot.material = Some(default_material_for(shape));
|
||||
snapshot.rigid_body = Some(RigidBodyDesc::default());
|
||||
snapshot.collider = Some(match shape {
|
||||
PrimitiveShape::Sphere => ColliderDesc::static_sphere(size.x * 0.5),
|
||||
_ => ColliderDesc::static_cuboid(size),
|
||||
});
|
||||
if matches!(shape, PrimitiveShape::Ramp) {
|
||||
snapshot.transform.rotation = Quat::from_rotation_x(-std::f32::consts::PI / 9.0);
|
||||
snapshot.transform.translation.y += 1.0;
|
||||
} else {
|
||||
snapshot.transform.translation.y += size.y * 0.5;
|
||||
}
|
||||
}
|
||||
EditorAssetKind::Light(kind) => {
|
||||
snapshot.actor_kind = ActorKind::Light;
|
||||
snapshot.light = Some(LightDesc::for_kind(*kind));
|
||||
snapshot.transform.translation.y += 3.0;
|
||||
}
|
||||
EditorAssetKind::PostProcessVolume => {
|
||||
snapshot.actor_kind = ActorKind::PostProcessVolume;
|
||||
snapshot.post_process_volume = Some(PostProcessVolumeDesc {
|
||||
half_extents: Vec3::new(4.0, 2.0, 4.0),
|
||||
..Default::default()
|
||||
});
|
||||
snapshot.transform.translation.y += 2.0;
|
||||
}
|
||||
EditorAssetKind::Model => {
|
||||
snapshot.actor_kind = ActorKind::ImportedModel;
|
||||
let path = asset.path.as_ref()?;
|
||||
snapshot.model = Some(ModelRef::new(path.clone()));
|
||||
snapshot.transform.translation.y += 0.1;
|
||||
}
|
||||
EditorAssetKind::Prefab | EditorAssetKind::Level => {
|
||||
snapshot.actor_kind = ActorKind::PrefabAnchor;
|
||||
let path = asset.path.as_ref()?;
|
||||
snapshot.prefab = Some(PrefabRef::new(path.clone()));
|
||||
}
|
||||
EditorAssetKind::Texture
|
||||
| EditorAssetKind::Material
|
||||
| EditorAssetKind::PostProcessEffect
|
||||
| EditorAssetKind::RenderingProfile
|
||||
| EditorAssetKind::ShaderSchema => return None,
|
||||
}
|
||||
|
||||
Some(snapshot)
|
||||
}
|
||||
|
||||
pub fn spawn_asset_at(world: &mut World, asset: &EditorAsset, translation: Vec3) -> Option<Entity> {
|
||||
let mut snapshot = snapshot_for_asset(asset, translation)?;
|
||||
if let Some(light) = snapshot.light.as_mut() {
|
||||
if matches!(light.kind, AuthoringLightKind::Directional) {
|
||||
light.intensity = world
|
||||
.resource::<ProjectSettings>()
|
||||
.rendering
|
||||
.sun_illuminance;
|
||||
}
|
||||
}
|
||||
if let Some(path) = asset.path.as_ref() {
|
||||
let record = world
|
||||
.get_resource::<AssetRegistry>()
|
||||
.and_then(|registry| find_asset_by_path(registry, path));
|
||||
if let Some(record) = record {
|
||||
match asset.kind {
|
||||
EditorAssetKind::Model => {
|
||||
snapshot.transform.scale *= record.import_settings.scale;
|
||||
if matches!(
|
||||
record.import_settings.placement_mode,
|
||||
ModelPlacementMode::StaticAsset
|
||||
) {
|
||||
if let Some(renderer) = static_mesh_renderer_for_asset(world, path) {
|
||||
let collider = record
|
||||
.import_settings
|
||||
.generate_collider
|
||||
.then(|| static_mesh_collider_for_renderer(&renderer));
|
||||
apply_static_mesh_placement_mode(
|
||||
&mut snapshot,
|
||||
renderer,
|
||||
collider,
|
||||
record.import_settings.hierarchy_mode,
|
||||
);
|
||||
} else {
|
||||
warn!(
|
||||
"Static mesh placement fell back to SceneRoot for {} because no renderer manifest was available",
|
||||
path
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
EditorAssetKind::Prefab => {
|
||||
snapshot.prefab_instance =
|
||||
Some(PrefabInstance::new(record.id.as_string(), path.clone()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(spawn_with_history(world, snapshot))
|
||||
}
|
||||
|
||||
fn apply_static_mesh_placement_mode(
|
||||
snapshot: &mut EditorEntitySnapshot,
|
||||
renderer: shared::StaticMeshRenderer,
|
||||
collider: Option<ColliderDesc>,
|
||||
hierarchy_mode: crate::asset_db::ModelHierarchyMode,
|
||||
) {
|
||||
snapshot.model = None;
|
||||
match hierarchy_mode {
|
||||
crate::asset_db::ModelHierarchyMode::SingleActor => {
|
||||
snapshot.actor_kind = ActorKind::StaticMesh;
|
||||
snapshot.static_mesh_renderer = Some(renderer);
|
||||
if let Some(collider) = collider {
|
||||
snapshot.rigid_body = Some(RigidBodyDesc::default());
|
||||
snapshot.collider = Some(collider);
|
||||
}
|
||||
}
|
||||
crate::asset_db::ModelHierarchyMode::SourceHierarchy => {
|
||||
snapshot.actor_kind = ActorKind::Empty;
|
||||
snapshot.static_mesh_renderer = None;
|
||||
snapshot.children = renderer
|
||||
.slots
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, mut entry)| {
|
||||
let transform = entry.local_transform;
|
||||
let collider = collider
|
||||
.as_ref()
|
||||
.map(|_| ColliderDesc::static_mesh(vec![entry.mesh.clone()]));
|
||||
entry.local_transform = Transform::default();
|
||||
EditorEntitySnapshot {
|
||||
actor_id: None,
|
||||
actor_kind: ActorKind::StaticMesh,
|
||||
actor_name: None,
|
||||
name: Some(if entry.name.trim().is_empty() {
|
||||
format!("Mesh Part {index}")
|
||||
} else {
|
||||
entry.name.clone()
|
||||
}),
|
||||
transform,
|
||||
primitive: None,
|
||||
static_mesh_renderer: Some(shared::StaticMeshRenderer::single(entry)),
|
||||
material: None,
|
||||
material_override: None,
|
||||
rigid_body: collider.as_ref().map(|_| RigidBodyDesc::default()),
|
||||
collider,
|
||||
physics: None,
|
||||
light: None,
|
||||
player_spawn: false,
|
||||
model: None,
|
||||
prefab: None,
|
||||
prefab_instance: None,
|
||||
weapon_spawn: None,
|
||||
trigger_volume: None,
|
||||
post_process_volume: None,
|
||||
team_spawn: None,
|
||||
objective: None,
|
||||
hierarchy_sibling_index: index as i32,
|
||||
editor_visibility: EditorVisibility::default(),
|
||||
children: Vec::new(),
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn static_mesh_collider_for_renderer(renderer: &shared::StaticMeshRenderer) -> ColliderDesc {
|
||||
ColliderDesc::static_mesh(
|
||||
renderer
|
||||
.slots
|
||||
.iter()
|
||||
.map(|slot| slot.mesh.clone())
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
fn static_mesh_renderer_for_asset(
|
||||
world: &mut World,
|
||||
path: &str,
|
||||
) -> Option<shared::StaticMeshRenderer> {
|
||||
let mut manifest_path = None;
|
||||
if let Some(mut registry) = world.get_resource_mut::<AssetRegistry>() {
|
||||
let mut renderer = None;
|
||||
if let Some(record) = find_asset_mut_by_path(&mut registry, path) {
|
||||
match refresh_static_mesh_artifact(record) {
|
||||
Ok(manifest) => {
|
||||
renderer = Some(renderer_from_manifest(&manifest, &record.import_settings));
|
||||
registry.index_dirty = true;
|
||||
}
|
||||
Err(error) => {
|
||||
warn!("Static mesh manifest refresh failed for {path}: {error}");
|
||||
manifest_path = record.import_settings.static_mesh_manifest_path.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
if renderer.is_some() {
|
||||
if let Err(error) = crate::asset_db::save_registry(®istry) {
|
||||
warn!("Asset registry save failed after static mesh refresh: {error}");
|
||||
} else {
|
||||
registry.index_dirty = false;
|
||||
}
|
||||
return renderer;
|
||||
}
|
||||
}
|
||||
|
||||
let manifest_path = manifest_path?;
|
||||
let registry = world.get_resource::<AssetRegistry>()?;
|
||||
let record = find_asset_by_path(registry, path)?;
|
||||
let manifest = load_static_mesh_manifest(&manifest_path).ok()?;
|
||||
Some(renderer_from_manifest(&manifest, &record.import_settings))
|
||||
}
|
||||
|
||||
pub fn import_external_assets(paths: &[PathBuf]) -> Result<usize, String> {
|
||||
let mut copied = 0;
|
||||
for source in paths {
|
||||
let Some(file_name) = source.file_name() else {
|
||||
continue;
|
||||
};
|
||||
let Some(extension) = source.extension().and_then(|ext| ext.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
let dest_dir = import_dir_for_extension(extension);
|
||||
if extension.eq_ignore_ascii_case("fbx") {
|
||||
super::import::copy_fbx_with_sidecar(source, Path::new(dest_dir))?;
|
||||
copied += 1;
|
||||
continue;
|
||||
}
|
||||
fs::create_dir_all(dest_dir)
|
||||
.map_err(|err| format!("could not create {dest_dir}: {err}"))?;
|
||||
let dest = Path::new(dest_dir).join(file_name);
|
||||
fs::copy(source, &dest).map_err(|err| {
|
||||
format!(
|
||||
"could not copy {} to {}: {err}",
|
||||
source.display(),
|
||||
dest.display()
|
||||
)
|
||||
})?;
|
||||
copied += 1;
|
||||
}
|
||||
Ok(copied)
|
||||
}
|
||||
|
||||
fn import_dir_for_extension(extension: &str) -> &'static str {
|
||||
match extension.to_ascii_lowercase().as_str() {
|
||||
"gltf" | "glb" | "fbx" => "assets/models",
|
||||
"png" | "jpg" | "jpeg" | "webp" | "ktx2" => "assets/textures",
|
||||
"ron" => "assets/levels",
|
||||
"mat" | "material" => "assets/materials",
|
||||
_ => "assets",
|
||||
}
|
||||
}
|
||||
|
||||
fn default_material_for(shape: &PrimitiveShape) -> MaterialDesc {
|
||||
match shape {
|
||||
PrimitiveShape::Box => MaterialDesc::default(),
|
||||
PrimitiveShape::Sphere => MaterialDesc::new(ColorDesc::srgb(0.9, 0.9, 0.92), 1.0, 0.25),
|
||||
PrimitiveShape::Ramp => MaterialDesc::new(ColorDesc::srgb(0.30, 0.45, 0.70), 0.05, 0.55),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn asset_cache_key(asset: &EditorAsset) -> String {
|
||||
match &asset.path {
|
||||
Some(path) => path.clone(),
|
||||
None => format!("builtin:{}", asset.label),
|
||||
}
|
||||
}
|
||||
71
crates/editor/src/assets/import.rs
Normal file
71
crates/editor/src/assets/import.rs
Normal file
@ -0,0 +1,71 @@
|
||||
//! External asset import helpers (FBX sidecar folders, etc.).
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Copies an FBX file and its sibling `.fbm` embedded-texture folder when present.
|
||||
pub fn copy_fbx_with_sidecar(source: &Path, dest_dir: &Path) -> Result<(), String> {
|
||||
let Some(file_name) = source.file_name() else {
|
||||
return Err("FBX import path has no file name".into());
|
||||
};
|
||||
fs::create_dir_all(dest_dir)
|
||||
.map_err(|err| format!("could not create {}: {err}", dest_dir.display()))?;
|
||||
let dest = dest_dir.join(file_name);
|
||||
fs::copy(source, &dest).map_err(|err| {
|
||||
format!(
|
||||
"could not copy {} to {}: {err}",
|
||||
source.display(),
|
||||
dest.display()
|
||||
)
|
||||
})?;
|
||||
|
||||
if let Some(fbm_dir) = fbm_sidecar_dir(source) {
|
||||
if fbm_dir.is_dir() {
|
||||
let dest_fbm = dest_dir.join(fbm_dir.file_name().unwrap_or_default());
|
||||
copy_dir_recursive(&fbm_dir, &dest_fbm)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn fbm_sidecar_dir(source: &Path) -> Option<PathBuf> {
|
||||
let stem = source.file_stem()?.to_str()?;
|
||||
let parent = source.parent()?;
|
||||
Some(parent.join(format!("{stem}.fbm")))
|
||||
}
|
||||
|
||||
fn copy_dir_recursive(source: &Path, dest: &Path) -> Result<(), String> {
|
||||
fs::create_dir_all(dest)
|
||||
.map_err(|err| format!("could not create {}: {err}", dest.display()))?;
|
||||
for entry in fs::read_dir(source).map_err(|err| err.to_string())? {
|
||||
let entry = entry.map_err(|err| err.to_string())?;
|
||||
let src_path = entry.path();
|
||||
let dest_path = dest.join(entry.file_name());
|
||||
if src_path.is_dir() {
|
||||
copy_dir_recursive(&src_path, &dest_path)?;
|
||||
} else {
|
||||
fs::copy(&src_path, &dest_path).map_err(|err| {
|
||||
format!(
|
||||
"could not copy {} to {}: {err}",
|
||||
src_path.display(),
|
||||
dest_path.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::fbm_sidecar_dir;
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
fn fbm_sidecar_path() {
|
||||
assert_eq!(
|
||||
fbm_sidecar_dir(Path::new("/tmp/Character.fbx")),
|
||||
Some(Path::new("/tmp/Character.fbm").to_path_buf())
|
||||
);
|
||||
}
|
||||
}
|
||||
101
crates/editor/src/assets/materials.rs
Normal file
101
crates/editor/src/assets/materials.rs
Normal file
@ -0,0 +1,101 @@
|
||||
//! Material asset loading and inspector/browser integration.
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy_egui::egui;
|
||||
use shared::{LevelObject, MaterialAsset, MaterialDesc};
|
||||
|
||||
use crate::assets::{EditorAsset, EditorAssetKind, EditorAssets};
|
||||
use crate::history::set_material_with_history;
|
||||
use crate::ui::inspector::property_row;
|
||||
|
||||
pub fn load_material_asset(catalog_path: &str) -> Result<MaterialAsset, String> {
|
||||
MaterialAsset::load_from_path(catalog_path)
|
||||
}
|
||||
|
||||
pub fn material_desc_from_asset(catalog_path: &str) -> Result<MaterialDesc, String> {
|
||||
let asset = load_material_asset(catalog_path)?;
|
||||
let mut desc = asset.material;
|
||||
desc.material_asset_path = Some(catalog_path.to_string());
|
||||
Ok(desc)
|
||||
}
|
||||
|
||||
pub fn apply_material_asset_to_selection(
|
||||
world: &mut World,
|
||||
asset: &EditorAsset,
|
||||
selected: &bevy_inspector_egui::bevy_inspector::hierarchy::SelectedEntities,
|
||||
) {
|
||||
let Some(path) = asset.path.clone() else {
|
||||
return;
|
||||
};
|
||||
let desc = match material_desc_from_asset(&path) {
|
||||
Ok(desc) => desc,
|
||||
Err(err) => {
|
||||
world.resource_mut::<crate::scene_io::SceneIo>().status = err;
|
||||
return;
|
||||
}
|
||||
};
|
||||
for entity in crate::ui::helpers::selected_level_entities(world, selected) {
|
||||
if world.get::<LevelObject>(entity).is_none() {
|
||||
continue;
|
||||
}
|
||||
if world.get::<MaterialDesc>(entity).is_none() {
|
||||
world.entity_mut(entity).insert(desc.clone());
|
||||
}
|
||||
set_material_with_history(world, entity, desc.clone());
|
||||
}
|
||||
world.resource_mut::<crate::scene_io::SceneIo>().status =
|
||||
format!("Applied material {}", asset.label);
|
||||
}
|
||||
|
||||
pub fn material_asset_picker_ui(
|
||||
world: &mut World,
|
||||
ui: &mut egui::Ui,
|
||||
_entity: Entity,
|
||||
material: &mut MaterialDesc,
|
||||
_original: &MaterialDesc,
|
||||
) -> bool {
|
||||
let assets: Vec<(String, String)> = world
|
||||
.resource::<EditorAssets>()
|
||||
.assets
|
||||
.iter()
|
||||
.filter(|a| matches!(a.kind, EditorAssetKind::Material))
|
||||
.filter_map(|a| Some((a.label.clone(), a.path.clone()?)))
|
||||
.collect();
|
||||
|
||||
let current = material.material_asset_path.clone().unwrap_or_default();
|
||||
let mut selected_label = assets
|
||||
.iter()
|
||||
.find(|(_, path)| path == ¤t)
|
||||
.map(|(label, _)| label.clone())
|
||||
.unwrap_or_else(|| "(none)".to_string());
|
||||
|
||||
let mut changed = false;
|
||||
property_row(ui, "Material asset", |ui| {
|
||||
egui::ComboBox::from_id_salt("material_asset_picker")
|
||||
.selected_text(&selected_label)
|
||||
.show_ui(ui, |ui| {
|
||||
if ui.selectable_label(current.is_empty(), "(none)").clicked() {
|
||||
material.material_asset_path = None;
|
||||
selected_label = "(none)".into();
|
||||
changed = true;
|
||||
}
|
||||
for (label, path) in &assets {
|
||||
if ui
|
||||
.selectable_label(&selected_label == label, label)
|
||||
.clicked()
|
||||
{
|
||||
if let Ok(desc) = material_desc_from_asset(path) {
|
||||
*material = desc;
|
||||
selected_label = label.clone();
|
||||
changed = true;
|
||||
} else {
|
||||
world.resource_mut::<crate::scene_io::SceneIo>().status =
|
||||
format!("Could not load material {path}");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
changed
|
||||
}
|
||||
15
crates/editor/src/assets/mod.rs
Normal file
15
crates/editor/src/assets/mod.rs
Normal file
@ -0,0 +1,15 @@
|
||||
//! Asset catalog, database, thumbnails, materials, and prefab overrides.
|
||||
|
||||
pub mod asset_db;
|
||||
mod catalog;
|
||||
mod import;
|
||||
pub mod materials;
|
||||
pub mod prefab_overrides;
|
||||
pub mod static_mesh;
|
||||
pub mod thumbnails;
|
||||
|
||||
pub use catalog::*;
|
||||
pub use thumbnails::{
|
||||
draw_asset_cell_with, invalidate_on_catalog_refresh, kind_icon, prefetch_folder_thumbnails,
|
||||
AssetThumbnailCache, ThumbnailCacheSnapshot, ThumbnailState, ThumbnailsPlugin,
|
||||
};
|
||||
390
crates/editor/src/assets/prefab_overrides.rs
Normal file
390
crates/editor/src/assets/prefab_overrides.rs
Normal file
@ -0,0 +1,390 @@
|
||||
//! Prefab instance overrides — apply/revert per field group (v2).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy_egui::egui;
|
||||
use egui_phosphor_icons::icons;
|
||||
use scene::{migrate_scene_text, strip_schema_version, validate_level_text};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use shared::{EditorVisibility, MaterialDesc, PrefabInstance};
|
||||
|
||||
use crate::history::{set_material_with_history, set_transform_with_history};
|
||||
use crate::ui::inspector::{
|
||||
apply_component_card_response, component_card, component_card_context, property_row,
|
||||
ComponentCardOptions,
|
||||
};
|
||||
|
||||
#[derive(Resource, Default)]
|
||||
pub struct PrefabOverrideClipboard {
|
||||
pub property_path: String,
|
||||
pub value_ron: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
|
||||
pub struct PrefabOverrides {
|
||||
#[serde(default)]
|
||||
pub transform: Option<Transform>,
|
||||
#[serde(default)]
|
||||
pub material: Option<MaterialDesc>,
|
||||
#[serde(default)]
|
||||
pub child_visibility: HashMap<String, bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PrefabOverrideField {
|
||||
Transform,
|
||||
Material,
|
||||
ChildVisibility,
|
||||
}
|
||||
|
||||
pub fn read_overrides(instance: &PrefabInstance) -> PrefabOverrides {
|
||||
instance
|
||||
.overrides_ron
|
||||
.as_ref()
|
||||
.and_then(|ron| ron::from_str(ron).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn write_overrides(world: &mut World, entity: Entity, overrides: &PrefabOverrides) {
|
||||
let Some(mut instance) = world.get::<PrefabInstance>(entity).cloned() else {
|
||||
return;
|
||||
};
|
||||
instance.overrides_ron = if overrides == &PrefabOverrides::default() {
|
||||
None
|
||||
} else {
|
||||
Some(ron::to_string(overrides).unwrap_or_default())
|
||||
};
|
||||
if let Ok(mut entity_mut) = world.get_entity_mut(entity) {
|
||||
entity_mut.insert(instance);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn has_override(overrides: &PrefabOverrides, field: PrefabOverrideField) -> bool {
|
||||
match field {
|
||||
PrefabOverrideField::Transform => overrides.transform.is_some(),
|
||||
PrefabOverrideField::Material => overrides.material.is_some(),
|
||||
PrefabOverrideField::ChildVisibility => !overrides.child_visibility.is_empty(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn capture_transform_override(world: &mut World, entity: Entity) {
|
||||
let Some(transform) = world.get::<Transform>(entity).copied() else {
|
||||
return;
|
||||
};
|
||||
let Some(instance) = world.get::<PrefabInstance>(entity) else {
|
||||
return;
|
||||
};
|
||||
let mut overrides = read_overrides(instance);
|
||||
overrides.transform = Some(transform);
|
||||
write_overrides(world, entity, &overrides);
|
||||
}
|
||||
|
||||
pub fn revert_transform_override(world: &mut World, entity: Entity) {
|
||||
let Some(instance) = world.get::<PrefabInstance>(entity).cloned() else {
|
||||
return;
|
||||
};
|
||||
let Some(old) = world.get::<Transform>(entity).copied() else {
|
||||
return;
|
||||
};
|
||||
let Some(source_transform) = prefab_root_transform(&instance.source_path) else {
|
||||
world.resource_mut::<crate::scene_io::SceneIo>().status =
|
||||
"Could not read prefab source for revert".into();
|
||||
return;
|
||||
};
|
||||
set_transform_with_history(world, entity, old, source_transform);
|
||||
let mut overrides = read_overrides(&instance);
|
||||
overrides.transform = None;
|
||||
write_overrides(world, entity, &overrides);
|
||||
}
|
||||
|
||||
pub fn apply_material_override(world: &mut World, entity: Entity) {
|
||||
let Some(instance) = world.get::<PrefabInstance>(entity).cloned() else {
|
||||
return;
|
||||
};
|
||||
let Some(material) = world.get::<MaterialDesc>(entity).cloned() else {
|
||||
world.resource_mut::<crate::scene_io::SceneIo>().status =
|
||||
"Prefab instance has no MaterialDesc to capture".into();
|
||||
return;
|
||||
};
|
||||
let mut overrides = read_overrides(&instance);
|
||||
overrides.material = Some(material);
|
||||
write_overrides(world, entity, &overrides);
|
||||
world.resource_mut::<crate::scene_io::SceneIo>().status = "Material override applied".into();
|
||||
}
|
||||
|
||||
pub fn revert_material_override(world: &mut World, entity: Entity) {
|
||||
let Some(instance) = world.get::<PrefabInstance>(entity).cloned() else {
|
||||
return;
|
||||
};
|
||||
let Some(source_material) = prefab_root_material(&instance.source_path) else {
|
||||
world.resource_mut::<crate::scene_io::SceneIo>().status =
|
||||
"Could not read prefab source material".into();
|
||||
return;
|
||||
};
|
||||
let old = world.get::<MaterialDesc>(entity).cloned();
|
||||
if old.is_some() {
|
||||
set_material_with_history(world, entity, source_material.clone());
|
||||
} else {
|
||||
world.entity_mut(entity).insert(source_material.clone());
|
||||
}
|
||||
let mut overrides = read_overrides(&instance);
|
||||
overrides.material = None;
|
||||
write_overrides(world, entity, &overrides);
|
||||
}
|
||||
|
||||
pub fn apply_child_visibility_override(world: &mut World, entity: Entity, child: Entity) {
|
||||
let Some(instance) = world.get::<PrefabInstance>(entity).cloned() else {
|
||||
return;
|
||||
};
|
||||
let Some(name) = world.get::<Name>(child).map(|n| n.to_string()) else {
|
||||
return;
|
||||
};
|
||||
let visible = world
|
||||
.get::<EditorVisibility>(child)
|
||||
.map(|v| v.visible)
|
||||
.unwrap_or(true);
|
||||
|
||||
let mut overrides = read_overrides(&instance);
|
||||
overrides.child_visibility.insert(name, visible);
|
||||
write_overrides(world, entity, &overrides);
|
||||
}
|
||||
|
||||
pub fn revert_child_visibility_overrides(world: &mut World, entity: Entity) {
|
||||
let Some(instance) = world.get::<PrefabInstance>(entity).cloned() else {
|
||||
return;
|
||||
};
|
||||
for child in prefab_instance_children(world, entity) {
|
||||
if let Ok(mut entity_mut) = world.get_entity_mut(child) {
|
||||
entity_mut.remove::<EditorVisibility>();
|
||||
}
|
||||
}
|
||||
let mut overrides = read_overrides(&instance);
|
||||
overrides.child_visibility.clear();
|
||||
write_overrides(world, entity, &overrides);
|
||||
}
|
||||
|
||||
pub fn apply_stored_overrides(world: &mut World, entity: Entity) {
|
||||
let Some(instance) = world.get::<PrefabInstance>(entity).cloned() else {
|
||||
return;
|
||||
};
|
||||
let overrides = read_overrides(&instance);
|
||||
|
||||
if let Some(transform) = overrides.transform {
|
||||
if let Ok(mut entity_mut) = world.get_entity_mut(entity) {
|
||||
entity_mut.insert(transform);
|
||||
}
|
||||
}
|
||||
if let Some(material) = overrides.material {
|
||||
if let Ok(mut entity_mut) = world.get_entity_mut(entity) {
|
||||
entity_mut.insert(material);
|
||||
}
|
||||
}
|
||||
for child in prefab_instance_children(world, entity) {
|
||||
let Some(name) = world.get::<Name>(child).map(|n| n.to_string()) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(&visible) = overrides.child_visibility.get(&name) {
|
||||
if let Ok(mut entity_mut) = world.get_entity_mut(child) {
|
||||
entity_mut.insert(EditorVisibility { visible });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn prefab_instance_inspector_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) {
|
||||
let Some(instance) = world.get::<PrefabInstance>(entity).cloned() else {
|
||||
return;
|
||||
};
|
||||
let overrides = read_overrides(&instance);
|
||||
|
||||
let card = component_card_context(
|
||||
world,
|
||||
entity,
|
||||
ComponentCardOptions::removable(
|
||||
shared::COMPONENT_PREFAB_INSTANCE,
|
||||
"Prefab Instance",
|
||||
icons::PACKAGE,
|
||||
),
|
||||
);
|
||||
let card_response = component_card(ui, &card, |ui| {
|
||||
property_row(ui, "Asset ID", |ui| {
|
||||
ui.add(egui::Label::new(instance.asset_id.as_str()).truncate());
|
||||
});
|
||||
property_row(ui, "Source", |ui| {
|
||||
ui.add(egui::Label::new(instance.source_path.as_str()).truncate());
|
||||
});
|
||||
|
||||
ui.collapsing("Transform override", |ui| {
|
||||
let active = has_override(&overrides, PrefabOverrideField::Transform);
|
||||
if active {
|
||||
ui.label(egui::RichText::new("Override active").weak());
|
||||
}
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
if ui.button("Apply").clicked() {
|
||||
capture_transform_override(world, entity);
|
||||
world.resource_mut::<crate::scene_io::SceneIo>().status =
|
||||
"Transform override saved".into();
|
||||
}
|
||||
if ui
|
||||
.add_enabled(active, egui::Button::new("Revert"))
|
||||
.clicked()
|
||||
{
|
||||
revert_transform_override(world, entity);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ui.collapsing("Material override", |ui| {
|
||||
let active = has_override(&overrides, PrefabOverrideField::Material);
|
||||
if active {
|
||||
ui.label(egui::RichText::new("Override active").weak());
|
||||
}
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
if ui.button("Apply").clicked() {
|
||||
apply_material_override(world, entity);
|
||||
}
|
||||
if ui
|
||||
.add_enabled(active, egui::Button::new("Revert"))
|
||||
.clicked()
|
||||
{
|
||||
revert_material_override(world, entity);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ui.collapsing("Child visibility overrides", |ui| {
|
||||
let active = has_override(&overrides, PrefabOverrideField::ChildVisibility);
|
||||
if active {
|
||||
ui.label(egui::RichText::new("Overrides stored by child name").weak());
|
||||
}
|
||||
for child in prefab_instance_children(world, entity) {
|
||||
let name = world
|
||||
.get::<Name>(child)
|
||||
.map(|n| n.to_string())
|
||||
.unwrap_or_else(|| format!("{child:?}"));
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.add(egui::Label::new(name).truncate());
|
||||
if ui.small_button("Apply").clicked() {
|
||||
apply_child_visibility_override(world, entity, child);
|
||||
}
|
||||
});
|
||||
}
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
if ui
|
||||
.add_enabled(active, egui::Button::new("Revert all"))
|
||||
.clicked()
|
||||
{
|
||||
revert_child_visibility_overrides(world, entity);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
if ui.button("Unpack Instance").clicked() {
|
||||
crate::history::unpack_prefab_instance(world, entity);
|
||||
}
|
||||
});
|
||||
});
|
||||
apply_component_card_response(world, entity, card_response);
|
||||
}
|
||||
|
||||
fn prefab_instance_children(world: &World, root: Entity) -> Vec<Entity> {
|
||||
let mut children = Vec::new();
|
||||
let mut stack = Vec::new();
|
||||
if let Some(kids) = world.get::<Children>(root) {
|
||||
stack.extend(kids.iter());
|
||||
}
|
||||
while let Some(entity) = stack.pop() {
|
||||
children.push(entity);
|
||||
if let Some(kids) = world.get::<Children>(entity) {
|
||||
stack.extend(kids.iter());
|
||||
}
|
||||
}
|
||||
children
|
||||
}
|
||||
|
||||
fn prefab_root_transform(source_path: &str) -> Option<Transform> {
|
||||
parse_prefab_root_component::<Transform>(source_path, "Transform")
|
||||
}
|
||||
|
||||
fn prefab_root_material(source_path: &str) -> Option<MaterialDesc> {
|
||||
parse_prefab_root_component::<MaterialDesc>(source_path, "MaterialDesc")
|
||||
}
|
||||
|
||||
fn parse_prefab_root_component<T: serde::de::DeserializeOwned>(
|
||||
source_path: &str,
|
||||
type_name: &str,
|
||||
) -> Option<T> {
|
||||
let path = Path::new(source_path);
|
||||
let text = std::fs::read_to_string(path).ok()?;
|
||||
validate_level_text(&text).ok()?;
|
||||
let body = strip_schema_version(&migrate_scene_text(&text).ok()?).ok()?;
|
||||
let root_tuple = body.trim().trim_start_matches('(').trim_end_matches(')');
|
||||
for segment in split_top_level_fields(root_tuple) {
|
||||
let segment = segment.trim();
|
||||
if segment.starts_with("entities:") {
|
||||
let entities_body = segment.trim_start_matches("entities:").trim();
|
||||
return parse_first_entity_component(entities_body, type_name);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn split_top_level_fields(input: &str) -> Vec<String> {
|
||||
let mut fields = Vec::new();
|
||||
let mut depth: i32 = 0;
|
||||
let mut start = 0;
|
||||
for (index, ch) in input.char_indices() {
|
||||
match ch {
|
||||
'(' | '{' => depth += 1,
|
||||
')' | '}' => depth = depth.saturating_sub(1),
|
||||
',' if depth == 0 => {
|
||||
fields.push(input[start..index].to_string());
|
||||
start = index + 1;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if start < input.len() {
|
||||
fields.push(input[start..].to_string());
|
||||
}
|
||||
fields
|
||||
}
|
||||
|
||||
fn parse_first_entity_component<T: serde::de::DeserializeOwned>(
|
||||
entities_body: &str,
|
||||
type_name: &str,
|
||||
) -> Option<T> {
|
||||
let trimmed = entities_body.trim();
|
||||
let inner = trimmed.strip_prefix('{')?.strip_suffix('}')?.trim();
|
||||
let first_entity = inner.split(',').next()?.trim();
|
||||
let components = first_entity
|
||||
.split(':')
|
||||
.nth(1)?
|
||||
.trim()
|
||||
.strip_prefix('(')?
|
||||
.strip_suffix(')')?;
|
||||
for component in split_top_level_fields(components) {
|
||||
if component.contains(type_name) {
|
||||
return ron::from_str::<T>(&format!("({component})")).ok();
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn list_prefab_dependencies(path: &str) -> Vec<String> {
|
||||
vec![path.to_string()]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn empty_overrides_roundtrip() {
|
||||
let instance = PrefabInstance::new("id", "assets/prefabs/test.scn.ron");
|
||||
assert_eq!(read_overrides(&instance), PrefabOverrides::default());
|
||||
}
|
||||
}
|
||||
648
crates/editor/src/assets/static_mesh.rs
Normal file
648
crates/editor/src/assets/static_mesh.rs
Normal file
@ -0,0 +1,648 @@
|
||||
//! Normalized static mesh artifacts generated from model source files.
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
use bevy::gltf::GltfAssetLabel;
|
||||
use bevy::prelude::*;
|
||||
use bevy_ufbx::label::FbxAssetLabel;
|
||||
use bevy_ufbx::mesh::group_faces_by_material;
|
||||
use bevy_ufbx::utils::convert_matrix;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::asset_db::{
|
||||
AssetRecord, ImportSettings, MaterialImportPolicy, ModelHierarchyMode, ModelPlacementMode,
|
||||
};
|
||||
use shared::{ComponentInstanceId, EditorAssetRef, StaticMeshRenderer, StaticMeshRendererEntry};
|
||||
|
||||
pub const STATIC_MESH_MANIFEST_SCHEMA: u32 = 1;
|
||||
pub const STATIC_MESH_ARTIFACT_DIR: &str = "assets/meshes/generated";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct StaticMeshManifest {
|
||||
pub schema_version: u32,
|
||||
pub asset_id: String,
|
||||
pub label: String,
|
||||
pub source: StaticMeshSource,
|
||||
pub import: StaticMeshImportSnapshot,
|
||||
pub metadata: StaticMeshMetadata,
|
||||
pub parts: Vec<StaticMeshPart>,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct StaticMeshSource {
|
||||
pub path: String,
|
||||
pub format: String,
|
||||
pub fingerprint: StaticMeshSourceFingerprint,
|
||||
pub dependencies: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct StaticMeshSourceFingerprint {
|
||||
pub byte_len: u64,
|
||||
pub modified_unix_secs: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct StaticMeshImportSnapshot {
|
||||
pub scale: f32,
|
||||
pub generate_collider: bool,
|
||||
pub lod0_only: bool,
|
||||
pub placement_mode: ModelPlacementMode,
|
||||
pub hierarchy_mode: ModelHierarchyMode,
|
||||
pub material_policy: MaterialImportPolicy,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct StaticMeshMetadata {
|
||||
pub mesh_count: usize,
|
||||
pub material_count: usize,
|
||||
pub node_count: usize,
|
||||
pub animation_count: usize,
|
||||
pub skin_count: usize,
|
||||
pub light_count: usize,
|
||||
pub camera_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct StaticMeshPart {
|
||||
#[serde(default)]
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub mesh_label: String,
|
||||
#[serde(default)]
|
||||
pub material_id: Option<String>,
|
||||
pub material_slot_name: String,
|
||||
pub material_label: Option<String>,
|
||||
pub local_transform: Transform,
|
||||
pub source_node: Option<String>,
|
||||
pub source_mesh: Option<String>,
|
||||
pub source_material: Option<String>,
|
||||
}
|
||||
|
||||
pub fn static_mesh_manifest_path(asset_id: &str) -> String {
|
||||
format!("{STATIC_MESH_ARTIFACT_DIR}/{asset_id}.static_mesh.ron")
|
||||
}
|
||||
|
||||
pub fn part_id_from_label(label: &str) -> String {
|
||||
format!("mesh:{}", stable_sub_asset_slug(label))
|
||||
}
|
||||
|
||||
pub fn material_id_from_label(label: &str) -> String {
|
||||
format!("material:{}", stable_sub_asset_slug(label))
|
||||
}
|
||||
|
||||
fn stable_sub_asset_slug(label: &str) -> String {
|
||||
let mut slug = String::new();
|
||||
for ch in label.chars() {
|
||||
if ch.is_ascii_alphanumeric() {
|
||||
slug.push(ch.to_ascii_lowercase());
|
||||
} else if !slug.ends_with('_') {
|
||||
slug.push('_');
|
||||
}
|
||||
}
|
||||
slug.trim_matches('_').to_string()
|
||||
}
|
||||
|
||||
pub fn refresh_static_mesh_artifact(
|
||||
record: &mut AssetRecord,
|
||||
) -> Result<StaticMeshManifest, String> {
|
||||
let mut manifest = build_static_mesh_manifest(record)?;
|
||||
let path = static_mesh_manifest_path(&record.id.as_string());
|
||||
record.import_settings.static_mesh_manifest_path = Some(path.clone());
|
||||
manifest.source.dependencies.sort();
|
||||
manifest.source.dependencies.dedup();
|
||||
record.dependencies = manifest.source.dependencies.clone();
|
||||
|
||||
let text = ron::ser::to_string_pretty(&manifest, ron::ser::PrettyConfig::default())
|
||||
.map_err(|err| format!("could not serialize static mesh manifest: {err}"))?;
|
||||
if let Some(parent) = Path::new(&path).parent() {
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|err| format!("could not create {}: {err}", parent.display()))?;
|
||||
}
|
||||
let should_write = fs::read_to_string(&path).ok().as_deref() != Some(text.as_str());
|
||||
if should_write {
|
||||
fs::write(&path, text)
|
||||
.map_err(|err| format!("could not write static mesh manifest {path}: {err}"))?;
|
||||
info!(
|
||||
"Static mesh manifest refreshed: source={} artifact={} parts={}",
|
||||
record.path,
|
||||
path,
|
||||
manifest.parts.len()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
pub fn load_static_mesh_manifest(path: &str) -> Result<StaticMeshManifest, String> {
|
||||
let text = fs::read_to_string(path).map_err(|err| format!("could not read {path}: {err}"))?;
|
||||
ron::from_str(&text).map_err(|err| format!("could not parse {path}: {err}"))
|
||||
}
|
||||
|
||||
pub fn renderer_from_manifest(
|
||||
manifest: &StaticMeshManifest,
|
||||
settings: &ImportSettings,
|
||||
) -> StaticMeshRenderer {
|
||||
let use_source_materials = matches!(
|
||||
settings.material_policy,
|
||||
MaterialImportPolicy::SourceMaterials
|
||||
);
|
||||
|
||||
StaticMeshRenderer {
|
||||
slots: manifest
|
||||
.parts
|
||||
.iter()
|
||||
.map(|part| StaticMeshRendererEntry {
|
||||
id: ComponentInstanceId::new(part_effective_id(part)),
|
||||
name: part.name.clone(),
|
||||
mesh: EditorAssetRef::new(
|
||||
manifest.asset_id.clone(),
|
||||
part_effective_id(part),
|
||||
part.name.clone(),
|
||||
),
|
||||
material: use_source_materials
|
||||
.then(|| {
|
||||
part_effective_material_id(part).map(|id| {
|
||||
EditorAssetRef::new(
|
||||
manifest.asset_id.clone(),
|
||||
id,
|
||||
part.material_slot_name.clone(),
|
||||
)
|
||||
})
|
||||
})
|
||||
.flatten(),
|
||||
local_transform: part.local_transform,
|
||||
visible: true,
|
||||
cast_shadows: true,
|
||||
receive_shadows: true,
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
|
||||
fn part_effective_id(part: &StaticMeshPart) -> String {
|
||||
if part.id.trim().is_empty() {
|
||||
part_id_from_label(&part.mesh_label)
|
||||
} else {
|
||||
part.id.clone()
|
||||
}
|
||||
}
|
||||
|
||||
fn part_effective_material_id(part: &StaticMeshPart) -> Option<String> {
|
||||
part.material_id.clone().or_else(|| {
|
||||
part.material_label
|
||||
.as_ref()
|
||||
.map(|label| material_id_from_label(label))
|
||||
})
|
||||
}
|
||||
|
||||
fn build_static_mesh_manifest(record: &AssetRecord) -> Result<StaticMeshManifest, String> {
|
||||
let format = source_format(&record.path)?;
|
||||
let fingerprint = source_fingerprint(&record.path)?;
|
||||
let import = StaticMeshImportSnapshot {
|
||||
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.clone(),
|
||||
hierarchy_mode: record.import_settings.hierarchy_mode.clone(),
|
||||
material_policy: record.import_settings.material_policy.clone(),
|
||||
};
|
||||
|
||||
let mut manifest = match format.as_str() {
|
||||
"gltf" | "glb" => build_gltf_manifest(record, format, fingerprint, import)?,
|
||||
"fbx" => build_fbx_manifest(record, format, fingerprint, import)?,
|
||||
_ => return Err(format!("unsupported static mesh source format `{format}`")),
|
||||
};
|
||||
|
||||
if manifest.parts.is_empty() {
|
||||
manifest
|
||||
.warnings
|
||||
.push("No renderable static mesh parts were found.".into());
|
||||
}
|
||||
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
fn build_gltf_manifest(
|
||||
record: &AssetRecord,
|
||||
format: String,
|
||||
fingerprint: StaticMeshSourceFingerprint,
|
||||
import: StaticMeshImportSnapshot,
|
||||
) -> Result<StaticMeshManifest, String> {
|
||||
let gltf = gltf::Gltf::open(&record.path)
|
||||
.map_err(|err| format!("could not parse glTF {}: {err}", record.path))?;
|
||||
let mut parts = Vec::new();
|
||||
let mut dependencies = Vec::new();
|
||||
let mut warnings = Vec::new();
|
||||
|
||||
for buffer in gltf.document.buffers() {
|
||||
if let gltf::buffer::Source::Uri(uri) = buffer.source() {
|
||||
dependencies.push(resolve_dependency(&record.path, uri));
|
||||
}
|
||||
}
|
||||
for image in gltf.document.images() {
|
||||
if let gltf::image::Source::Uri { uri, .. } = image.source() {
|
||||
dependencies.push(resolve_dependency(&record.path, uri));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(scene) = gltf
|
||||
.document
|
||||
.default_scene()
|
||||
.or_else(|| gltf.document.scenes().next())
|
||||
{
|
||||
for node in scene.nodes() {
|
||||
collect_gltf_node_parts(node, Mat4::IDENTITY, &mut parts);
|
||||
}
|
||||
} else {
|
||||
for mesh in gltf.document.meshes() {
|
||||
collect_gltf_mesh_parts(None, None, mesh, Mat4::IDENTITY, &mut parts);
|
||||
}
|
||||
}
|
||||
|
||||
if gltf.document.animations().count() > 0 {
|
||||
warnings.push(
|
||||
"Animations are recorded as metadata; use Scene Instance placement for playback."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
if gltf.document.skins().count() > 0 {
|
||||
warnings.push(
|
||||
"Skins are recorded as metadata; skinned playback stays on the Scene Instance path."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
parts.sort_by(|a, b| a.mesh_label.cmp(&b.mesh_label).then(a.name.cmp(&b.name)));
|
||||
|
||||
Ok(StaticMeshManifest {
|
||||
schema_version: STATIC_MESH_MANIFEST_SCHEMA,
|
||||
asset_id: record.id.as_string(),
|
||||
label: record.label.clone(),
|
||||
source: StaticMeshSource {
|
||||
path: record.path.clone(),
|
||||
format,
|
||||
fingerprint,
|
||||
dependencies,
|
||||
},
|
||||
import,
|
||||
metadata: StaticMeshMetadata {
|
||||
mesh_count: gltf.document.meshes().count(),
|
||||
material_count: gltf.document.materials().count(),
|
||||
node_count: gltf.document.nodes().count(),
|
||||
animation_count: gltf.document.animations().count(),
|
||||
skin_count: gltf.document.skins().count(),
|
||||
light_count: 0,
|
||||
camera_count: gltf.document.cameras().count(),
|
||||
},
|
||||
parts,
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
|
||||
fn collect_gltf_node_parts(
|
||||
node: gltf::Node<'_>,
|
||||
parent_transform: Mat4,
|
||||
parts: &mut Vec<StaticMeshPart>,
|
||||
) {
|
||||
let local = Mat4::from_cols_array_2d(&node.transform().matrix());
|
||||
let world_transform = parent_transform * local;
|
||||
if let Some(mesh) = node.mesh() {
|
||||
collect_gltf_mesh_parts(
|
||||
node.name().map(str::to_string),
|
||||
Some(node.index()),
|
||||
mesh,
|
||||
world_transform,
|
||||
parts,
|
||||
);
|
||||
}
|
||||
for child in node.children() {
|
||||
collect_gltf_node_parts(child, world_transform, parts);
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_gltf_mesh_parts(
|
||||
node_name: Option<String>,
|
||||
node_index: Option<usize>,
|
||||
mesh: gltf::Mesh<'_>,
|
||||
transform: Mat4,
|
||||
parts: &mut Vec<StaticMeshPart>,
|
||||
) {
|
||||
let mesh_index = mesh.index();
|
||||
let mesh_name = mesh.name().map(str::to_string);
|
||||
for primitive in mesh.primitives() {
|
||||
let primitive_index = primitive.index();
|
||||
let mesh_label = GltfAssetLabel::Primitive {
|
||||
mesh: mesh_index,
|
||||
primitive: primitive_index,
|
||||
}
|
||||
.to_string();
|
||||
let material = primitive.material();
|
||||
let material_label = material
|
||||
.index()
|
||||
.map(|index| {
|
||||
GltfAssetLabel::Material {
|
||||
index,
|
||||
is_scale_inverted: false,
|
||||
}
|
||||
.to_string()
|
||||
})
|
||||
.or_else(|| Some(GltfAssetLabel::DefaultMaterial.to_string()));
|
||||
let material_name = material
|
||||
.name()
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| "Default Material".into());
|
||||
let name = node_name
|
||||
.clone()
|
||||
.or_else(|| mesh_name.clone())
|
||||
.unwrap_or_else(|| format!("Mesh {mesh_index}"));
|
||||
let source_node = node_index.map(|index| format!("Node{index}"));
|
||||
parts.push(StaticMeshPart {
|
||||
id: part_id_from_label(&mesh_label),
|
||||
name: format!("{name} / Primitive {primitive_index}"),
|
||||
material_id: material_label
|
||||
.as_ref()
|
||||
.map(|label| material_id_from_label(label)),
|
||||
mesh_label,
|
||||
material_slot_name: material_name.clone(),
|
||||
material_label,
|
||||
local_transform: Transform::from_matrix(transform),
|
||||
source_node,
|
||||
source_mesh: Some(format!("Mesh{mesh_index}")),
|
||||
source_material: Some(material_name),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn build_fbx_manifest(
|
||||
record: &AssetRecord,
|
||||
format: String,
|
||||
fingerprint: StaticMeshSourceFingerprint,
|
||||
import: StaticMeshImportSnapshot,
|
||||
) -> Result<StaticMeshManifest, String> {
|
||||
let bytes =
|
||||
fs::read(&record.path).map_err(|err| format!("could not read {}: {err}", record.path))?;
|
||||
let scene = ufbx::load_memory(
|
||||
&bytes,
|
||||
ufbx::LoadOpts {
|
||||
target_unit_meters: 1.0,
|
||||
target_axes: ufbx::CoordinateAxes::right_handed_y_up(),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.map_err(|err| format!("could not parse FBX {}: {err:?}", record.path))?;
|
||||
|
||||
let mut parts = Vec::new();
|
||||
let mut warnings = Vec::new();
|
||||
for (node_index, node) in scene.nodes.as_ref().iter().enumerate() {
|
||||
let Some(mesh_ref) = node.mesh.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
let mesh = mesh_ref.as_ref();
|
||||
if mesh.num_vertices == 0 || mesh.faces.as_ref().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut groups: Vec<(usize, Vec<u32>)> =
|
||||
group_faces_by_material(mesh).into_iter().collect();
|
||||
groups.sort_by_key(|(material_index, _)| *material_index);
|
||||
for (material_index, indices) in groups {
|
||||
if indices.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let material = mesh.materials.get(material_index).map(|mat| mat.as_ref());
|
||||
let material_label = material
|
||||
.and_then(|mat| fbx_material_label(&scene, mat.element.element_id))
|
||||
.or_else(|| Some(FbxAssetLabel::DefaultMaterial.to_string()));
|
||||
let material_name = material
|
||||
.map(|mat| mat.element.name.to_string())
|
||||
.filter(|name| !name.is_empty())
|
||||
.unwrap_or_else(|| "Default Material".into());
|
||||
let node_name = if node.element.name.is_empty() {
|
||||
format!("Node {node_index}")
|
||||
} else {
|
||||
node.element.name.to_string()
|
||||
};
|
||||
let mesh_label = FbxAssetLabel::Mesh(node_index * 1000 + material_index).to_string();
|
||||
parts.push(StaticMeshPart {
|
||||
id: part_id_from_label(&mesh_label),
|
||||
name: format!("{node_name} / Material {material_index}"),
|
||||
material_id: material_label
|
||||
.as_ref()
|
||||
.map(|label| material_id_from_label(label)),
|
||||
mesh_label,
|
||||
material_slot_name: material_name.clone(),
|
||||
material_label,
|
||||
local_transform: Transform::from_matrix(convert_matrix(&node.geometry_to_world)),
|
||||
source_node: Some(format!("Node{node_index}")),
|
||||
source_mesh: Some(format!("Mesh{node_index}")),
|
||||
source_material: Some(material_name),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if !scene.anim_stacks.as_ref().is_empty() {
|
||||
warnings.push(
|
||||
"Animations are recorded as metadata; use Scene Instance placement for playback."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
if !scene.skin_deformers.as_ref().is_empty() {
|
||||
warnings.push(
|
||||
"Skins are recorded as metadata; skinned playback stays on the Scene Instance path."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
parts.sort_by(|a, b| a.mesh_label.cmp(&b.mesh_label).then(a.name.cmp(&b.name)));
|
||||
|
||||
Ok(StaticMeshManifest {
|
||||
schema_version: STATIC_MESH_MANIFEST_SCHEMA,
|
||||
asset_id: record.id.as_string(),
|
||||
label: record.label.clone(),
|
||||
source: StaticMeshSource {
|
||||
path: record.path.clone(),
|
||||
format,
|
||||
fingerprint,
|
||||
dependencies: fbx_dependencies(&record.path),
|
||||
},
|
||||
import,
|
||||
metadata: StaticMeshMetadata {
|
||||
mesh_count: scene.meshes.as_ref().len(),
|
||||
material_count: scene.materials.as_ref().len(),
|
||||
node_count: scene.nodes.as_ref().len(),
|
||||
animation_count: scene.anim_stacks.as_ref().len(),
|
||||
skin_count: scene.skin_deformers.as_ref().len(),
|
||||
light_count: scene.lights.as_ref().len(),
|
||||
camera_count: scene.cameras.as_ref().len(),
|
||||
},
|
||||
parts,
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
|
||||
fn fbx_material_label(scene: &ufbx::Scene, element_id: u32) -> Option<String> {
|
||||
if element_id == 0 {
|
||||
return None;
|
||||
}
|
||||
scene
|
||||
.materials
|
||||
.as_ref()
|
||||
.iter()
|
||||
.position(|material| material.element.element_id == element_id)
|
||||
.map(|index| FbxAssetLabel::Material(index).to_string())
|
||||
}
|
||||
|
||||
fn fbx_dependencies(source_path: &str) -> Vec<String> {
|
||||
let path = Path::new(source_path);
|
||||
let Some(stem) = path.file_stem().and_then(|stem| stem.to_str()) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(parent) = path.parent() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let sidecar = parent.join(format!("{stem}.fbm"));
|
||||
if !sidecar.is_dir() {
|
||||
return Vec::new();
|
||||
}
|
||||
walkdir::WalkDir::new(sidecar)
|
||||
.follow_links(false)
|
||||
.into_iter()
|
||||
.filter_map(Result::ok)
|
||||
.filter(|entry| entry.file_type().is_file())
|
||||
.map(|entry| entry.path().to_string_lossy().replace('\\', "/"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn source_format(path: &str) -> Result<String, String> {
|
||||
Path::new(path)
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.map(|ext| ext.to_ascii_lowercase())
|
||||
.ok_or_else(|| format!("asset path `{path}` has no extension"))
|
||||
}
|
||||
|
||||
fn source_fingerprint(path: &str) -> Result<StaticMeshSourceFingerprint, String> {
|
||||
let metadata = fs::metadata(path).map_err(|err| format!("could not stat {path}: {err}"))?;
|
||||
let modified_unix_secs = metadata
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
|
||||
.map(|duration| duration.as_secs())
|
||||
.unwrap_or_default();
|
||||
Ok(StaticMeshSourceFingerprint {
|
||||
byte_len: metadata.len(),
|
||||
modified_unix_secs,
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_dependency(source_path: &str, uri: &str) -> String {
|
||||
if uri.starts_with("data:") || uri.contains("://") {
|
||||
return uri.to_string();
|
||||
}
|
||||
Path::new(source_path)
|
||||
.parent()
|
||||
.unwrap_or_else(|| Path::new(""))
|
||||
.join(uri)
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn test_manifest() -> StaticMeshManifest {
|
||||
StaticMeshManifest {
|
||||
schema_version: STATIC_MESH_MANIFEST_SCHEMA,
|
||||
asset_id: "asset-1".into(),
|
||||
label: "Crate".into(),
|
||||
source: StaticMeshSource {
|
||||
path: "assets/models/crate.glb".into(),
|
||||
format: "glb".into(),
|
||||
fingerprint: StaticMeshSourceFingerprint {
|
||||
byte_len: 42,
|
||||
modified_unix_secs: 10,
|
||||
},
|
||||
dependencies: Vec::new(),
|
||||
},
|
||||
import: StaticMeshImportSnapshot {
|
||||
scale: 1.0,
|
||||
generate_collider: true,
|
||||
lod0_only: true,
|
||||
placement_mode: ModelPlacementMode::StaticAsset,
|
||||
hierarchy_mode: ModelHierarchyMode::SingleActor,
|
||||
material_policy: MaterialImportPolicy::SourceMaterials,
|
||||
},
|
||||
metadata: StaticMeshMetadata {
|
||||
mesh_count: 1,
|
||||
material_count: 1,
|
||||
node_count: 1,
|
||||
animation_count: 0,
|
||||
skin_count: 0,
|
||||
light_count: 0,
|
||||
camera_count: 0,
|
||||
},
|
||||
parts: vec![StaticMeshPart {
|
||||
id: "mesh:mesh0_primitive0".into(),
|
||||
name: "Crate / Primitive 0".into(),
|
||||
mesh_label: "Mesh0/Primitive0".into(),
|
||||
material_id: Some("material:material0".into()),
|
||||
material_slot_name: "Wood".into(),
|
||||
material_label: Some("Material0".into()),
|
||||
local_transform: Transform::from_xyz(1.0, 2.0, 3.0),
|
||||
source_node: Some("Node0".into()),
|
||||
source_mesh: Some("Mesh0".into()),
|
||||
source_material: Some("Wood".into()),
|
||||
}],
|
||||
warnings: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn static_mesh_manifest_path_uses_asset_id() {
|
||||
assert_eq!(
|
||||
static_mesh_manifest_path("abc"),
|
||||
"assets/meshes/generated/abc.static_mesh.ron"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renderer_from_manifest_preserves_labels_and_collider_policy() {
|
||||
let manifest = test_manifest();
|
||||
let settings = ImportSettings {
|
||||
generate_collider: true,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let renderer = renderer_from_manifest(&manifest, &settings);
|
||||
|
||||
assert_eq!(renderer.slots.len(), 1);
|
||||
let entry = &renderer.slots[0];
|
||||
assert_eq!(entry.mesh.asset_id, manifest.asset_id);
|
||||
assert_eq!(entry.mesh.sub_asset_id, "mesh:mesh0_primitive0");
|
||||
assert_eq!(
|
||||
entry
|
||||
.material
|
||||
.as_ref()
|
||||
.map(|material| material.sub_asset_id.as_str()),
|
||||
Some("material:material0")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renderer_from_manifest_can_ignore_source_materials() {
|
||||
let manifest = test_manifest();
|
||||
let settings = ImportSettings {
|
||||
material_policy: MaterialImportPolicy::AuthoringOverride,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let renderer = renderer_from_manifest(&manifest, &settings);
|
||||
|
||||
assert!(renderer.slots[0].material.is_none());
|
||||
}
|
||||
}
|
||||
379
crates/editor/src/assets/thumbnails/cache.rs
Normal file
379
crates/editor/src/assets/thumbnails/cache.rs
Normal file
@ -0,0 +1,379 @@
|
||||
//! Thumbnail cache for asset browser grid cells.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy_egui::{egui, EguiPrimaryContextPass, EguiTextureHandle, EguiUserTextures};
|
||||
use egui_phosphor_icons::icons;
|
||||
|
||||
use super::sources::gltf::gltf_base_color_texture_path;
|
||||
use super::studio::{model_file_exists, ThumbnailStudio};
|
||||
use crate::assets::{
|
||||
asset_cache_key, asset_server_path, EditorAsset, EditorAssetKind, EditorAssets,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ThumbnailState {
|
||||
Pending,
|
||||
Ready,
|
||||
Failed { reason: String, retryable: bool },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct ThumbnailFailure {
|
||||
reason: String,
|
||||
retryable: bool,
|
||||
}
|
||||
|
||||
#[derive(Resource, Default)]
|
||||
pub struct AssetThumbnailCache {
|
||||
pub texture_ids: HashMap<String, egui::TextureId>,
|
||||
pending: HashMap<String, Handle<Image>>,
|
||||
pub(crate) studio_pending: HashSet<String>,
|
||||
failed: HashMap<String, ThumbnailFailure>,
|
||||
prefetched_folder: Option<String>,
|
||||
}
|
||||
|
||||
pub struct AssetThumbnailsPlugin;
|
||||
|
||||
impl Plugin for AssetThumbnailsPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<AssetThumbnailCache>()
|
||||
.add_systems(EguiPrimaryContextPass, register_loaded_thumbnails);
|
||||
}
|
||||
}
|
||||
|
||||
impl AssetThumbnailCache {
|
||||
pub fn state(&self, key: &str) -> Option<ThumbnailState> {
|
||||
if self.texture_ids.contains_key(key) {
|
||||
return Some(ThumbnailState::Ready);
|
||||
}
|
||||
if self.failed.contains_key(key) {
|
||||
let failure = self.failed.get(key)?;
|
||||
return Some(ThumbnailState::Failed {
|
||||
reason: failure.reason.clone(),
|
||||
retryable: failure.retryable,
|
||||
});
|
||||
}
|
||||
if self.pending.contains_key(key) || self.studio_pending.contains(key) {
|
||||
return Some(ThumbnailState::Pending);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn retry(&mut self, key: &str) {
|
||||
self.failed.remove(key);
|
||||
self.pending.remove(key);
|
||||
self.studio_pending.remove(key);
|
||||
self.texture_ids.remove(key);
|
||||
}
|
||||
|
||||
pub fn request_texture(&mut self, key: String, path: String, asset_server: &AssetServer) {
|
||||
if matches!(
|
||||
self.state(&key),
|
||||
Some(ThumbnailState::Ready | ThumbnailState::Pending)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if self.failed.contains_key(&key) {
|
||||
return;
|
||||
}
|
||||
let handle: Handle<Image> = asset_server.load(asset_server_path(&path));
|
||||
self.pending.insert(key, handle);
|
||||
}
|
||||
|
||||
pub fn request_model(
|
||||
&mut self,
|
||||
key: String,
|
||||
model_path: String,
|
||||
asset_server: &AssetServer,
|
||||
studio: &mut ThumbnailStudio,
|
||||
) {
|
||||
if matches!(
|
||||
self.state(&key),
|
||||
Some(ThumbnailState::Ready | ThumbnailState::Pending)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if self.failed.contains_key(&key) {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(texture_path) = gltf_base_color_texture_path(&model_path) {
|
||||
self.request_texture(key, texture_path, asset_server);
|
||||
return;
|
||||
}
|
||||
|
||||
if !model_file_exists(&model_path) {
|
||||
self.mark_studio_failed(&key, "file not found", false);
|
||||
return;
|
||||
}
|
||||
|
||||
if studio.enqueue(key.clone(), model_path) {
|
||||
self.studio_pending.insert(key);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn complete_studio_thumbnail(
|
||||
&mut self,
|
||||
key: &str,
|
||||
image: Handle<Image>,
|
||||
textures: &mut EguiUserTextures,
|
||||
) {
|
||||
self.studio_pending.remove(key);
|
||||
self.failed.remove(key);
|
||||
let texture_id = textures
|
||||
.image_id(&image)
|
||||
.unwrap_or_else(|| textures.add_image(EguiTextureHandle::Strong(image)));
|
||||
self.texture_ids.insert(key.to_string(), texture_id);
|
||||
}
|
||||
|
||||
pub(crate) fn mark_studio_failed(&mut self, key: &str, reason: &str, retryable: bool) {
|
||||
self.studio_pending.remove(key);
|
||||
self.pending.remove(key);
|
||||
self.failed.insert(
|
||||
key.to_string(),
|
||||
ThumbnailFailure {
|
||||
reason: reason.to_string(),
|
||||
retryable,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
pub fn invalidate_all(&mut self) {
|
||||
self.texture_ids.clear();
|
||||
self.pending.clear();
|
||||
self.studio_pending.clear();
|
||||
self.failed.clear();
|
||||
self.prefetched_folder = None;
|
||||
}
|
||||
|
||||
pub fn snapshot(&self) -> ThumbnailCacheSnapshot {
|
||||
ThumbnailCacheSnapshot {
|
||||
texture_ids: self.texture_ids.clone(),
|
||||
pending_keys: self
|
||||
.pending
|
||||
.keys()
|
||||
.chain(self.studio_pending.iter())
|
||||
.cloned()
|
||||
.collect(),
|
||||
failed_keys: self.failed.keys().cloned().collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn register_loaded_thumbnails(
|
||||
asset_server: Res<AssetServer>,
|
||||
mut cache: ResMut<AssetThumbnailCache>,
|
||||
mut textures: ResMut<EguiUserTextures>,
|
||||
) {
|
||||
let ready: Vec<(String, Handle<Image>)> = cache
|
||||
.pending
|
||||
.iter()
|
||||
.filter(|(_, handle)| asset_server.is_loaded_with_dependencies(*handle))
|
||||
.map(|(key, handle)| (key.clone(), handle.clone()))
|
||||
.collect();
|
||||
|
||||
for (key, handle) in ready {
|
||||
cache.pending.remove(&key);
|
||||
cache.failed.remove(&key);
|
||||
let texture_id = textures
|
||||
.image_id(&handle)
|
||||
.unwrap_or_else(|| textures.add_image(EguiTextureHandle::Strong(handle)));
|
||||
cache.texture_ids.insert(key, texture_id);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn kind_icon(kind: &EditorAssetKind) -> egui_phosphor_icons::Icon {
|
||||
match kind {
|
||||
EditorAssetKind::Primitive(_) => icons::CUBE,
|
||||
EditorAssetKind::Light(_) => icons::LIGHTBULB,
|
||||
EditorAssetKind::Model => icons::CUBE_TRANSPARENT,
|
||||
EditorAssetKind::Texture => icons::IMAGE,
|
||||
EditorAssetKind::Material => icons::PAINT_BRUSH,
|
||||
EditorAssetKind::Level => icons::MAP_TRIFOLD,
|
||||
EditorAssetKind::Prefab => icons::PACKAGE,
|
||||
EditorAssetKind::PostProcessVolume => icons::CAMERA,
|
||||
EditorAssetKind::PostProcessEffect => icons::SPARKLE,
|
||||
EditorAssetKind::RenderingProfile => icons::SLIDERS,
|
||||
EditorAssetKind::ShaderSchema => icons::CODE,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn draw_asset_cell_with(
|
||||
ui: &mut egui::Ui,
|
||||
asset: &EditorAsset,
|
||||
texture_id: Option<egui::TextureId>,
|
||||
pending: bool,
|
||||
failed: Option<&str>,
|
||||
selected: bool,
|
||||
thumbnail_size: f32,
|
||||
) -> egui::Response {
|
||||
let thumb_size = thumbnail_size.clamp(48.0, 112.0);
|
||||
let cell_size = egui::vec2(thumb_size + 20.0, thumb_size + 30.0);
|
||||
let (rect, response) = ui.allocate_exact_size(cell_size, egui::Sense::click_and_drag());
|
||||
|
||||
let fill = if selected {
|
||||
crate::ui::theme::SELECTION_BG_MUTED
|
||||
} else if response.hovered() {
|
||||
crate::ui::theme::ELEVATED_BG
|
||||
} else {
|
||||
crate::ui::theme::WIDGET_BG
|
||||
};
|
||||
ui.painter().rect(
|
||||
rect,
|
||||
4.0,
|
||||
fill,
|
||||
egui::Stroke::new(
|
||||
1.0,
|
||||
if selected {
|
||||
crate::ui::theme::ACCENT_HOVER
|
||||
} else {
|
||||
crate::ui::theme::BORDER
|
||||
},
|
||||
),
|
||||
egui::StrokeKind::Inside,
|
||||
);
|
||||
|
||||
let thumb_rect = egui::Rect::from_min_size(
|
||||
rect.min + egui::vec2(10.0, 8.0),
|
||||
egui::vec2(thumb_size, thumb_size),
|
||||
);
|
||||
|
||||
if let Some(texture_id) = texture_id {
|
||||
ui.painter().image(
|
||||
texture_id,
|
||||
thumb_rect,
|
||||
egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)),
|
||||
egui::Color32::WHITE,
|
||||
);
|
||||
} else if pending {
|
||||
ui.painter().text(
|
||||
thumb_rect.center(),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
icons::CIRCLE_NOTCH.as_str(),
|
||||
egui::FontId::new(24.0, egui::FontFamily::Name("phosphor-regular".into())),
|
||||
crate::ui::theme::TEXT_DIM,
|
||||
);
|
||||
} else if failed.is_some() {
|
||||
ui.painter().text(
|
||||
thumb_rect.center(),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
icons::WARNING_CIRCLE.as_str(),
|
||||
egui::FontId::new(24.0, egui::FontFamily::Name("phosphor-regular".into())),
|
||||
crate::ui::theme::TEXT_DIM,
|
||||
);
|
||||
} else {
|
||||
let icon = kind_icon(&asset.kind);
|
||||
ui.painter().text(
|
||||
thumb_rect.center(),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
icon.as_str(),
|
||||
egui::FontId::new(28.0, egui::FontFamily::Name("phosphor-regular".into())),
|
||||
crate::ui::theme::TEXT,
|
||||
);
|
||||
}
|
||||
|
||||
ui.painter().text(
|
||||
egui::pos2(rect.center().x, rect.max.y - 6.0),
|
||||
egui::Align2::CENTER_BOTTOM,
|
||||
asset.label.as_str(),
|
||||
egui::FontId::new(11.0, egui::FontFamily::Proportional),
|
||||
if selected {
|
||||
crate::ui::theme::TEXT_SELECTED
|
||||
} else {
|
||||
crate::ui::theme::TEXT
|
||||
},
|
||||
);
|
||||
|
||||
response
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ThumbnailCacheSnapshot {
|
||||
pub texture_ids: HashMap<String, egui::TextureId>,
|
||||
pub pending_keys: Vec<String>,
|
||||
pub failed_keys: Vec<String>,
|
||||
}
|
||||
|
||||
impl ThumbnailCacheSnapshot {
|
||||
pub fn texture_for(&self, asset: &EditorAsset) -> Option<egui::TextureId> {
|
||||
self.texture_ids.get(&asset_cache_key(asset)).copied()
|
||||
}
|
||||
|
||||
pub fn is_pending(&self, asset: &EditorAsset) -> bool {
|
||||
self.pending_keys
|
||||
.iter()
|
||||
.any(|key| key == &asset_cache_key(asset))
|
||||
}
|
||||
|
||||
pub fn is_failed(&self, asset: &EditorAsset) -> bool {
|
||||
self.failed_keys
|
||||
.iter()
|
||||
.any(|key| key == &asset_cache_key(asset))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn prefetch_folder_thumbnails(world: &mut World, folder: &str) {
|
||||
{
|
||||
let cache = world.resource::<AssetThumbnailCache>();
|
||||
if cache.prefetched_folder.as_deref() == Some(folder) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
let requests: Vec<(String, String, EditorAssetKind)> = {
|
||||
let assets = world.resource::<EditorAssets>();
|
||||
assets
|
||||
.assets
|
||||
.iter()
|
||||
.filter(|asset| asset.folder_path == folder)
|
||||
.filter(|asset| {
|
||||
matches!(
|
||||
asset.kind,
|
||||
EditorAssetKind::Texture | EditorAssetKind::Model
|
||||
)
|
||||
})
|
||||
.filter_map(|asset| {
|
||||
Some((
|
||||
asset_cache_key(asset),
|
||||
asset.path.clone()?,
|
||||
asset.kind.clone(),
|
||||
))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
let asset_server = world.resource::<AssetServer>().clone();
|
||||
world.resource_scope(|world, mut cache: Mut<AssetThumbnailCache>| {
|
||||
world.resource_scope(|_world, mut studio: Mut<ThumbnailStudio>| {
|
||||
for (key, path, kind) in requests {
|
||||
match kind {
|
||||
EditorAssetKind::Texture => cache.request_texture(key, path, &asset_server),
|
||||
EditorAssetKind::Model => {
|
||||
cache.request_model(key, path, &asset_server, &mut studio)
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
world
|
||||
.resource_mut::<AssetThumbnailCache>()
|
||||
.prefetched_folder = Some(folder.to_string());
|
||||
}
|
||||
|
||||
pub fn invalidate_on_catalog_refresh(world: &mut World) {
|
||||
world.resource_mut::<AssetThumbnailCache>().invalidate_all();
|
||||
if !world.contains_resource::<ThumbnailStudio>() {
|
||||
return;
|
||||
}
|
||||
let cleanup = {
|
||||
let mut studio = world.resource_mut::<ThumbnailStudio>();
|
||||
studio.clear_queue();
|
||||
studio.clear_jobs()
|
||||
};
|
||||
if let Some(root) = cleanup.active_root {
|
||||
world.commands().entity(root).despawn_children();
|
||||
world.commands().entity(root).despawn();
|
||||
}
|
||||
}
|
||||
7
crates/editor/src/assets/thumbnails/job.rs
Normal file
7
crates/editor/src/assets/thumbnails/job.rs
Normal file
@ -0,0 +1,7 @@
|
||||
//! Thumbnail generation job descriptor.
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ThumbnailJob {
|
||||
pub cache_key: String,
|
||||
pub model_path: String,
|
||||
}
|
||||
26
crates/editor/src/assets/thumbnails/mod.rs
Normal file
26
crates/editor/src/assets/thumbnails/mod.rs
Normal file
@ -0,0 +1,26 @@
|
||||
//! Unified asset thumbnail pipeline (texture fast-path + model studio).
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
mod cache;
|
||||
mod job;
|
||||
mod sources;
|
||||
mod studio;
|
||||
|
||||
pub use cache::{
|
||||
draw_asset_cell_with, invalidate_on_catalog_refresh, kind_icon, prefetch_folder_thumbnails,
|
||||
AssetThumbnailCache, AssetThumbnailsPlugin, ThumbnailCacheSnapshot, ThumbnailState,
|
||||
};
|
||||
pub use job::ThumbnailJob;
|
||||
pub use sources::{FbxThumbnailSource, GltfThumbnailSource, ThumbnailModelSource};
|
||||
pub use studio::{
|
||||
model_file_exists, model_scene_asset_path, ThumbnailStudio, ThumbnailStudioPlugin,
|
||||
};
|
||||
|
||||
pub struct ThumbnailsPlugin;
|
||||
|
||||
impl Plugin for ThumbnailsPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_plugins((AssetThumbnailsPlugin, ThumbnailStudioPlugin));
|
||||
}
|
||||
}
|
||||
123
crates/editor/src/assets/thumbnails/sources/fbx.rs
Normal file
123
crates/editor/src/assets/thumbnails/sources/fbx.rs
Normal file
@ -0,0 +1,123 @@
|
||||
//! Direct FBX mesh extraction for thumbnail studio (no texture deps).
|
||||
|
||||
use bevy::asset::RenderAssetUsages;
|
||||
use bevy::camera::visibility::RenderLayers;
|
||||
use bevy::mesh::{Indices, PrimitiveTopology};
|
||||
use bevy::prelude::*;
|
||||
use bevy_ufbx::mesh::group_faces_by_material;
|
||||
use bevy_ufbx::utils::convert_matrix;
|
||||
|
||||
use super::ThumbnailModelSource;
|
||||
use crate::assets::thumbnails::studio::THUMBNAIL_LAYER;
|
||||
|
||||
pub struct FbxThumbnailSource;
|
||||
|
||||
impl ThumbnailModelSource for FbxThumbnailSource {
|
||||
fn spawn_preview(
|
||||
&self,
|
||||
commands: &mut Commands,
|
||||
meshes: &mut Assets<Mesh>,
|
||||
materials: &mut Assets<StandardMaterial>,
|
||||
root: Entity,
|
||||
catalog_path: &str,
|
||||
) -> bool {
|
||||
let Ok(bytes) = std::fs::read(catalog_path) else {
|
||||
warn!("FBX thumbnail: could not read {catalog_path}");
|
||||
return false;
|
||||
};
|
||||
|
||||
let scene = match ufbx::load_memory(
|
||||
&bytes,
|
||||
ufbx::LoadOpts {
|
||||
target_unit_meters: 1.0,
|
||||
target_axes: ufbx::CoordinateAxes::right_handed_y_up(),
|
||||
..Default::default()
|
||||
},
|
||||
) {
|
||||
Ok(scene) => scene,
|
||||
Err(err) => {
|
||||
warn!("FBX thumbnail: failed to parse {catalog_path}: {err:?}");
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
let material = materials.add(StandardMaterial {
|
||||
base_color: Color::srgb(0.72, 0.72, 0.75),
|
||||
..default()
|
||||
});
|
||||
|
||||
let mut spawned = false;
|
||||
for node in scene.nodes.as_ref().iter() {
|
||||
let Some(mesh_ref) = node.mesh.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
let ufbx_mesh = mesh_ref.as_ref();
|
||||
if ufbx_mesh.num_vertices == 0 || ufbx_mesh.faces.as_ref().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (_material_idx, indices) in group_faces_by_material(ufbx_mesh) {
|
||||
if indices.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mesh_handle = meshes.add(build_mesh(ufbx_mesh, &indices));
|
||||
let transform = Transform::from_matrix(convert_matrix(&node.geometry_to_world));
|
||||
|
||||
commands.entity(root).with_children(|parent| {
|
||||
parent.spawn((
|
||||
RenderLayers::layer(THUMBNAIL_LAYER),
|
||||
Mesh3d(mesh_handle),
|
||||
MeshMaterial3d(material.clone()),
|
||||
transform,
|
||||
));
|
||||
});
|
||||
spawned = true;
|
||||
}
|
||||
}
|
||||
|
||||
if !spawned {
|
||||
warn!("FBX thumbnail: no renderable meshes in {catalog_path}");
|
||||
}
|
||||
spawned
|
||||
}
|
||||
}
|
||||
|
||||
fn build_mesh(ufbx_mesh: &ufbx::Mesh, indices: &[u32]) -> Mesh {
|
||||
let mut mesh = Mesh::new(
|
||||
PrimitiveTopology::TriangleList,
|
||||
RenderAssetUsages::default(),
|
||||
);
|
||||
|
||||
let positions: Vec<[f32; 3]> = ufbx_mesh
|
||||
.vertex_position
|
||||
.values
|
||||
.as_ref()
|
||||
.iter()
|
||||
.map(|v| [v.x as f32, v.y as f32, v.z as f32])
|
||||
.collect();
|
||||
mesh.insert_attribute(Mesh::ATTRIBUTE_POSITION, positions);
|
||||
|
||||
if ufbx_mesh.vertex_normal.exists {
|
||||
let normals: Vec<[f32; 3]> = (0..ufbx_mesh.num_vertices)
|
||||
.map(|i| {
|
||||
let n = ufbx_mesh.vertex_normal[i];
|
||||
[n.x as f32, n.y as f32, n.z as f32]
|
||||
})
|
||||
.collect();
|
||||
mesh.insert_attribute(Mesh::ATTRIBUTE_NORMAL, normals);
|
||||
}
|
||||
|
||||
if ufbx_mesh.vertex_uv.exists {
|
||||
let uvs: Vec<[f32; 2]> = (0..ufbx_mesh.num_vertices)
|
||||
.map(|i| {
|
||||
let uv = ufbx_mesh.vertex_uv[i];
|
||||
[uv.x as f32, uv.y as f32]
|
||||
})
|
||||
.collect();
|
||||
mesh.insert_attribute(Mesh::ATTRIBUTE_UV_0, uvs);
|
||||
}
|
||||
|
||||
mesh.insert_indices(Indices::U32(indices.to_vec()));
|
||||
mesh
|
||||
}
|
||||
82
crates/editor/src/assets/thumbnails/sources/gltf.rs
Normal file
82
crates/editor/src/assets/thumbnails/sources/gltf.rs
Normal file
@ -0,0 +1,82 @@
|
||||
//! glTF/GLB fast-path texture extraction for asset browser thumbnails.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
/// Returns an asset-server path for the first base-color texture referenced by a glTF/GLB file.
|
||||
///
|
||||
/// Embedded GLB buffers are skipped so callers can fall back to the render studio.
|
||||
pub fn gltf_base_color_texture_path(model_asset_path: &str) -> Option<String> {
|
||||
let model_path = Path::new(model_asset_path);
|
||||
if !is_gltf_path(model_path) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let (document, ..) = gltf::import(model_path).ok()?;
|
||||
for material in document.materials() {
|
||||
let Some(tex_info) = material.pbr_metallic_roughness().base_color_texture() else {
|
||||
continue;
|
||||
};
|
||||
let texture = document.textures().nth(tex_info.texture().index())?;
|
||||
let image = document.images().nth(texture.source().index())?;
|
||||
let uri = match image.source() {
|
||||
gltf::image::Source::Uri { uri, .. } => uri,
|
||||
gltf::image::Source::View { .. } => continue,
|
||||
};
|
||||
if let Some(path) = resolve_texture_uri(model_path, uri) {
|
||||
return Some(path);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub struct GltfThumbnailSource;
|
||||
|
||||
fn is_gltf_path(path: &Path) -> bool {
|
||||
path.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.is_some_and(|ext| matches!(ext.to_ascii_lowercase().as_str(), "gltf" | "glb"))
|
||||
}
|
||||
|
||||
fn resolve_texture_uri(model_path: &Path, uri: &str) -> Option<String> {
|
||||
if uri.starts_with("data:") {
|
||||
return None;
|
||||
}
|
||||
let model_dir = model_path.parent()?;
|
||||
let texture_path = model_dir.join(uri);
|
||||
normalize_asset_path(&texture_path)
|
||||
}
|
||||
|
||||
fn normalize_asset_path(path: &Path) -> Option<String> {
|
||||
let raw = path.to_string_lossy().replace('\\', "/");
|
||||
if raw.starts_with("assets/") {
|
||||
return Some(raw);
|
||||
}
|
||||
let file_name = path.file_name()?.to_str()?;
|
||||
if file_name.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(format!("assets/{file_name}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn normalize_asset_path_keeps_assets_prefix() {
|
||||
assert_eq!(
|
||||
normalize_asset_path(Path::new("assets/textures/albedo.png")),
|
||||
Some("assets/textures/albedo.png".into())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_texture_uri_relative_to_model() {
|
||||
let model = PathBuf::from("assets/models/chair.gltf");
|
||||
assert_eq!(
|
||||
resolve_texture_uri(&model, "textures/chair_albedo.png"),
|
||||
Some("assets/models/textures/chair_albedo.png".into())
|
||||
);
|
||||
}
|
||||
}
|
||||
33
crates/editor/src/assets/thumbnails/sources/mod.rs
Normal file
33
crates/editor/src/assets/thumbnails/sources/mod.rs
Normal file
@ -0,0 +1,33 @@
|
||||
//! Model file sources for the thumbnail render studio.
|
||||
|
||||
mod fbx;
|
||||
pub(crate) mod gltf;
|
||||
|
||||
pub use fbx::FbxThumbnailSource;
|
||||
pub use gltf::GltfThumbnailSource;
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
/// Spawns untextured or scene-root preview content under `root` for the thumbnail studio.
|
||||
pub trait ThumbnailModelSource: Send + Sync {
|
||||
fn spawn_preview(
|
||||
&self,
|
||||
commands: &mut Commands,
|
||||
meshes: &mut Assets<Mesh>,
|
||||
materials: &mut Assets<StandardMaterial>,
|
||||
root: Entity,
|
||||
catalog_path: &str,
|
||||
) -> bool;
|
||||
}
|
||||
|
||||
pub fn source_for_extension(path: &str) -> Option<&'static dyn ThumbnailModelSource> {
|
||||
if path.ends_with(".fbx") {
|
||||
Some(&FbxThumbnailSource)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn uses_scene_root(path: &str) -> bool {
|
||||
!path.ends_with(".fbx")
|
||||
}
|
||||
544
crates/editor/src/assets/thumbnails/studio.rs
Normal file
544
crates/editor/src/assets/thumbnails/studio.rs
Normal file
@ -0,0 +1,544 @@
|
||||
//! Offscreen model thumbnail studio for FBX and untextured glTF assets.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::path::Path;
|
||||
|
||||
use bevy::camera::visibility::RenderLayers;
|
||||
use bevy::camera::RenderTarget;
|
||||
use bevy::gltf::GltfAssetLabel;
|
||||
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::ModelRef;
|
||||
|
||||
use super::cache::AssetThumbnailCache;
|
||||
use super::job::ThumbnailJob;
|
||||
use super::sources::{source_for_extension, uses_scene_root};
|
||||
use crate::assets::asset_server_path;
|
||||
use crate::infra::EditorOnly;
|
||||
|
||||
pub(crate) const THUMB_SIZE: u32 = 128;
|
||||
pub(crate) const THUMBNAIL_LAYER: usize = 31;
|
||||
const MESH_WARMUP_FRAMES: u8 = 12;
|
||||
const POST_ATTACH_FRAMES: u8 = 8;
|
||||
const RENDER_FRAMES: u8 = 3;
|
||||
const COOLDOWN_FRAMES: u8 = 2;
|
||||
const LOAD_TIMEOUT_FRAMES: u32 = 240;
|
||||
|
||||
#[derive(Component)]
|
||||
struct ThumbnailStudioCamera;
|
||||
|
||||
#[derive(Component)]
|
||||
struct ThumbnailStudioLight;
|
||||
|
||||
#[derive(Component)]
|
||||
struct ThumbnailStudioLayer;
|
||||
|
||||
#[derive(Component)]
|
||||
struct ThumbnailSceneRoot;
|
||||
|
||||
#[derive(Resource)]
|
||||
pub struct ThumbnailStudio {
|
||||
camera: Entity,
|
||||
render_image: Handle<Image>,
|
||||
_lights: Vec<Entity>,
|
||||
queue: VecDeque<ThumbnailJob>,
|
||||
active: Option<ActiveModelThumbnail>,
|
||||
cooldown_frames: u8,
|
||||
}
|
||||
|
||||
struct ActiveModelThumbnail {
|
||||
cache_key: String,
|
||||
model_path: String,
|
||||
root: Entity,
|
||||
meshes_ready: bool,
|
||||
mesh_warmup_frames: u8,
|
||||
camera_active: bool,
|
||||
post_attach_frames: u8,
|
||||
framed: bool,
|
||||
frames_remaining: u8,
|
||||
wait_frames: u32,
|
||||
}
|
||||
|
||||
pub struct ThumbnailStudioPlugin;
|
||||
|
||||
impl Plugin for ThumbnailStudioPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_systems(Startup, setup_thumbnail_studio)
|
||||
.add_systems(PostUpdate, apply_thumbnail_studio_layers)
|
||||
.add_systems(Last, process_thumbnail_studio);
|
||||
}
|
||||
}
|
||||
|
||||
impl ThumbnailStudio {
|
||||
pub fn enqueue(&mut self, cache_key: String, model_path: String) -> bool {
|
||||
if self
|
||||
.active
|
||||
.as_ref()
|
||||
.is_some_and(|active| active.cache_key == cache_key)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if self.queue.iter().any(|job| job.cache_key == cache_key) {
|
||||
return false;
|
||||
}
|
||||
self.queue.push_back(ThumbnailJob {
|
||||
cache_key,
|
||||
model_path,
|
||||
});
|
||||
true
|
||||
}
|
||||
|
||||
pub fn clear_queue(&mut self) {
|
||||
self.queue.clear();
|
||||
self.cooldown_frames = 0;
|
||||
}
|
||||
|
||||
pub fn clear_jobs(&mut self) -> StudioCleanup {
|
||||
StudioCleanup {
|
||||
camera: self.camera,
|
||||
active_root: self.active.take().map(|active| active.root),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct StudioCleanup {
|
||||
pub camera: Entity,
|
||||
pub active_root: Option<Entity>,
|
||||
}
|
||||
|
||||
pub fn model_scene_asset_path(path: &str, scene_index: usize) -> String {
|
||||
let path = asset_server_path(path);
|
||||
if path.ends_with(".fbx") {
|
||||
ModelRef::fbx_scene_asset_path(&path, scene_index)
|
||||
} else {
|
||||
GltfAssetLabel::Scene(scene_index)
|
||||
.from_asset(path)
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn setup_thumbnail_studio(mut commands: Commands, mut images: ResMut<Assets<Image>>) {
|
||||
let render_image = images.add(Image::new_target_texture(
|
||||
THUMB_SIZE,
|
||||
THUMB_SIZE,
|
||||
TextureFormat::Rgba8UnormSrgb,
|
||||
None,
|
||||
));
|
||||
|
||||
let camera = commands
|
||||
.spawn((
|
||||
Name::new("Thumbnail Studio Camera"),
|
||||
EditorOnly,
|
||||
ThumbnailStudioCamera,
|
||||
ThumbnailStudioLayer,
|
||||
RenderLayers::layer(THUMBNAIL_LAYER),
|
||||
Camera3d::default(),
|
||||
Camera {
|
||||
is_active: false,
|
||||
order: -50,
|
||||
clear_color: ClearColorConfig::Custom(Color::srgb(0.12, 0.12, 0.14)),
|
||||
..default()
|
||||
},
|
||||
RenderTarget::Image(render_image.clone().into()),
|
||||
Transform::from_xyz(2.0, 1.4, 2.0).looking_at(Vec3::new(0.0, 0.5, 0.0), Vec3::Y),
|
||||
Projection::Perspective(PerspectiveProjection {
|
||||
fov: 40.0_f32.to_radians(),
|
||||
..default()
|
||||
}),
|
||||
))
|
||||
.id();
|
||||
|
||||
let key_light = commands
|
||||
.spawn((
|
||||
EditorOnly,
|
||||
ThumbnailStudioLight,
|
||||
ThumbnailStudioLayer,
|
||||
RenderLayers::layer(THUMBNAIL_LAYER),
|
||||
DirectionalLight {
|
||||
illuminance: 12_000.0,
|
||||
shadows_enabled: false,
|
||||
..default()
|
||||
},
|
||||
Transform::from_rotation(Quat::from_euler(EulerRot::XYZ, -0.8, 0.9, 0.0)),
|
||||
))
|
||||
.id();
|
||||
|
||||
let fill_light = commands
|
||||
.spawn((
|
||||
EditorOnly,
|
||||
ThumbnailStudioLight,
|
||||
ThumbnailStudioLayer,
|
||||
RenderLayers::layer(THUMBNAIL_LAYER),
|
||||
DirectionalLight {
|
||||
illuminance: 3_500.0,
|
||||
shadows_enabled: false,
|
||||
..default()
|
||||
},
|
||||
Transform::from_rotation(Quat::from_euler(EulerRot::XYZ, -0.4, -2.2, 0.0)),
|
||||
))
|
||||
.id();
|
||||
|
||||
commands.insert_resource(ThumbnailStudio {
|
||||
camera,
|
||||
render_image,
|
||||
_lights: vec![key_light, fill_light],
|
||||
queue: VecDeque::new(),
|
||||
active: None,
|
||||
cooldown_frames: 0,
|
||||
});
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn process_thumbnail_studio(
|
||||
mut commands: Commands,
|
||||
mut studio: ResMut<ThumbnailStudio>,
|
||||
mut cache: ResMut<AssetThumbnailCache>,
|
||||
mut textures: ResMut<EguiUserTextures>,
|
||||
mut materials: ResMut<Assets<StandardMaterial>>,
|
||||
mut mesh_storage: ResMut<Assets<Mesh>>,
|
||||
asset_server: Res<AssetServer>,
|
||||
mut cameras: Query<&mut Camera, With<ThumbnailStudioCamera>>,
|
||||
mut camera_transforms: Query<&mut Transform, With<ThumbnailStudioCamera>>,
|
||||
children: Query<&Children>,
|
||||
transforms: Query<&GlobalTransform>,
|
||||
mesh3d: Query<&Mesh3d>,
|
||||
) {
|
||||
if studio.cooldown_frames > 0 {
|
||||
studio.cooldown_frames -= 1;
|
||||
return;
|
||||
}
|
||||
|
||||
if let Some(mut active) = studio.active.take() {
|
||||
if !active.framed {
|
||||
active.wait_frames += 1;
|
||||
|
||||
if !active.meshes_ready {
|
||||
if thumbnail_meshes_present(active.root, &children, &mesh3d, &mesh_storage) {
|
||||
active.meshes_ready = true;
|
||||
active.mesh_warmup_frames = 0;
|
||||
}
|
||||
} else if active.mesh_warmup_frames < MESH_WARMUP_FRAMES {
|
||||
active.mesh_warmup_frames += 1;
|
||||
} else if frame_thumbnail_scene(
|
||||
active.root,
|
||||
&children,
|
||||
&transforms,
|
||||
&mesh3d,
|
||||
&mesh_storage,
|
||||
&mut camera_transforms,
|
||||
) {
|
||||
active.framed = true;
|
||||
active.post_attach_frames = 0;
|
||||
studio.active = Some(active);
|
||||
return;
|
||||
}
|
||||
|
||||
if active.wait_frames >= LOAD_TIMEOUT_FRAMES {
|
||||
warn!(
|
||||
"Model thumbnail timed out framing {} (meshes may still be loading)",
|
||||
active.model_path
|
||||
);
|
||||
fail_active_thumbnail(
|
||||
&mut commands,
|
||||
&mut studio,
|
||||
&mut cache,
|
||||
&mut cameras,
|
||||
active,
|
||||
"load timeout",
|
||||
true,
|
||||
);
|
||||
} else {
|
||||
studio.active = Some(active);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if !active.camera_active {
|
||||
active.post_attach_frames += 1;
|
||||
if active.post_attach_frames >= POST_ATTACH_FRAMES {
|
||||
if let Ok(mut camera) = cameras.get_mut(studio.camera) {
|
||||
camera.is_active = true;
|
||||
}
|
||||
active.camera_active = true;
|
||||
active.frames_remaining = RENDER_FRAMES;
|
||||
}
|
||||
studio.active = Some(active);
|
||||
return;
|
||||
}
|
||||
|
||||
if active.camera_active {
|
||||
if active.frames_remaining > 0 {
|
||||
active.frames_remaining -= 1;
|
||||
studio.active = Some(active);
|
||||
} else {
|
||||
cache.complete_studio_thumbnail(
|
||||
&active.cache_key,
|
||||
studio.render_image.clone(),
|
||||
&mut textures,
|
||||
);
|
||||
finish_active_thumbnail(&mut commands, &mut studio, &mut cameras, active);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
studio.active = Some(active);
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(job) = studio.queue.pop_front() else {
|
||||
deactivate_studio_camera(&mut cameras, studio.camera);
|
||||
return;
|
||||
};
|
||||
|
||||
let mut root_entity = commands.spawn((
|
||||
EditorOnly,
|
||||
ThumbnailSceneRoot,
|
||||
ThumbnailStudioLayer,
|
||||
RenderLayers::layer(THUMBNAIL_LAYER),
|
||||
Transform::default(),
|
||||
));
|
||||
|
||||
if uses_scene_root(&job.model_path) {
|
||||
root_entity.insert(SceneRoot(
|
||||
asset_server.load(model_scene_asset_path(&job.model_path, 0)),
|
||||
));
|
||||
}
|
||||
|
||||
let root = root_entity.id();
|
||||
|
||||
let content_spawned = if let Some(source) = source_for_extension(&job.model_path) {
|
||||
source.spawn_preview(
|
||||
&mut commands,
|
||||
&mut mesh_storage,
|
||||
&mut materials,
|
||||
root,
|
||||
&job.model_path,
|
||||
)
|
||||
} else {
|
||||
uses_scene_root(&job.model_path)
|
||||
};
|
||||
|
||||
if source_for_extension(&job.model_path).is_some() && !content_spawned {
|
||||
fail_active_thumbnail(
|
||||
&mut commands,
|
||||
&mut studio,
|
||||
&mut cache,
|
||||
&mut cameras,
|
||||
ActiveModelThumbnail {
|
||||
cache_key: job.cache_key,
|
||||
model_path: job.model_path,
|
||||
root,
|
||||
meshes_ready: false,
|
||||
mesh_warmup_frames: 0,
|
||||
camera_active: false,
|
||||
post_attach_frames: 0,
|
||||
framed: false,
|
||||
frames_remaining: 0,
|
||||
wait_frames: 0,
|
||||
},
|
||||
"no renderable meshes",
|
||||
true,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
studio.active = Some(ActiveModelThumbnail {
|
||||
cache_key: job.cache_key,
|
||||
model_path: job.model_path,
|
||||
root,
|
||||
meshes_ready: false,
|
||||
mesh_warmup_frames: 0,
|
||||
camera_active: false,
|
||||
post_attach_frames: 0,
|
||||
framed: false,
|
||||
frames_remaining: 0,
|
||||
wait_frames: 0,
|
||||
});
|
||||
}
|
||||
|
||||
fn finish_active_thumbnail(
|
||||
commands: &mut Commands,
|
||||
studio: &mut ThumbnailStudio,
|
||||
cameras: &mut Query<&mut Camera, With<ThumbnailStudioCamera>>,
|
||||
active: ActiveModelThumbnail,
|
||||
) {
|
||||
despawn_thumbnail_root(commands, active.root);
|
||||
deactivate_studio_camera(cameras, studio.camera);
|
||||
studio.cooldown_frames = COOLDOWN_FRAMES;
|
||||
}
|
||||
|
||||
fn fail_active_thumbnail(
|
||||
commands: &mut Commands,
|
||||
studio: &mut ThumbnailStudio,
|
||||
cache: &mut AssetThumbnailCache,
|
||||
cameras: &mut Query<&mut Camera, With<ThumbnailStudioCamera>>,
|
||||
active: ActiveModelThumbnail,
|
||||
reason: &str,
|
||||
retryable: bool,
|
||||
) {
|
||||
cache.mark_studio_failed(&active.cache_key, reason, retryable);
|
||||
finish_active_thumbnail(commands, studio, cameras, active);
|
||||
}
|
||||
|
||||
fn apply_thumbnail_studio_layers(
|
||||
mut commands: Commands,
|
||||
roots: Query<Entity, With<ThumbnailSceneRoot>>,
|
||||
children: Query<&Children>,
|
||||
tagged: Query<(), With<ThumbnailStudioLayer>>,
|
||||
) {
|
||||
for root in &roots {
|
||||
let mut stack = vec![root];
|
||||
while let Some(entity) = stack.pop() {
|
||||
if tagged.get(entity).is_err() {
|
||||
commands
|
||||
.entity(entity)
|
||||
.insert((ThumbnailStudioLayer, RenderLayers::layer(THUMBNAIL_LAYER)));
|
||||
}
|
||||
if let Ok(kids) = children.get(entity) {
|
||||
stack.extend(kids.iter());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn deactivate_studio_camera(
|
||||
cameras: &mut Query<&mut Camera, With<ThumbnailStudioCamera>>,
|
||||
camera: Entity,
|
||||
) {
|
||||
if let Ok(mut camera) = cameras.get_mut(camera) {
|
||||
camera.is_active = false;
|
||||
}
|
||||
}
|
||||
|
||||
fn despawn_thumbnail_root(commands: &mut Commands, root: Entity) {
|
||||
commands.entity(root).despawn_children();
|
||||
commands.entity(root).despawn();
|
||||
}
|
||||
|
||||
fn thumbnail_meshes_present(
|
||||
root: Entity,
|
||||
children: &Query<&Children>,
|
||||
meshes: &Query<&Mesh3d>,
|
||||
mesh_assets: &Assets<Mesh>,
|
||||
) -> bool {
|
||||
let mut entities = Vec::new();
|
||||
collect_descendants(root, children, &mut entities);
|
||||
|
||||
entities.iter().any(|&entity| {
|
||||
let Ok(mesh3d) = meshes.get(entity) else {
|
||||
return false;
|
||||
};
|
||||
mesh_assets
|
||||
.get(&mesh3d.0)
|
||||
.is_some_and(|mesh| mesh_positions(mesh).next().is_some())
|
||||
})
|
||||
}
|
||||
|
||||
fn mesh_positions(mesh: &Mesh) -> impl Iterator<Item = [f32; 3]> + '_ {
|
||||
let positions = mesh
|
||||
.attribute(Mesh::ATTRIBUTE_POSITION)
|
||||
.and_then(|values| match values {
|
||||
VertexAttributeValues::Float32x3(positions) => Some(positions.as_slice()),
|
||||
_ => None,
|
||||
});
|
||||
positions
|
||||
.into_iter()
|
||||
.flat_map(|slice| slice.iter().copied())
|
||||
}
|
||||
|
||||
fn frame_thumbnail_scene(
|
||||
root: Entity,
|
||||
children: &Query<&Children>,
|
||||
transforms: &Query<&GlobalTransform>,
|
||||
meshes: &Query<&Mesh3d>,
|
||||
mesh_assets: &Assets<Mesh>,
|
||||
camera_transforms: &mut Query<&mut Transform, With<ThumbnailStudioCamera>>,
|
||||
) -> bool {
|
||||
let mut entities = Vec::new();
|
||||
collect_descendants(root, children, &mut entities);
|
||||
|
||||
let mut min = Vec3::splat(f32::INFINITY);
|
||||
let mut max = Vec3::splat(f32::NEG_INFINITY);
|
||||
let mut found_mesh = false;
|
||||
|
||||
for entity in entities {
|
||||
let Ok(mesh3d) = meshes.get(entity) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(global) = transforms.get(entity) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if let Some(mesh) = mesh_assets.get(&mesh3d.0) {
|
||||
let mut local_min = Vec3::splat(f32::INFINITY);
|
||||
let mut local_max = Vec3::splat(f32::NEG_INFINITY);
|
||||
let mut has_positions = false;
|
||||
for position in mesh_positions(mesh) {
|
||||
let p = Vec3::from_array(position);
|
||||
local_min = local_min.min(p);
|
||||
local_max = local_max.max(p);
|
||||
has_positions = true;
|
||||
}
|
||||
if has_positions {
|
||||
found_mesh = true;
|
||||
for corner in aabb_corners(&Aabb3d::from_min_max(
|
||||
Vec3A::from(local_min),
|
||||
Vec3A::from(local_max),
|
||||
)) {
|
||||
let world = global.transform_point(corner);
|
||||
min = min.min(world);
|
||||
max = max.max(world);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !found_mesh {
|
||||
return false;
|
||||
}
|
||||
|
||||
let center = (min + max) * 0.5;
|
||||
let radius = (max - min).length().max(0.25) * 0.5;
|
||||
let distance = radius / (20.0_f32.to_radians()).tan() * 1.35;
|
||||
let eye = center + Vec3::new(1.0, 0.75, 1.0).normalize() * distance;
|
||||
|
||||
if let Ok(mut transform) = camera_transforms.single_mut() {
|
||||
*transform = Transform::from_translation(eye).looking_at(center, Vec3::Y);
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn collect_descendants(entity: Entity, children: &Query<&Children>, out: &mut Vec<Entity>) {
|
||||
out.push(entity);
|
||||
let Ok(child_list) = children.get(entity) else {
|
||||
return;
|
||||
};
|
||||
for child in child_list.iter() {
|
||||
collect_descendants(child, children, out);
|
||||
}
|
||||
}
|
||||
|
||||
fn aabb_corners(aabb: &Aabb3d) -> [Vec3; 8] {
|
||||
let min = Vec3::from(aabb.min);
|
||||
let max = Vec3::from(aabb.max);
|
||||
[
|
||||
Vec3::new(min.x, min.y, min.z),
|
||||
Vec3::new(max.x, min.y, min.z),
|
||||
Vec3::new(min.x, max.y, min.z),
|
||||
Vec3::new(max.x, max.y, min.z),
|
||||
Vec3::new(min.x, min.y, max.z),
|
||||
Vec3::new(max.x, min.y, max.z),
|
||||
Vec3::new(min.x, max.y, max.z),
|
||||
Vec3::new(max.x, max.y, max.z),
|
||||
]
|
||||
}
|
||||
|
||||
/// Skip studio jobs when the source file is missing on disk.
|
||||
pub fn model_file_exists(catalog_path: &str) -> bool {
|
||||
Path::new(catalog_path).is_file()
|
||||
}
|
||||
13
crates/editor/src/bin/main.rs
Normal file
13
crates/editor/src/bin/main.rs
Normal file
@ -0,0 +1,13 @@
|
||||
//! In-process editor binary entry point.
|
||||
|
||||
fn main() {
|
||||
if std::env::var_os("WGPU_VALIDATION").is_none() {
|
||||
// SAFETY: called before any threads or GPU init.
|
||||
unsafe { std::env::set_var("WGPU_VALIDATION", "0") };
|
||||
}
|
||||
|
||||
let mut app = bevy::prelude::App::new();
|
||||
editor::configure_editor_app(&mut app);
|
||||
editor::register_game_editor_plugins(&mut app);
|
||||
app.run();
|
||||
}
|
||||
13
crates/editor/src/ext/brp.rs
Normal file
13
crates/editor/src/ext/brp.rs
Normal file
@ -0,0 +1,13 @@
|
||||
use bevy::prelude::*;
|
||||
use bevy::remote::{http::RemoteHttpPlugin, RemotePlugin};
|
||||
|
||||
/// Bevy Remote Protocol support for future external editor, automation, and AI
|
||||
/// tooling. This is editor-only so the shipped game does not expose a port.
|
||||
pub struct BrpPlugin;
|
||||
|
||||
impl Plugin for BrpPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_plugins(RemotePlugin::default())
|
||||
.add_plugins(RemoteHttpPlugin::default());
|
||||
}
|
||||
}
|
||||
15
crates/editor/src/ext/command_queue.rs
Normal file
15
crates/editor/src/ext/command_queue.rs
Normal file
@ -0,0 +1,15 @@
|
||||
//! Deferred editor command execution (palette, BRP bridge).
|
||||
|
||||
use bevy::prelude::*;
|
||||
|
||||
#[derive(Resource, Default)]
|
||||
pub struct PendingEditorCommands {
|
||||
pub queue: Vec<String>,
|
||||
}
|
||||
|
||||
pub fn queue_editor_command(world: &mut World, name: String) {
|
||||
world
|
||||
.resource_mut::<PendingEditorCommands>()
|
||||
.queue
|
||||
.push(name);
|
||||
}
|
||||
370
crates/editor/src/ext/extensibility.rs
Normal file
370
crates/editor/src/ext/extensibility.rs
Normal file
@ -0,0 +1,370 @@
|
||||
//! Editor extensibility: command registry, inspector sections, and extension hooks.
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy_egui::{egui, EguiContexts, EguiPrimaryContextPass};
|
||||
|
||||
use crate::history::group_selection_with_history;
|
||||
use crate::state::{EditorMode, PlayPossession};
|
||||
use crate::ui::helpers::{
|
||||
reset_scene_lighting_to_project_defaults, toggle_play_mode, toggle_play_paused,
|
||||
};
|
||||
use crate::ui::selection_ops::focus_editor_camera_on_selection;
|
||||
use crate::ui::theme::{apply_editor_theme, PANEL_BG, TEXT, TEXT_DIM};
|
||||
use crate::ui::UiState;
|
||||
|
||||
/// Named editor command invokable from the palette and BRP.
|
||||
pub trait EditorCommand: Send + Sync {
|
||||
fn name(&self) -> &str;
|
||||
fn execute(&self, world: &mut World);
|
||||
}
|
||||
|
||||
#[derive(Resource, Default)]
|
||||
pub struct EditorCommandRegistry {
|
||||
commands: Vec<Box<dyn EditorCommand>>,
|
||||
}
|
||||
|
||||
impl EditorCommandRegistry {
|
||||
pub fn register(&mut self, command: Box<dyn EditorCommand>) {
|
||||
self.commands.push(command);
|
||||
}
|
||||
|
||||
pub fn names(&self) -> Vec<String> {
|
||||
self.commands
|
||||
.iter()
|
||||
.map(|cmd| cmd.name().to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn run(&self, world: &mut World, name: &str) -> bool {
|
||||
if let Some(command) = self.commands.iter().find(|cmd| cmd.name() == name) {
|
||||
command.execute(world);
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Custom inspector body for level objects (game crates register via [`register_actor_inspector_section`]).
|
||||
pub trait ActorInspectorSection: Send + Sync {
|
||||
fn id(&self) -> &str;
|
||||
fn title(&self) -> &str;
|
||||
fn order(&self) -> i32;
|
||||
fn applies_to(&self, world: &World, entity: Entity) -> bool;
|
||||
fn ui(&self, world: &mut World, ui: &mut egui::Ui, entity: Entity);
|
||||
}
|
||||
|
||||
#[derive(Resource, Default)]
|
||||
pub struct ActorInspectorSectionRegistry {
|
||||
sections: Vec<Box<dyn ActorInspectorSection>>,
|
||||
}
|
||||
|
||||
impl ActorInspectorSectionRegistry {
|
||||
pub fn register(&mut self, section: Box<dyn ActorInspectorSection>) {
|
||||
self.sections.push(section);
|
||||
}
|
||||
|
||||
pub fn sections_for(&self, world: &World, entity: Entity) -> Vec<&dyn ActorInspectorSection> {
|
||||
let mut matched: Vec<_> = self
|
||||
.sections
|
||||
.iter()
|
||||
.filter(|section| section.applies_to(world, entity))
|
||||
.map(|section| section.as_ref())
|
||||
.collect();
|
||||
matched.sort_by_key(|section| section.order());
|
||||
matched
|
||||
}
|
||||
|
||||
pub fn draw_sections(&self, world: &mut World, ui: &mut egui::Ui, entity: Entity) {
|
||||
let mut matched: Vec<_> = self
|
||||
.sections
|
||||
.iter()
|
||||
.filter(|section| section.applies_to(world, entity))
|
||||
.collect();
|
||||
matched.sort_by_key(|section| section.order());
|
||||
for section in matched {
|
||||
section.ui(world, ui, entity);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn register_actor_inspector_section(app: &mut App, section: Box<dyn ActorInspectorSection>) {
|
||||
if !app
|
||||
.world()
|
||||
.contains_resource::<ActorInspectorSectionRegistry>()
|
||||
{
|
||||
app.init_resource::<ActorInspectorSectionRegistry>();
|
||||
}
|
||||
app.world_mut()
|
||||
.resource_mut::<ActorInspectorSectionRegistry>()
|
||||
.register(section);
|
||||
}
|
||||
|
||||
/// Optional extension point for registering editor panels and menu hooks.
|
||||
pub trait EditorExtension: Send + Sync {
|
||||
fn name(&self) -> &str;
|
||||
fn register(&self, _app: &mut App) {}
|
||||
}
|
||||
|
||||
/// Formal editor plugin surface for game-specific panels (H5).
|
||||
pub trait EditorPlugin: Send + Sync {
|
||||
fn name(&self) -> &str;
|
||||
fn register(&self, app: &mut App);
|
||||
}
|
||||
|
||||
#[derive(Resource, Default)]
|
||||
pub struct EditorExtensions {
|
||||
pub extensions: Vec<Box<dyn EditorExtension>>,
|
||||
}
|
||||
|
||||
#[derive(Resource, Default)]
|
||||
pub struct RegisteredEditorPlugins {
|
||||
pub plugins: Vec<Box<dyn EditorPlugin>>,
|
||||
}
|
||||
|
||||
#[derive(Resource, Default)]
|
||||
pub struct CommandPalette {
|
||||
pub open: bool,
|
||||
pub filter: String,
|
||||
pub pending_run: Option<String>,
|
||||
}
|
||||
|
||||
pub struct ExtensibilityPlugin;
|
||||
|
||||
impl Plugin for ExtensibilityPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<EditorCommandRegistry>()
|
||||
.init_resource::<ActorInspectorSectionRegistry>()
|
||||
.init_resource::<EditorExtensions>()
|
||||
.init_resource::<RegisteredEditorPlugins>()
|
||||
.init_resource::<CommandPalette>()
|
||||
.init_resource::<crate::command_queue::PendingEditorCommands>()
|
||||
.add_systems(Startup, register_builtin_commands)
|
||||
.add_systems(Update, run_palette_commands)
|
||||
.add_systems(EguiPrimaryContextPass, command_palette_ui);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn register_editor_plugin(app: &mut App, plugin: Box<dyn EditorPlugin>) {
|
||||
plugin.register(app);
|
||||
app.world_mut()
|
||||
.resource_mut::<RegisteredEditorPlugins>()
|
||||
.plugins
|
||||
.push(plugin);
|
||||
}
|
||||
|
||||
fn register_builtin_commands(mut registry: ResMut<EditorCommandRegistry>) {
|
||||
registry.register(Box::new(TogglePlayCommand));
|
||||
registry.register(Box::new(TogglePlayPausedCommand));
|
||||
registry.register(Box::new(TogglePossessionCommand));
|
||||
registry.register(Box::new(ResetLightingCommand));
|
||||
registry.register(Box::new(GroupSelectionCommand));
|
||||
registry.register(Box::new(FocusSelectionCommand));
|
||||
registry.register(Box::new(CreatePostProcessVolumeCommand));
|
||||
registry.register(Box::new(FocusActiveVolumesCommand));
|
||||
registry.register(Box::new(SelectVolumesAtCameraCommand));
|
||||
}
|
||||
|
||||
fn run_palette_commands(world: &mut World) {
|
||||
let pending = world.resource_mut::<CommandPalette>().pending_run.take();
|
||||
if let Some(name) = pending {
|
||||
dispatch_editor_command(world, &name);
|
||||
}
|
||||
let names = std::mem::take(
|
||||
&mut world
|
||||
.resource_mut::<crate::command_queue::PendingEditorCommands>()
|
||||
.queue,
|
||||
);
|
||||
for name in names {
|
||||
dispatch_editor_command(world, &name);
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch_editor_command(world: &mut World, name: &str) {
|
||||
let ran = world
|
||||
.resource_scope(|world, registry: Mut<EditorCommandRegistry>| registry.run(world, name));
|
||||
if !ran {
|
||||
warn!("unknown editor command: {name}");
|
||||
}
|
||||
}
|
||||
|
||||
struct TogglePlayCommand;
|
||||
|
||||
impl EditorCommand for TogglePlayCommand {
|
||||
fn name(&self) -> &str {
|
||||
"play.toggle"
|
||||
}
|
||||
|
||||
fn execute(&self, world: &mut World) {
|
||||
toggle_play_mode(world);
|
||||
}
|
||||
}
|
||||
|
||||
struct TogglePlayPausedCommand;
|
||||
|
||||
impl EditorCommand for TogglePlayPausedCommand {
|
||||
fn name(&self) -> &str {
|
||||
"play.toggle_pause"
|
||||
}
|
||||
|
||||
fn execute(&self, world: &mut World) {
|
||||
toggle_play_paused(world);
|
||||
}
|
||||
}
|
||||
|
||||
struct TogglePossessionCommand;
|
||||
|
||||
impl EditorCommand for TogglePossessionCommand {
|
||||
fn name(&self) -> &str {
|
||||
"play.toggle_possession"
|
||||
}
|
||||
|
||||
fn execute(&self, world: &mut World) {
|
||||
if *world.resource::<State<EditorMode>>().get() != EditorMode::Playing {
|
||||
return;
|
||||
}
|
||||
let next = match *world.resource::<PlayPossession>() {
|
||||
PlayPossession::Possessed => PlayPossession::Ejected,
|
||||
PlayPossession::Ejected => PlayPossession::Possessed,
|
||||
};
|
||||
*world.resource_mut::<PlayPossession>() = next;
|
||||
}
|
||||
}
|
||||
|
||||
struct ResetLightingCommand;
|
||||
|
||||
impl EditorCommand for ResetLightingCommand {
|
||||
fn name(&self) -> &str {
|
||||
"scene.reset_lighting"
|
||||
}
|
||||
|
||||
fn execute(&self, world: &mut World) {
|
||||
reset_scene_lighting_to_project_defaults(world);
|
||||
}
|
||||
}
|
||||
|
||||
struct GroupSelectionCommand;
|
||||
|
||||
impl EditorCommand for GroupSelectionCommand {
|
||||
fn name(&self) -> &str {
|
||||
"selection.group"
|
||||
}
|
||||
|
||||
fn execute(&self, world: &mut World) {
|
||||
let selected: Vec<Entity> = world
|
||||
.resource::<UiState>()
|
||||
.selected_entities
|
||||
.iter()
|
||||
.collect();
|
||||
group_selection_with_history(world, &selected);
|
||||
}
|
||||
}
|
||||
|
||||
struct FocusSelectionCommand;
|
||||
|
||||
impl EditorCommand for FocusSelectionCommand {
|
||||
fn name(&self) -> &str {
|
||||
"selection.focus"
|
||||
}
|
||||
|
||||
fn execute(&self, world: &mut World) {
|
||||
focus_editor_camera_on_selection(world);
|
||||
}
|
||||
}
|
||||
|
||||
struct CreatePostProcessVolumeCommand;
|
||||
|
||||
impl EditorCommand for CreatePostProcessVolumeCommand {
|
||||
fn name(&self) -> &str {
|
||||
"rendering.create_volume"
|
||||
}
|
||||
|
||||
fn execute(&self, world: &mut World) {
|
||||
crate::rendering_diagnostics::spawn_post_process_volume_at_camera(world);
|
||||
}
|
||||
}
|
||||
|
||||
struct FocusActiveVolumesCommand;
|
||||
|
||||
impl EditorCommand for FocusActiveVolumesCommand {
|
||||
fn name(&self) -> &str {
|
||||
"rendering.focus_active_volumes"
|
||||
}
|
||||
|
||||
fn execute(&self, world: &mut World) {
|
||||
crate::rendering_diagnostics::select_volumes_at_camera(world);
|
||||
focus_editor_camera_on_selection(world);
|
||||
}
|
||||
}
|
||||
|
||||
struct SelectVolumesAtCameraCommand;
|
||||
|
||||
impl EditorCommand for SelectVolumesAtCameraCommand {
|
||||
fn name(&self) -> &str {
|
||||
"rendering.select_volumes_at_camera"
|
||||
}
|
||||
|
||||
fn execute(&self, world: &mut World) {
|
||||
crate::rendering_diagnostics::select_volumes_at_camera(world);
|
||||
}
|
||||
}
|
||||
|
||||
fn command_palette_ui(
|
||||
mut contexts: EguiContexts,
|
||||
mut palette: ResMut<CommandPalette>,
|
||||
registry: Res<EditorCommandRegistry>,
|
||||
) -> Result {
|
||||
let Ok(ctx) = contexts.ctx_mut() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
apply_editor_theme(ctx);
|
||||
|
||||
if ctx.input(|input| input.modifiers.command && input.key_pressed(egui::Key::P)) {
|
||||
palette.open = true;
|
||||
}
|
||||
|
||||
if !palette.open {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut open = palette.open;
|
||||
egui::Window::new("Command Palette")
|
||||
.open(&mut open)
|
||||
.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(),
|
||||
);
|
||||
ui.add(
|
||||
egui::TextEdit::singleline(&mut palette.filter)
|
||||
.hint_text("Type to filter...")
|
||||
.desired_width(f32::INFINITY),
|
||||
);
|
||||
ui.separator();
|
||||
egui::ScrollArea::vertical()
|
||||
.max_height(280.0)
|
||||
.show(ui, |ui| {
|
||||
for name in registry.names() {
|
||||
if !palette.filter.is_empty()
|
||||
&& !name.to_lowercase().contains(&palette.filter.to_lowercase())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let response =
|
||||
ui.selectable_label(false, egui::RichText::new(&name).color(TEXT));
|
||||
if response.clicked() {
|
||||
palette.pending_run = Some(name);
|
||||
palette.open = false;
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
palette.open = open;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
50
crates/editor/src/ext/game_inspector.rs
Normal file
50
crates/editor/src/ext/game_inspector.rs
Normal file
@ -0,0 +1,50 @@
|
||||
//! Game-specific [`ActorInspectorSection`] implementations (ADR 0007 dogfood).
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy_egui::egui;
|
||||
use shared::{LevelObject, WeaponSpawn};
|
||||
|
||||
use super::extensibility::{register_actor_inspector_section, ActorInspectorSection};
|
||||
|
||||
/// Registers inspector sections declared in [`game::editor_ext::actor_inspector_section_ids`].
|
||||
pub fn register_game_inspector_sections(app: &mut App) {
|
||||
for id in game::editor_ext::actor_inspector_section_ids() {
|
||||
match *id {
|
||||
"game.fps_weapon_stats" => {
|
||||
register_actor_inspector_section(app, Box::new(FpsWeaponStatsSection));
|
||||
}
|
||||
other => {
|
||||
warn!("unknown game actor inspector section id: {other}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct FpsWeaponStatsSection;
|
||||
|
||||
impl ActorInspectorSection for FpsWeaponStatsSection {
|
||||
fn id(&self) -> &str {
|
||||
"game.fps_weapon_stats"
|
||||
}
|
||||
|
||||
fn title(&self) -> &str {
|
||||
"Weapon Stats (Demo)"
|
||||
}
|
||||
|
||||
fn order(&self) -> i32 {
|
||||
200
|
||||
}
|
||||
|
||||
fn applies_to(&self, world: &World, entity: Entity) -> bool {
|
||||
world
|
||||
.get_entity(entity)
|
||||
.is_ok_and(|e| e.contains::<LevelObject>() && e.contains::<WeaponSpawn>())
|
||||
}
|
||||
|
||||
fn ui(&self, _world: &mut World, ui: &mut egui::Ui, entity: Entity) {
|
||||
ui.collapsing(self.title(), |ui| {
|
||||
ui.label(format!("Entity {entity:?}"));
|
||||
ui.small("Registered from `game::editor_ext` — extend with gameplay fields.");
|
||||
});
|
||||
}
|
||||
}
|
||||
48
crates/editor/src/ext/game_panels.rs
Normal file
48
crates/editor/src/ext/game_panels.rs
Normal file
@ -0,0 +1,48 @@
|
||||
//! Renders game-registered editor panel state (ADR 0007 dogfood UI).
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy_egui::{egui, EguiContexts, EguiPrimaryContextPass};
|
||||
|
||||
use game::editor_ext::FpsDemoPanelState;
|
||||
|
||||
pub struct GameEditorPanelsPlugin;
|
||||
|
||||
impl Plugin for GameEditorPanelsPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_systems(EguiPrimaryContextPass, fps_demo_panel_ui);
|
||||
}
|
||||
}
|
||||
|
||||
fn fps_demo_panel_ui(mut contexts: EguiContexts, mut panel: ResMut<FpsDemoPanelState>) -> Result {
|
||||
let Ok(ctx) = contexts.ctx_mut() else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
if panel.headline.is_empty() {
|
||||
panel.headline = "FPS Foundation Demo".into();
|
||||
}
|
||||
|
||||
if ctx.input(|input| {
|
||||
input.modifiers.command && input.key_pressed(egui::Key::G) && input.modifiers.shift
|
||||
}) {
|
||||
panel.open = !panel.open;
|
||||
}
|
||||
|
||||
if !panel.open {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut open = panel.open;
|
||||
egui::Window::new("FPS Demo (Game EditorPlugin)")
|
||||
.open(&mut open)
|
||||
.default_width(280.0)
|
||||
.show(ctx, |ui| {
|
||||
ui.label("Registered from `game::editor_ext` via ADR 0007.");
|
||||
ui.small("Toggle: Ctrl+Shift+G");
|
||||
ui.separator();
|
||||
ui.heading(&panel.headline);
|
||||
ui.label("Game crate owns panel state; editor renders the dock window.");
|
||||
});
|
||||
panel.open = open;
|
||||
Ok(())
|
||||
}
|
||||
149
crates/editor/src/ext/hot_reload.rs
Normal file
149
crates/editor/src/ext/hot_reload.rs
Normal file
@ -0,0 +1,149 @@
|
||||
//! Dev-only hot reload polling and status for the `game_hot` dylib.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{mpsc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use bevy::prelude::*;
|
||||
use game::{hot_reload_subscribe, on_hot_reload};
|
||||
use hot_lib_reloader::LibReloadObserver;
|
||||
use notify::Event;
|
||||
|
||||
/// Status label shown in the bottom status bar.
|
||||
#[derive(Resource, Debug, Clone)]
|
||||
pub struct HotReloadState {
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
impl Default for HotReloadState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
label: "Hot reload: idle".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct HotReloadPlugin;
|
||||
|
||||
impl Plugin for HotReloadPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<HotReloadState>()
|
||||
.add_systems(Startup, spawn_dylib_watcher)
|
||||
.add_systems(Last, poll_lib_reload)
|
||||
.add_systems(Last, manual_hot_reload);
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_dylib_watcher(mut commands: Commands) {
|
||||
let Some(path) = game_hot_dylib_path() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let watch_path = path.clone();
|
||||
std::thread::spawn(move || {
|
||||
use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher};
|
||||
|
||||
let tx = tx.clone();
|
||||
let mut watcher = RecommendedWatcher::new(
|
||||
move |result: notify::Result<Event>| {
|
||||
if let Ok(event) = result {
|
||||
if matches!(event.kind, EventKind::Modify(_) | EventKind::Create(_)) {
|
||||
let _ = tx.send(());
|
||||
}
|
||||
}
|
||||
},
|
||||
notify::Config::default(),
|
||||
)
|
||||
.expect("hot reload watcher");
|
||||
|
||||
if let Some(parent) = watch_path.parent() {
|
||||
let _ = watcher.watch(parent, RecursiveMode::NonRecursive);
|
||||
}
|
||||
|
||||
loop {
|
||||
std::thread::sleep(Duration::from_secs(3600));
|
||||
}
|
||||
});
|
||||
|
||||
commands.insert_resource(DylibWatchReceiver(Mutex::new(rx)));
|
||||
info!("Watching for game_hot dylib changes at {}", path.display());
|
||||
}
|
||||
|
||||
#[derive(Resource)]
|
||||
struct DylibWatchReceiver(Mutex<mpsc::Receiver<()>>);
|
||||
|
||||
fn poll_lib_reload(world: &mut World, mut observer: Local<Option<LibReloadObserver>>) {
|
||||
if observer.is_none() {
|
||||
*observer = Some(hot_reload_subscribe());
|
||||
}
|
||||
|
||||
let mut building = false;
|
||||
if let Some(watch) = world.get_resource::<DylibWatchReceiver>() {
|
||||
if let Ok(rx) = watch.0.lock() {
|
||||
while rx.try_recv().is_ok() {
|
||||
building = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if building {
|
||||
world.resource_mut::<HotReloadState>().label = "Hot reload: building…".into();
|
||||
}
|
||||
|
||||
let Some(obs) = observer.as_ref() else {
|
||||
return;
|
||||
};
|
||||
|
||||
if obs.wait_for_reload_timeout(Duration::ZERO) {
|
||||
on_hot_reload(world);
|
||||
world.resource_mut::<HotReloadState>().label = "Hot reload: reloaded".into();
|
||||
info!("game_hot dylib reloaded");
|
||||
}
|
||||
}
|
||||
|
||||
fn manual_hot_reload(world: &mut World) {
|
||||
let ctrl = world
|
||||
.resource::<ButtonInput<KeyCode>>()
|
||||
.pressed(KeyCode::ControlLeft)
|
||||
|| world
|
||||
.resource::<ButtonInput<KeyCode>>()
|
||||
.pressed(KeyCode::ControlRight);
|
||||
let shift = world
|
||||
.resource::<ButtonInput<KeyCode>>()
|
||||
.pressed(KeyCode::ShiftLeft)
|
||||
|| world
|
||||
.resource::<ButtonInput<KeyCode>>()
|
||||
.pressed(KeyCode::ShiftRight);
|
||||
let reload = world
|
||||
.resource::<ButtonInput<KeyCode>>()
|
||||
.just_pressed(KeyCode::KeyR);
|
||||
|
||||
if ctrl && shift && reload {
|
||||
on_hot_reload(world);
|
||||
world.resource_mut::<HotReloadState>().label = "Hot reload: manual refresh".into();
|
||||
info!("Manual hot reload refresh (Ctrl+Shift+R)");
|
||||
}
|
||||
}
|
||||
|
||||
fn game_hot_dylib_path() -> Option<PathBuf> {
|
||||
let target = std::env::var("CARGO_TARGET_DIR").map_or_else(
|
||||
|_| PathBuf::from(concat!(env!("CARGO_MANIFEST_DIR"), "/../../target")),
|
||||
PathBuf::from,
|
||||
);
|
||||
let profile = if cfg!(debug_assertions) {
|
||||
"debug"
|
||||
} else {
|
||||
"release"
|
||||
};
|
||||
Some(target.join(profile).join(game_hot_lib_name()))
|
||||
}
|
||||
|
||||
fn game_hot_lib_name() -> &'static str {
|
||||
if cfg!(target_os = "macos") {
|
||||
"libgame_hot.dylib"
|
||||
} else if cfg!(target_os = "windows") {
|
||||
"game_hot.dll"
|
||||
} else {
|
||||
"libgame_hot.so"
|
||||
}
|
||||
}
|
||||
9
crates/editor/src/ext/mod.rs
Normal file
9
crates/editor/src/ext/mod.rs
Normal file
@ -0,0 +1,9 @@
|
||||
//! Editor extensibility, BRP, command queue, and optional hot reload.
|
||||
|
||||
pub mod brp;
|
||||
pub mod command_queue;
|
||||
pub mod extensibility;
|
||||
pub mod game_inspector;
|
||||
pub mod game_panels;
|
||||
#[cfg(feature = "hot-reload")]
|
||||
pub mod hot_reload;
|
||||
151
crates/editor/src/history/commands.rs
Normal file
151
crates/editor/src/history/commands.rs
Normal file
@ -0,0 +1,151 @@
|
||||
use bevy::prelude::*;
|
||||
use shared::{
|
||||
ActorId, ActorKind, ActorName, ColliderDesc, EditorVisibility, InspectorOrder, LightDesc,
|
||||
MaterialDesc, MaterialOverride, ModelRef, ObjectiveMarker, PhysicsBody, PostProcessVolumeDesc,
|
||||
PrefabInstance, PrefabRef, Primitive, RigidBodyDesc, StaticMeshRenderer, TeamSpawn,
|
||||
TriggerVolume, WeaponSpawn,
|
||||
};
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EditorEntitySnapshot {
|
||||
pub actor_id: Option<ActorId>,
|
||||
pub actor_kind: ActorKind,
|
||||
pub actor_name: Option<ActorName>,
|
||||
pub name: Option<String>,
|
||||
pub transform: Transform,
|
||||
pub primitive: Option<Primitive>,
|
||||
pub static_mesh_renderer: Option<StaticMeshRenderer>,
|
||||
pub material: Option<MaterialDesc>,
|
||||
pub material_override: Option<MaterialOverride>,
|
||||
pub rigid_body: Option<RigidBodyDesc>,
|
||||
pub collider: Option<ColliderDesc>,
|
||||
pub physics: Option<PhysicsBody>,
|
||||
pub light: Option<LightDesc>,
|
||||
pub player_spawn: bool,
|
||||
pub model: Option<ModelRef>,
|
||||
pub prefab: Option<PrefabRef>,
|
||||
pub prefab_instance: Option<PrefabInstance>,
|
||||
pub weapon_spawn: Option<WeaponSpawn>,
|
||||
pub trigger_volume: Option<TriggerVolume>,
|
||||
pub post_process_volume: Option<PostProcessVolumeDesc>,
|
||||
pub team_spawn: Option<TeamSpawn>,
|
||||
pub objective: Option<ObjectiveMarker>,
|
||||
pub hierarchy_sibling_index: i32,
|
||||
pub editor_visibility: EditorVisibility,
|
||||
pub children: Vec<EditorEntitySnapshot>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SiblingChange {
|
||||
pub entity: Entity,
|
||||
pub old_parent: Option<Entity>,
|
||||
pub new_parent: Option<Entity>,
|
||||
pub old_index: i32,
|
||||
pub new_index: i32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum EditorCommand {
|
||||
Spawn {
|
||||
snapshot: EditorEntitySnapshot,
|
||||
entity: Option<Entity>,
|
||||
},
|
||||
Despawn {
|
||||
snapshot: EditorEntitySnapshot,
|
||||
entity: Option<Entity>,
|
||||
},
|
||||
Duplicate {
|
||||
snapshots: Vec<EditorEntitySnapshot>,
|
||||
entities: Vec<Entity>,
|
||||
},
|
||||
Rename {
|
||||
entity: Entity,
|
||||
old: String,
|
||||
new: String,
|
||||
},
|
||||
SetTransform {
|
||||
entity: Entity,
|
||||
old: Transform,
|
||||
new: Transform,
|
||||
},
|
||||
SetMaterial {
|
||||
entity: Entity,
|
||||
old: Option<MaterialDesc>,
|
||||
new: MaterialDesc,
|
||||
},
|
||||
SetMaterialOverride {
|
||||
entity: Entity,
|
||||
old: Option<MaterialOverride>,
|
||||
new: MaterialOverride,
|
||||
},
|
||||
SetLight {
|
||||
entity: Entity,
|
||||
old: Option<LightDesc>,
|
||||
new: LightDesc,
|
||||
},
|
||||
SetPhysics {
|
||||
entity: Entity,
|
||||
old: Option<PhysicsBody>,
|
||||
new: PhysicsBody,
|
||||
},
|
||||
SetRigidBody {
|
||||
entity: Entity,
|
||||
old: Option<RigidBodyDesc>,
|
||||
new: RigidBodyDesc,
|
||||
},
|
||||
SetCollider {
|
||||
entity: Entity,
|
||||
old: Option<ColliderDesc>,
|
||||
new: ColliderDesc,
|
||||
},
|
||||
SetPrimitive {
|
||||
entity: Entity,
|
||||
old: Option<Primitive>,
|
||||
new: Primitive,
|
||||
},
|
||||
SetStaticMeshRenderer {
|
||||
entity: Entity,
|
||||
old: Option<StaticMeshRenderer>,
|
||||
new: StaticMeshRenderer,
|
||||
},
|
||||
SetPostProcessVolume {
|
||||
entity: Entity,
|
||||
old: Option<PostProcessVolumeDesc>,
|
||||
new: PostProcessVolumeDesc,
|
||||
},
|
||||
SetTransformGroup {
|
||||
entities: Vec<Entity>,
|
||||
olds: Vec<Transform>,
|
||||
news: Vec<Transform>,
|
||||
},
|
||||
Reparent {
|
||||
entity: Entity,
|
||||
old_parent: Option<Entity>,
|
||||
new_parent: Option<Entity>,
|
||||
},
|
||||
SetActorKind {
|
||||
entity: Entity,
|
||||
old: ActorKind,
|
||||
new: ActorKind,
|
||||
},
|
||||
ReorderSibling {
|
||||
changes: Vec<SiblingChange>,
|
||||
},
|
||||
SetEditorVisibility {
|
||||
entity: Entity,
|
||||
old: EditorVisibility,
|
||||
new: EditorVisibility,
|
||||
},
|
||||
SetInspectorOrder {
|
||||
entity: Entity,
|
||||
old: Option<InspectorOrder>,
|
||||
new: InspectorOrder,
|
||||
},
|
||||
AddComponent {
|
||||
entity: Entity,
|
||||
snapshot: EditorEntitySnapshot,
|
||||
},
|
||||
RemoveComponent {
|
||||
entity: Entity,
|
||||
snapshot: EditorEntitySnapshot,
|
||||
},
|
||||
}
|
||||
1385
crates/editor/src/history/mod.rs
Normal file
1385
crates/editor/src/history/mod.rs
Normal file
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user