Build renderer and material component foundations
Add dedicated skinned rendering, pose restoration, shared Material and Material Instance slots, registry-driven components, Surface/Solari integration, transactional schema upgrades, navigation authoring, documentation, and evaluation evidence.
This commit is contained in:
parent
0553a85220
commit
0798aa5d57
@ -7,4 +7,6 @@ rustflags = ["-C", "link-arg=-fuse-ld=mold"]
|
||||
[alias]
|
||||
clean-target = "run -p xtask --bin clean-target --"
|
||||
validate-levels = "run -p xtask --features validate-levels --bin validate-levels --"
|
||||
bake-navigation = "run -p xtask --features validate-levels --bin bake-navigation --"
|
||||
package-project = "run -p xtask --features validate-levels --bin package-project --"
|
||||
upgrade-project = "run -p xtask --features validate-levels --bin upgrade-project --"
|
||||
|
||||
68
.cursor/plans/navigation_authoring_2026-07-11.plan.md
Normal file
68
.cursor/plans/navigation_authoring_2026-07-11.plan.md
Normal file
@ -0,0 +1,68 @@
|
||||
# Navigation Mesh Authoring, Bake Diagnostics, And Path Preview
|
||||
|
||||
Working implementation plan for Gitea issue
|
||||
[`#48`](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/48).
|
||||
This is the navigation production loop required by the M7 content-production milestone.
|
||||
|
||||
## Status
|
||||
|
||||
Architecture and dependency compatibility are established. Implementation and production
|
||||
acceptance are in progress.
|
||||
|
||||
## Outcome
|
||||
|
||||
A level designer can author navigation bounds, obstacles, areas, links, and agent profiles; bake a
|
||||
deterministic versioned artifact; inspect stale or invalid data; preview a path in the viewport; and
|
||||
use the same artifact through a game-owned runtime query API and headless release validation.
|
||||
|
||||
## Architecture
|
||||
|
||||
- Shared reflected components own bounds, obstacles, area volumes, links, and scene bake settings.
|
||||
- The scene crate owns deterministic source fingerprinting, bake artifact IO, and validation.
|
||||
- Rerecast performs the 3D walkable-surface bake without coupling navigation to a Bevy plugin
|
||||
release. Polyanya performs proven any-angle runtime and editor-preview path queries.
|
||||
- Overlapping primitive and additive-brush triangles enter the deterministic bake source; distant
|
||||
geometry and navigation authoring are excluded from each bounds fingerprint.
|
||||
- The game crate owns loading and querying baked artifacts. Editor UI calls that public API for
|
||||
preview rather than implementing a second pathfinder.
|
||||
- Generated artifacts live under `assets/navigation/generated/`, are project-relative runtime
|
||||
dependencies, and never serialize viewport helpers into authored scenes.
|
||||
|
||||
## Implementation Sequence
|
||||
|
||||
1. Add reflected authoring schema, versioned bake artifact, source fingerprint, validation, and
|
||||
exact agent-profile settings.
|
||||
2. Add deterministic Rerecast bake input extraction for authored bounds/obstacles and headless bake
|
||||
command coverage.
|
||||
3. Add game-owned artifact loading and Polyanya path-query API, including authored off-mesh links.
|
||||
4. Add typed inspectors, create commands, bake/stale status, viewport overlay, and start/end path
|
||||
preview.
|
||||
5. Add owner-attributed project validation for invalid links, missing/stale artifacts, unreachable
|
||||
preview samples, and bake failures.
|
||||
6. Commit a small sample scene/artifact and run automated, headless, live editor, PIE, and packaged
|
||||
runtime acceptance.
|
||||
|
||||
## Acceptance Gates
|
||||
|
||||
- A designer can create bounds and obstacles, bake, view the resulting mesh, and preview a valid
|
||||
start-to-end path.
|
||||
- Agent radius, height, climb, and slope settings deterministically affect the bake fingerprint.
|
||||
- Relevant geometry or navigation-authoring changes mark the artifact stale; unrelated scene
|
||||
metadata does not.
|
||||
- Invalid or dangling links, samples outside the mesh, isolated regions, and bake failures identify
|
||||
the owning actor or setting and provide a repair action.
|
||||
- Runtime path queries and editor preview use the same game-owned API and versioned artifact.
|
||||
- Helper meshes, lines, endpoints, and bake state are transient and never serialize.
|
||||
- Headless bake and project validation pass for the committed sample fixture, and package dependency
|
||||
collection includes the current artifact.
|
||||
|
||||
## Deliberate Boundaries
|
||||
|
||||
- V1 supports static baked navigation plus explicit links. Dynamic crowd avoidance and runtime tile
|
||||
carving are separate work.
|
||||
- Full partial-tile regeneration waits for upstream Rerecast tiling support. V1 records affected
|
||||
source bounds and debounces a deterministic full bake while reporting the dirty region.
|
||||
- Area costs are authored and validated in V1; path-cost weighting beyond walkable/blocked areas is
|
||||
deferred until the runtime exposes per-polygon cost callbacks.
|
||||
- Imported static meshes remain obstacle-authored in V1 because their normalized manifests do not
|
||||
yet expose deterministic collision triangles to the headless scene crate.
|
||||
@ -0,0 +1,43 @@
|
||||
# Renderer, material, and component foundation
|
||||
|
||||
## Goal
|
||||
|
||||
Separate static and skinned renderer ownership, restore deterministic skinned edit poses, add shared
|
||||
material/material-instance slots, and make authoring components registry-driven and composable.
|
||||
|
||||
## Component foundation milestone
|
||||
|
||||
- Give authoring components immutable IDs distinct from Rust type paths.
|
||||
- Split persisted active state from inspector ordering with legacy-state migration.
|
||||
- Route presence, persistence, add/remove/reset/copy/paste, and component history through Bevy
|
||||
reflection and atomic transactions.
|
||||
- Treat `ActorKind` as a derived compatibility/display hint; validate component data and explicit
|
||||
requirements/conflicts.
|
||||
- Provide one statically linked extension registration helper and reflection round-trip fixture.
|
||||
- Verify registry integrity, transaction undo/redo, scene save inclusion, composition, and legacy
|
||||
enable-state preservation.
|
||||
|
||||
## Related renderer/material milestone
|
||||
|
||||
- Keep `StaticMeshRenderer` and `SkinnedMeshRenderer` as separate authoring and hydration paths.
|
||||
- Give both renderer types stable imported-source/explicit Material slots and preserve orphaned
|
||||
assignments by ID across source changes.
|
||||
- Add versioned Material and direct-base Material Instance assets, sparse editor overrides, shared
|
||||
handle live refresh, and runtime-only property blocks.
|
||||
- Capture and restore imported Transform/MorphWeights baselines; sample only the explicitly chosen
|
||||
default edit clip and never reload a rig for a material-only change.
|
||||
- Compose one validated Surface evaluator into raster and Solari, including normal/emissive/unlit
|
||||
fields and exact cutout candidate acceptance. Safely omit skinned/morph geometry from Solari until
|
||||
instance-owned deformed vertices and dynamic BLAS updates exist.
|
||||
- Upgrade scenes/materials explicitly to schema v4 with dry-run, staged writes, backup, rollback,
|
||||
stable slot normalization, and active-state separation.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- Shared, scene, game, game-hot, editor, Surface, and maintained Solari-fork tests pass.
|
||||
- `cargo --locked validate-levels` reports zero blocking findings.
|
||||
- A second project-upgrade dry run reports no changes after apply.
|
||||
- Editor evidence shows the stable component cards, static/skinned material slots, Material Instance
|
||||
workflow, deterministic animated actor pose, and Surface/Solari diagnostics.
|
||||
- Gitea implementation issue and related roadmap issues link the documentation and screenshot
|
||||
evidence without claiming unsupported dynamic deformed BLAS behavior.
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -14,6 +14,7 @@
|
||||
# Editor/runtime generated session state
|
||||
/assets/levels/.pie_session.scn.ron
|
||||
/assets/.trash/
|
||||
/.blacksite/backups/
|
||||
|
||||
# Backup, temporary, and log files
|
||||
**/*.rs.bk
|
||||
|
||||
707
Cargo.lock
generated
707
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@ -1,5 +1,6 @@
|
||||
[workspace]
|
||||
members = [
|
||||
"crates/blacksite_surface",
|
||||
"crates/game",
|
||||
"crates/game_hot",
|
||||
"crates/shared",
|
||||
@ -36,6 +37,7 @@ bevy = { version = "0.19", features = [
|
||||
] }
|
||||
bevy_core_pipeline = "0.19"
|
||||
bevy_solari = "0.19"
|
||||
blacksite_surface = { path = "crates/blacksite_surface" }
|
||||
bevy_ufbx = "0.18.1-rc.1"
|
||||
bevy_egui = "0.40"
|
||||
bevy-inspector-egui = "0.37"
|
||||
@ -43,6 +45,9 @@ egui_dock = { version = "0.19.1", features = ["serde"] }
|
||||
egui_phosphor_icons = { version = "0.3.1", default-features = false }
|
||||
transform-gizmo-bevy = "0.9"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
nav_glam = { package = "glam", version = "0.30.8", features = ["serde"] }
|
||||
polyanya = { version = "0.16.1", default-features = false, features = ["recast", "serde"] }
|
||||
rerecast = { version = "0.3.2", default-features = false, features = ["std", "serialize"] }
|
||||
shared = { path = "crates/shared" }
|
||||
game = { path = "crates/game" }
|
||||
game_hot = { path = "crates/game_hot" }
|
||||
@ -52,6 +57,8 @@ settings = { path = "crates/settings" }
|
||||
scene = { path = "crates/scene" }
|
||||
|
||||
[patch.crates-io]
|
||||
# Blacksite Surface ABI and deformation-safety integration for Solari.
|
||||
bevy_solari = { path = "third_party/bevy_solari" }
|
||||
# Local Bevy 0.19 compatibility patch until upstream publishes a matching FBX loader.
|
||||
bevy_ufbx = { path = "third_party/bevy_ufbx" }
|
||||
# Lossless RON editing fixes used by transactional prefab source edits.
|
||||
|
||||
40
README.md
40
README.md
@ -27,6 +27,7 @@ cargo clippy --workspace --all-targets -- -D warnings
|
||||
cargo clippy -p editor -p game --bins --features dev,hot-reload -- -D warnings
|
||||
cargo test --workspace
|
||||
cargo validate-levels
|
||||
cargo bake-navigation --project . --check
|
||||
# Machine-readable project dependency and finding report
|
||||
cargo validate-levels --json
|
||||
cargo package-project --profile development
|
||||
@ -149,6 +150,7 @@ deep-stale variants.
|
||||
| Asset Browser project/file views | Browse `assets/`, search/filter/sort models, textures, materials, audio clips, levels, and prefabs; switch grid/list; expand model subassets; inspect file details; audition audio; drag supported assets/submeshes into the viewport |
|
||||
| Drag audio clip into viewport | Create an authored audio source; when an audio source is selected, assign the clip instead |
|
||||
| Asset Browser context/details actions | Apply textures/materials, regenerate thumbnails, reimport models, place assets/submeshes, or move file assets to `assets/.trash/` |
|
||||
| Static/Skinned Mesh Renderer material slots | Assign a shared Material/Material Instance per slot; Browse/Select/Locate the reference, or Clear the override to restore the imported source material |
|
||||
| `Ctrl+P` | Centered command palette; search human labels or stable command IDs, use arrow keys to select, Enter to run |
|
||||
| `F7` | While paused in Play: advance one sim tick |
|
||||
| Shift/Ctrl + click (Hierarchy) | Additive selection |
|
||||
@ -230,10 +232,21 @@ Viewport shortcut keys require the pointer to be in the viewport and are suspend
|
||||
details when you need full `WorldAssetRoot` scene data instead of normalized static slots.
|
||||
Expanding a model in the Asset Browser exposes normalized mesh/material/texture plus generated
|
||||
skeleton/animation-clip subassets. Dragging a mesh places that part through the static mesh path;
|
||||
dragging a clip creates an animated scene instance or assigns an exact-rig-compatible state to
|
||||
the selected imported model. The Animation Controller inspector edits state IDs, clips,
|
||||
dragging a clip creates a dedicated skinned renderer or assigns an exact-rig-compatible state to
|
||||
the selected skinned actor. **Default animation** in Model Import Settings optionally selects the
|
||||
exact clip sampled and paused as the edit-rest pose; **Imported rest pose** never guesses a clip.
|
||||
Preview stop and PIE exit restore imported transforms and morph weights before resampling that
|
||||
explicit default. The Animation Controller inspector edits state IDs, clips,
|
||||
range/loop/speed/default/crossfade and provides non-dirty play/pause/stop/scrub preview. See the
|
||||
[animation authoring guide](docs/editor/animation-authoring.md) and [ADR 0031](docs/adr/0031-animation-authoring-runtime-contract.md).
|
||||
- Navigation bounds, obstacles, areas, and links are created from **Scene > Navigation** or the path
|
||||
icon in the existing horizontal toolbar. Bounds bake versioned Rerecast artifacts under
|
||||
`assets/navigation/generated/`; overlapping primitive and additive-brush triangles participate
|
||||
in the bake fingerprint while distant authoring is excluded. The Inspector reports
|
||||
stale/current state and provides a Polyanya-backed path test whose mesh, links, and route render
|
||||
in the viewport. Use
|
||||
`cargo bake-navigation --project . --check` in CI. See the
|
||||
[navigation authoring guide](docs/editor/navigation-authoring.md) and [ADR 0032](docs/adr/0032-versioned-navigation-bake-and-runtime-query.md).
|
||||
- Brush actors are persisted as `ActorKind::Brush + BrushDesc`; valid convex faces hydrate into
|
||||
generated preview meshes. Vertex, edge, and face selections use the standard transform gizmo,
|
||||
face material/UV fields are undoable, and clip/intersect/merge/subtract provide conservative
|
||||
@ -329,6 +342,7 @@ The `.vscode/` folder is preconfigured:
|
||||
- [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)
|
||||
- [ADR 0034: Registry-driven Authoring Components](docs/adr/0034-registry-driven-authoring-components.md)
|
||||
|
||||
## Project Layout
|
||||
|
||||
@ -375,12 +389,13 @@ crates/
|
||||
- [x] Audio clip catalog/import foundation for Ogg, WAV, MP3, and FLAC with dedicated filtering, file details, and stable runtime-resolvable asset references
|
||||
- [x] Audio source/listener authoring, non-dirty spatial audition, viewport icons/range gizmos, stable buses, PIE/runtime parity, device diagnostics, and shared release validation ([ADR 0030](docs/adr/0030-audio-authoring-and-bus-schema.md); production acceptance completed in [Gitea #47](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/47))
|
||||
- [x] glTF/GLB skeletal animation manifests, stable controller states, non-dirty preview, PIE/runtime hydration, and exact-signature compatibility validation ([ADR 0031](docs/adr/0031-animation-authoring-runtime-contract.md); production acceptance completed in [Gitea #46](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/46))
|
||||
- [ ] Navigation bounds/obstacles/areas/links, deterministic stale-checked bake artifacts, viewport path preview, headless bake, and game runtime query API ([ADR 0032](docs/adr/0032-versioned-navigation-bake-and-runtime-query.md); tracked in [Gitea #48](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/48))
|
||||
- [x] Prefab instances (`PrefabInstance`) + save-as-prefab + unpack
|
||||
- [x] Independent dirty-tab close confirmation and all-tab save guard when switching projects
|
||||
- [x] Transactional scene writes + bounded user-local recovery snapshots ([ADR 0023](docs/adr/0023-transactional-scene-persistence-and-recovery.md))
|
||||
- [x] Versioned clean/crash session resume + privacy-bounded diagnostic bundle ([ADR 0024](docs/adr/0024-versioned-editor-session-state.md))
|
||||
- [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] Typed inspector undo (light, rigid body, collider, primitive, material, static mesh renderer) + registry-driven Add Component footer; skinned renderer sources are asset-owned and inspectable
|
||||
- [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)
|
||||
@ -392,16 +407,19 @@ crates/
|
||||
- [x] Project Browser UI, strict manifest validation, `--project` startup activation, recent filtering, sandbox scaffolding, clean process handoff, and desktop launcher action ([ADR 0025](docs/adr/0025-project-root-is-a-startup-boundary.md))
|
||||
- [x] Independent scene tabs + stable subscene composition, recursive validation, ownership locks, active-world PIE consistency, and per-saved-tab recovery ([ADR 0026](docs/adr/0026-stable-scene-composition-and-active-document.md))
|
||||
- [x] Editor lib/bin split + `EditorPluginGroup`; game EditorPlugin dogfood panel
|
||||
- [x] FBX/glTF model import + normalized `StaticMeshRenderer` placement; explicit scene-instance load via `bevy_ufbx` / `ModelRef`
|
||||
- [x] FBX/glTF model import + normalized static/skinned renderer routing; explicit generic 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] Shared Material/Material Instance assets, stable static/skinned renderer material slots, imported-source fallback, orphan preservation, and runtime-only property blocks ([ADR 0035](docs/adr/0035-shared-material-assets-and-renderer-slots.md), [material-system guide](docs/editor/material-system.md))
|
||||
- [x] Prefab v2 core: shared stable nested override paths, property/component/structural scopes, recursive graph validation, linked-root variants, conflict recovery, transactional source Apply, and undoable unpack/convert ([ADR 0027](docs/adr/0027-stable-prefab-ownership-and-variants.md))
|
||||
- [x] Prefab v2 production acceptance: committed base/nested/variant fixtures pass workspace tests, recursive headless validation, packaged release startup, and live editor placement/inspection regression coverage ([Gitea #43](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/43))
|
||||
- [x] Shared project validation: editor Diagnostics and `cargo validate-levels --json` use one owner-attributed dependency/finding report across project settings, registry/import artifacts, materials, shaders, scenes, prefabs, brushes, and colliders; valid/missing/cyclic/incompatible fixtures fail on blocking content errors ([ADR 0028](docs/adr/0028-authoritative-project-content-validation.md), [Gitea #45](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/45))
|
||||
- [x] Validation-gated package foundation: versioned development/QA/release profiles, deterministic runtime exclusions, stable input snapshots, transactional staged publication, Cargo-reported artifacts, BLAKE3 metadata, authored default-scene startup, and a non-blocking editor Build panel with live logs/cancel/run/reveal ([ADR 0029](docs/adr/0029-validation-gated-build-profiles-and-packaging.md), [build guide](docs/editor/build-and-package.md), [Gitea #44](https://git.spacetrainclubhouse.com/Falling-Metal-Interactive/Blacksite/issues/44))
|
||||
- [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] Surface ABI v1 with shared raster/Solari evaluator dispatch, packed typed parameters/textures, exact cutout ray-candidate evaluation, last-good shader fallback, and explicit exclusion of skinned/morph-deformed Solari geometry ([ADR 0036](docs/adr/0036-surface-abi-and-solari-parity.md), [material-system guide](docs/editor/material-system.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] Dedicated `SkinnedMeshRenderer`/`SkinnedMesh` actor path, skinned-part exclusion from static slots, dedicated runtime hierarchy hydration, and v2-to-v3 animated-scene migration ([ADR 0033](docs/adr/0033-dedicated-skinned-mesh-renderer.md))
|
||||
- [x] Componentized actor inspector, unified component cards, thumbnail static mesh slots, imported asset Browse/Locate/Clear refs with inherited source defaults, collider split, shader-aware actor material data, and texture picker/drop refs ([ADR 0018](docs/adr/0018-componentized-actor-inspector-and-materials.md))
|
||||
- [x] Stable authoring component IDs, registry-built save filtering, reflected atomic add/remove/reset/copy/paste history, independent active state, static extension registration, and derived composable `ActorKind` hints ([ADR 0034](docs/adr/0034-registry-driven-authoring-components.md), [extensibility guide](docs/editor/extensibility.md))
|
||||
- [x] Brush authoring schema MVP with `ActorKind::Brush`, cube `BrushDesc`, generated mesh hydration, scene migration, and inspector Add Component support ([ADR 0021](docs/adr/0021-brush-authoring-schema.md))
|
||||
- [x] Brush draw, vertex/edge/face gizmo editing, face material/UV authoring, clip, and bounds-based CSG preview/commit workflow ([brush guide](docs/editor/brushes.md))
|
||||
- [x] Searchable command palette with human labels/stable IDs and a status bar that exposes scene I/O, tool, history, mode, and selection feedback
|
||||
@ -414,10 +432,12 @@ crates/
|
||||
untextured models), material sphere thumbnails, search/filter/sort controls, expandable model
|
||||
subasset shelves, and a staged 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. Expanded mesh subassets generate independent thumbnails and can be placed independently. Asset details can switch
|
||||
placement to **Scene Instance** for `ModelRef`/`WorldAssetRoot` playback, shared material assets can be
|
||||
Model assets generate normalized model manifests under `assets/meshes/generated/`; drag/drop uses
|
||||
**Renderable Asset (Auto)**. Unrigged sources use `StaticMeshRenderer` with imported asset refs
|
||||
and optional separate static mesh colliders. Skin-bound or animated sources and their subasset placement use
|
||||
`SkinnedMeshRenderer`, preserve the source joint hierarchy, and never create static slots or
|
||||
automatic triangle colliders. Expanded mesh subassets generate independent thumbnails. Asset details can switch
|
||||
placement to **Scene Instance** for generic `ModelRef`/`WorldAssetRoot` scenes, shared material assets can be
|
||||
edited from the browser, and delete actions move files to `assets/.trash/`. Animation authoring
|
||||
supports glTF/GLB rig and clip manifests, stable controller refs, clip drag assignment/placement,
|
||||
inspector preview, PIE/runtime playback, and headless compatibility diagnostics. Animated or
|
||||
@ -432,7 +452,7 @@ crates/
|
||||
**Unpack Layer** preserves nested links; **Convert to Local** recursively removes them. Current
|
||||
authoring UI coverage and production-acceptance gaps are tracked in
|
||||
[prefab-authoring.md](docs/editor/prefab-authoring.md).
|
||||
- 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. The Asset Browser material details editor can apply shader schemas, edit typed parameters/textures, and regenerate sphere thumbnails. Actor inspector material edits live on the actor `MaterialDesc`; static mesh source-material refs remain imported defaults and shared material assets are edited from the Asset Browser.
|
||||
- Material and direct-base Material Instance assets live under `assets/materials/`; shader schemas live under `assets/shaders/`. In the Asset Browser, select a Material and use **Create Instance** for sparse inherited variants. Static and skinned renderers own stable per-draw material slots, with explicit assignments taking precedence over imported source defaults; shared edits propagate through live-updated handles without reloading skinned geometry. Custom Surface evaluators share one constrained ABI between raster and Solari-eligible non-deformed geometry. Dynamic skinned/morph Solari geometry and a persisted property-block authoring workflow remain future work; see the [material-system guide](docs/editor/material-system.md).
|
||||
- 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.
|
||||
|
||||
@ -12,6 +12,8 @@
|
||||
hierarchy_mode: SingleActor,
|
||||
material_policy: SourceMaterials,
|
||||
static_mesh_manifest_path: None,
|
||||
animation_manifest_path: None,
|
||||
default_animation_clip_id: None,
|
||||
),
|
||||
dependencies: [],
|
||||
),
|
||||
@ -28,6 +30,8 @@
|
||||
hierarchy_mode: SingleActor,
|
||||
material_policy: SourceMaterials,
|
||||
static_mesh_manifest_path: None,
|
||||
animation_manifest_path: None,
|
||||
default_animation_clip_id: None,
|
||||
),
|
||||
dependencies: [],
|
||||
),
|
||||
@ -44,6 +48,8 @@
|
||||
hierarchy_mode: SingleActor,
|
||||
material_policy: SourceMaterials,
|
||||
static_mesh_manifest_path: None,
|
||||
animation_manifest_path: None,
|
||||
default_animation_clip_id: None,
|
||||
),
|
||||
dependencies: [],
|
||||
),
|
||||
@ -60,6 +66,8 @@
|
||||
hierarchy_mode: SingleActor,
|
||||
material_policy: SourceMaterials,
|
||||
static_mesh_manifest_path: None,
|
||||
animation_manifest_path: None,
|
||||
default_animation_clip_id: None,
|
||||
),
|
||||
dependencies: [],
|
||||
),
|
||||
@ -76,6 +84,44 @@
|
||||
hierarchy_mode: SingleActor,
|
||||
material_policy: SourceMaterials,
|
||||
static_mesh_manifest_path: Some("assets/meshes/generated/b98ef565-3500-49e7-9935-f685fa9b2594.static_mesh.ron"),
|
||||
animation_manifest_path: Some("assets/animations/generated/b98ef565-3500-49e7-9935-f685fa9b2594.animation.ron"),
|
||||
default_animation_clip_id: None,
|
||||
),
|
||||
dependencies: [],
|
||||
),
|
||||
(
|
||||
id: ("113f74df-e39c-41d4-9b5b-e48efe541f7f"),
|
||||
path: "assets/models/RobotExpressive.glb",
|
||||
label: "RobotExpressive",
|
||||
kind_tag: "Model",
|
||||
import_settings: (
|
||||
scale: 1.0,
|
||||
generate_collider: true,
|
||||
lod0_only: true,
|
||||
placement_mode: SceneInstance,
|
||||
hierarchy_mode: SourceHierarchy,
|
||||
material_policy: SourceMaterials,
|
||||
static_mesh_manifest_path: Some("assets/meshes/generated/113f74df-e39c-41d4-9b5b-e48efe541f7f.static_mesh.ron"),
|
||||
animation_manifest_path: Some("assets/animations/generated/113f74df-e39c-41d4-9b5b-e48efe541f7f.animation.ron"),
|
||||
default_animation_clip_id: Some("animation:clip:8:standing"),
|
||||
),
|
||||
dependencies: [],
|
||||
),
|
||||
(
|
||||
id: ("3f63f359-45eb-4cb2-8970-71921cbd7bd0"),
|
||||
path: "assets/models/robot_expressive.glb",
|
||||
label: "robot_expressive",
|
||||
kind_tag: "Model",
|
||||
import_settings: (
|
||||
scale: 1.0,
|
||||
generate_collider: true,
|
||||
lod0_only: true,
|
||||
placement_mode: StaticAsset,
|
||||
hierarchy_mode: SingleActor,
|
||||
material_policy: SourceMaterials,
|
||||
static_mesh_manifest_path: Some("assets/meshes/generated/3f63f359-45eb-4cb2-8970-71921cbd7bd0.static_mesh.ron"),
|
||||
animation_manifest_path: Some("assets/animations/generated/3f63f359-45eb-4cb2-8970-71921cbd7bd0.animation.ron"),
|
||||
default_animation_clip_id: Some("animation:clip:8:standing"),
|
||||
),
|
||||
dependencies: [],
|
||||
),
|
||||
@ -92,6 +138,8 @@
|
||||
hierarchy_mode: SingleActor,
|
||||
material_policy: SourceMaterials,
|
||||
static_mesh_manifest_path: None,
|
||||
animation_manifest_path: None,
|
||||
default_animation_clip_id: None,
|
||||
),
|
||||
dependencies: [],
|
||||
),
|
||||
@ -108,6 +156,8 @@
|
||||
hierarchy_mode: SingleActor,
|
||||
material_policy: SourceMaterials,
|
||||
static_mesh_manifest_path: None,
|
||||
animation_manifest_path: None,
|
||||
default_animation_clip_id: None,
|
||||
),
|
||||
dependencies: [],
|
||||
),
|
||||
@ -124,6 +174,8 @@
|
||||
hierarchy_mode: SingleActor,
|
||||
material_policy: SourceMaterials,
|
||||
static_mesh_manifest_path: None,
|
||||
animation_manifest_path: None,
|
||||
default_animation_clip_id: None,
|
||||
),
|
||||
dependencies: [],
|
||||
),
|
||||
@ -140,6 +192,26 @@
|
||||
hierarchy_mode: SingleActor,
|
||||
material_policy: SourceMaterials,
|
||||
static_mesh_manifest_path: None,
|
||||
animation_manifest_path: None,
|
||||
default_animation_clip_id: None,
|
||||
),
|
||||
dependencies: [],
|
||||
),
|
||||
(
|
||||
id: ("706e604e-1a63-4e97-b35d-eeb1d88fe3a9"),
|
||||
path: "assets/levels/navigation_authoring_showcase.scn.ron",
|
||||
label: "navigation_authoring_showcase.scn",
|
||||
kind_tag: "Level",
|
||||
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,
|
||||
animation_manifest_path: None,
|
||||
default_animation_clip_id: None,
|
||||
),
|
||||
dependencies: [],
|
||||
),
|
||||
@ -156,6 +228,8 @@
|
||||
hierarchy_mode: SingleActor,
|
||||
material_policy: SourceMaterials,
|
||||
static_mesh_manifest_path: None,
|
||||
animation_manifest_path: None,
|
||||
default_animation_clip_id: None,
|
||||
),
|
||||
dependencies: [],
|
||||
),
|
||||
@ -172,6 +246,8 @@
|
||||
hierarchy_mode: SingleActor,
|
||||
material_policy: SourceMaterials,
|
||||
static_mesh_manifest_path: None,
|
||||
animation_manifest_path: None,
|
||||
default_animation_clip_id: None,
|
||||
),
|
||||
dependencies: [],
|
||||
),
|
||||
@ -188,6 +264,8 @@
|
||||
hierarchy_mode: SingleActor,
|
||||
material_policy: SourceMaterials,
|
||||
static_mesh_manifest_path: None,
|
||||
animation_manifest_path: None,
|
||||
default_animation_clip_id: None,
|
||||
),
|
||||
dependencies: [],
|
||||
),
|
||||
@ -204,6 +282,8 @@
|
||||
hierarchy_mode: SingleActor,
|
||||
material_policy: SourceMaterials,
|
||||
static_mesh_manifest_path: None,
|
||||
animation_manifest_path: None,
|
||||
default_animation_clip_id: None,
|
||||
),
|
||||
dependencies: [],
|
||||
),
|
||||
@ -220,6 +300,8 @@
|
||||
hierarchy_mode: SingleActor,
|
||||
material_policy: SourceMaterials,
|
||||
static_mesh_manifest_path: None,
|
||||
animation_manifest_path: None,
|
||||
default_animation_clip_id: None,
|
||||
),
|
||||
dependencies: [],
|
||||
),
|
||||
@ -236,6 +318,8 @@
|
||||
hierarchy_mode: SingleActor,
|
||||
material_policy: SourceMaterials,
|
||||
static_mesh_manifest_path: None,
|
||||
animation_manifest_path: None,
|
||||
default_animation_clip_id: None,
|
||||
),
|
||||
dependencies: [],
|
||||
),
|
||||
@ -252,6 +336,62 @@
|
||||
hierarchy_mode: SingleActor,
|
||||
material_policy: SourceMaterials,
|
||||
static_mesh_manifest_path: None,
|
||||
animation_manifest_path: None,
|
||||
default_animation_clip_id: None,
|
||||
),
|
||||
dependencies: [],
|
||||
),
|
||||
(
|
||||
id: ("04a4e00e-4732-43db-8e99-9cd99b94667b"),
|
||||
path: "assets/animations/generated/113f74df-e39c-41d4-9b5b-e48efe541f7f.animation.ron",
|
||||
label: "113f74df-e39c-41d4-9b5b-e48efe541f7f.animation",
|
||||
kind_tag: "Level",
|
||||
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,
|
||||
animation_manifest_path: None,
|
||||
default_animation_clip_id: None,
|
||||
),
|
||||
dependencies: [],
|
||||
),
|
||||
(
|
||||
id: ("4937e63f-923b-4270-bdfc-298d63bcb814"),
|
||||
path: "assets/animations/generated/3f63f359-45eb-4cb2-8970-71921cbd7bd0.animation.ron",
|
||||
label: "3f63f359-45eb-4cb2-8970-71921cbd7bd0.animation",
|
||||
kind_tag: "Level",
|
||||
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,
|
||||
animation_manifest_path: None,
|
||||
default_animation_clip_id: None,
|
||||
),
|
||||
dependencies: [],
|
||||
),
|
||||
(
|
||||
id: ("28f1df44-6d24-4722-bf75-6969879995a4"),
|
||||
path: "assets/animations/generated/b98ef565-3500-49e7-9935-f685fa9b2594.animation.ron",
|
||||
label: "b98ef565-3500-49e7-9935-f685fa9b2594.animation",
|
||||
kind_tag: "Level",
|
||||
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,
|
||||
animation_manifest_path: None,
|
||||
default_animation_clip_id: None,
|
||||
),
|
||||
dependencies: [],
|
||||
),
|
||||
@ -268,6 +408,8 @@
|
||||
hierarchy_mode: SingleActor,
|
||||
material_policy: SourceMaterials,
|
||||
static_mesh_manifest_path: None,
|
||||
animation_manifest_path: None,
|
||||
default_animation_clip_id: None,
|
||||
),
|
||||
dependencies: [],
|
||||
),
|
||||
@ -284,6 +426,26 @@
|
||||
hierarchy_mode: SingleActor,
|
||||
material_policy: SourceMaterials,
|
||||
static_mesh_manifest_path: None,
|
||||
animation_manifest_path: None,
|
||||
default_animation_clip_id: None,
|
||||
),
|
||||
dependencies: [],
|
||||
),
|
||||
(
|
||||
id: ("264e2010-0693-4d74-8440-99373c2cedb3"),
|
||||
path: "assets/navigation/generated/navigation_showcase_humanoid.nav.ron",
|
||||
label: "navigation_showcase_humanoid.nav",
|
||||
kind_tag: "Level",
|
||||
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,
|
||||
animation_manifest_path: None,
|
||||
default_animation_clip_id: None,
|
||||
),
|
||||
dependencies: [],
|
||||
),
|
||||
@ -300,6 +462,26 @@
|
||||
hierarchy_mode: SingleActor,
|
||||
material_policy: SourceMaterials,
|
||||
static_mesh_manifest_path: None,
|
||||
animation_manifest_path: None,
|
||||
default_animation_clip_id: None,
|
||||
),
|
||||
dependencies: [],
|
||||
),
|
||||
(
|
||||
id: ("cc2769da-c9a5-4cb3-866a-cc81d0688ee2"),
|
||||
path: "assets/materials/surface_tint_instance.ron",
|
||||
label: "surface_tint_instance",
|
||||
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,
|
||||
animation_manifest_path: None,
|
||||
default_animation_clip_id: None,
|
||||
),
|
||||
dependencies: [],
|
||||
),
|
||||
@ -316,6 +498,26 @@
|
||||
hierarchy_mode: SingleActor,
|
||||
material_policy: SourceMaterials,
|
||||
static_mesh_manifest_path: None,
|
||||
animation_manifest_path: None,
|
||||
default_animation_clip_id: None,
|
||||
),
|
||||
dependencies: [],
|
||||
),
|
||||
(
|
||||
id: ("d6cb4151-7124-4237-aaf9-f7f8abd5fb76"),
|
||||
path: "assets/materials/surface_tint.ron",
|
||||
label: "surface_tint",
|
||||
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,
|
||||
animation_manifest_path: None,
|
||||
default_animation_clip_id: None,
|
||||
),
|
||||
dependencies: [],
|
||||
),
|
||||
@ -332,6 +534,8 @@
|
||||
hierarchy_mode: SingleActor,
|
||||
material_policy: SourceMaterials,
|
||||
static_mesh_manifest_path: None,
|
||||
animation_manifest_path: None,
|
||||
default_animation_clip_id: None,
|
||||
),
|
||||
dependencies: [],
|
||||
),
|
||||
@ -348,6 +552,8 @@
|
||||
hierarchy_mode: SingleActor,
|
||||
material_policy: SourceMaterials,
|
||||
static_mesh_manifest_path: None,
|
||||
animation_manifest_path: None,
|
||||
default_animation_clip_id: None,
|
||||
),
|
||||
dependencies: [],
|
||||
),
|
||||
@ -364,6 +570,8 @@
|
||||
hierarchy_mode: SingleActor,
|
||||
material_policy: SourceMaterials,
|
||||
static_mesh_manifest_path: None,
|
||||
animation_manifest_path: None,
|
||||
default_animation_clip_id: None,
|
||||
),
|
||||
dependencies: [],
|
||||
),
|
||||
@ -380,6 +588,8 @@
|
||||
hierarchy_mode: SingleActor,
|
||||
material_policy: SourceMaterials,
|
||||
static_mesh_manifest_path: None,
|
||||
animation_manifest_path: None,
|
||||
default_animation_clip_id: None,
|
||||
),
|
||||
dependencies: [],
|
||||
),
|
||||
@ -396,6 +606,26 @@
|
||||
hierarchy_mode: SingleActor,
|
||||
material_policy: SourceMaterials,
|
||||
static_mesh_manifest_path: None,
|
||||
animation_manifest_path: None,
|
||||
default_animation_clip_id: None,
|
||||
),
|
||||
dependencies: [],
|
||||
),
|
||||
(
|
||||
id: ("a72943d6-3e9d-4629-8887-5c02d99d4f92"),
|
||||
path: "assets/shaders/surface_tint.shader.ron",
|
||||
label: "surface_tint.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,
|
||||
animation_manifest_path: None,
|
||||
default_animation_clip_id: None,
|
||||
),
|
||||
dependencies: [],
|
||||
),
|
||||
|
||||
@ -0,0 +1,236 @@
|
||||
(
|
||||
schema_version: 3,
|
||||
asset_id: "113f74df-e39c-41d4-9b5b-e48efe541f7f",
|
||||
label: "RobotExpressive",
|
||||
default_animation_clip_id: Some("animation:clip:8:standing"),
|
||||
source: (
|
||||
path: "assets/models/RobotExpressive.glb",
|
||||
format: "glb",
|
||||
fingerprint: (
|
||||
byte_len: 463988,
|
||||
modified_unix_secs: 1783799272,
|
||||
content_hash: "5869cb813e6a6093eda7af2824a9c13a582750e0443e88ce7c991a1f83415521",
|
||||
),
|
||||
dependencies: [],
|
||||
),
|
||||
runtime_supported: true,
|
||||
skeletons: [
|
||||
(
|
||||
id: "animation:skeleton:0:skeleton_0",
|
||||
label: "Skeleton 0",
|
||||
source_index: 0,
|
||||
signature: ("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b"),
|
||||
joint_paths: [
|
||||
"RootNode/RobotArmature/Bone",
|
||||
"RootNode/RobotArmature/Bone/Foot.L",
|
||||
"RootNode/RobotArmature/Bone/Body",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Neck",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Neck/Head",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm2.L",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm2.L/Middle1.L",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm2.L/Middle1.L/Middle2.L",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Thumb.L",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Thumb.L/Thumb2.L",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm1.L",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm1.L/Index.L",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm1.L/Index.L/Index2.L",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm3.L",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm3.L/Ring1.L",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm3.L/Ring1.L/Ring2.L",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm2.R",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm2.R/Middle1.R",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm2.R/Middle1.R/Middle2.R",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Thumb.R",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Thumb.R/Thumb2.R",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm1.R",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm1.R/Index.R",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm1.R/Index.R/Index2.R",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm3.R",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm3.R/Ring1.R",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm3.R/Ring1.R/Ring2.R",
|
||||
"RootNode/RobotArmature/Bone/Body/UpperLeg.L",
|
||||
"RootNode/RobotArmature/Bone/Body/UpperLeg.L/LowerLeg.L",
|
||||
"RootNode/RobotArmature/Bone/Body/UpperLeg.R",
|
||||
"RootNode/RobotArmature/Bone/Body/UpperLeg.R/LowerLeg.R",
|
||||
"RootNode/RobotArmature/Bone/PoleTarget.L",
|
||||
"RootNode/RobotArmature/Bone/Foot.R",
|
||||
"RootNode/RobotArmature/Bone/PoleTarget.R",
|
||||
],
|
||||
),
|
||||
(
|
||||
id: "animation:skeleton:1:skeleton_1",
|
||||
label: "Skeleton 1",
|
||||
source_index: 1,
|
||||
signature: ("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b"),
|
||||
joint_paths: [
|
||||
"RootNode/RobotArmature/Bone",
|
||||
"RootNode/RobotArmature/Bone/Foot.L",
|
||||
"RootNode/RobotArmature/Bone/Body",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Neck",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Neck/Head",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm2.L",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm2.L/Middle1.L",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm2.L/Middle1.L/Middle2.L",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Thumb.L",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Thumb.L/Thumb2.L",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm1.L",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm1.L/Index.L",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm1.L/Index.L/Index2.L",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm3.L",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm3.L/Ring1.L",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm3.L/Ring1.L/Ring2.L",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm2.R",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm2.R/Middle1.R",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm2.R/Middle1.R/Middle2.R",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Thumb.R",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Thumb.R/Thumb2.R",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm1.R",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm1.R/Index.R",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm1.R/Index.R/Index2.R",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm3.R",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm3.R/Ring1.R",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm3.R/Ring1.R/Ring2.R",
|
||||
"RootNode/RobotArmature/Bone/Body/UpperLeg.L",
|
||||
"RootNode/RobotArmature/Bone/Body/UpperLeg.L/LowerLeg.L",
|
||||
"RootNode/RobotArmature/Bone/Body/UpperLeg.R",
|
||||
"RootNode/RobotArmature/Bone/Body/UpperLeg.R/LowerLeg.R",
|
||||
"RootNode/RobotArmature/Bone/PoleTarget.L",
|
||||
"RootNode/RobotArmature/Bone/Foot.R",
|
||||
"RootNode/RobotArmature/Bone/PoleTarget.R",
|
||||
],
|
||||
),
|
||||
],
|
||||
clips: [
|
||||
(
|
||||
id: "animation:clip:0:dance",
|
||||
label: "Dance",
|
||||
source_index: 0,
|
||||
duration_seconds: 3.3333333,
|
||||
target_skeleton_signature: Some(("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b")),
|
||||
events: [],
|
||||
),
|
||||
(
|
||||
id: "animation:clip:1:death",
|
||||
label: "Death",
|
||||
source_index: 1,
|
||||
duration_seconds: 0.9583333,
|
||||
target_skeleton_signature: Some(("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b")),
|
||||
events: [],
|
||||
),
|
||||
(
|
||||
id: "animation:clip:2:idle",
|
||||
label: "Idle",
|
||||
source_index: 2,
|
||||
duration_seconds: 3.3333333,
|
||||
target_skeleton_signature: Some(("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b")),
|
||||
events: [],
|
||||
),
|
||||
(
|
||||
id: "animation:clip:3:jump",
|
||||
label: "Jump",
|
||||
source_index: 3,
|
||||
duration_seconds: 0.7083333,
|
||||
target_skeleton_signature: Some(("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b")),
|
||||
events: [],
|
||||
),
|
||||
(
|
||||
id: "animation:clip:4:no",
|
||||
label: "No",
|
||||
source_index: 4,
|
||||
duration_seconds: 1.6666666,
|
||||
target_skeleton_signature: Some(("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b")),
|
||||
events: [],
|
||||
),
|
||||
(
|
||||
id: "animation:clip:5:punch",
|
||||
label: "Punch",
|
||||
source_index: 5,
|
||||
duration_seconds: 0.8333333,
|
||||
target_skeleton_signature: Some(("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b")),
|
||||
events: [],
|
||||
),
|
||||
(
|
||||
id: "animation:clip:6:running",
|
||||
label: "Running",
|
||||
source_index: 6,
|
||||
duration_seconds: 0.9583333,
|
||||
target_skeleton_signature: Some(("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b")),
|
||||
events: [],
|
||||
),
|
||||
(
|
||||
id: "animation:clip:7:sitting",
|
||||
label: "Sitting",
|
||||
source_index: 7,
|
||||
duration_seconds: 0.41666666,
|
||||
target_skeleton_signature: Some(("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b")),
|
||||
events: [],
|
||||
),
|
||||
(
|
||||
id: "animation:clip:8:standing",
|
||||
label: "Standing",
|
||||
source_index: 8,
|
||||
duration_seconds: 0.41666666,
|
||||
target_skeleton_signature: Some(("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b")),
|
||||
events: [],
|
||||
),
|
||||
(
|
||||
id: "animation:clip:9:thumbsup",
|
||||
label: "ThumbsUp",
|
||||
source_index: 9,
|
||||
duration_seconds: 1.5833334,
|
||||
target_skeleton_signature: Some(("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b")),
|
||||
events: [],
|
||||
),
|
||||
(
|
||||
id: "animation:clip:10:walking",
|
||||
label: "Walking",
|
||||
source_index: 10,
|
||||
duration_seconds: 0.9583333,
|
||||
target_skeleton_signature: Some(("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b")),
|
||||
events: [],
|
||||
),
|
||||
(
|
||||
id: "animation:clip:11:walkjump",
|
||||
label: "WalkJump",
|
||||
source_index: 11,
|
||||
duration_seconds: 0.8333333,
|
||||
target_skeleton_signature: Some(("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b")),
|
||||
events: [],
|
||||
),
|
||||
(
|
||||
id: "animation:clip:12:wave",
|
||||
label: "Wave",
|
||||
source_index: 12,
|
||||
duration_seconds: 1.8333334,
|
||||
target_skeleton_signature: Some(("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b")),
|
||||
events: [],
|
||||
),
|
||||
(
|
||||
id: "animation:clip:13:yes",
|
||||
label: "Yes",
|
||||
source_index: 13,
|
||||
duration_seconds: 1.6666666,
|
||||
target_skeleton_signature: Some(("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b")),
|
||||
events: [],
|
||||
),
|
||||
],
|
||||
diagnostics: [],
|
||||
)
|
||||
@ -0,0 +1,236 @@
|
||||
(
|
||||
schema_version: 3,
|
||||
asset_id: "3f63f359-45eb-4cb2-8970-71921cbd7bd0",
|
||||
label: "robot_expressive",
|
||||
default_animation_clip_id: Some("animation:clip:8:standing"),
|
||||
source: (
|
||||
path: "assets/models/robot_expressive.glb",
|
||||
format: "glb",
|
||||
fingerprint: (
|
||||
byte_len: 463988,
|
||||
modified_unix_secs: 1783750957,
|
||||
content_hash: "5869cb813e6a6093eda7af2824a9c13a582750e0443e88ce7c991a1f83415521",
|
||||
),
|
||||
dependencies: [],
|
||||
),
|
||||
runtime_supported: true,
|
||||
skeletons: [
|
||||
(
|
||||
id: "animation:skeleton:0:skeleton_0",
|
||||
label: "Skeleton 0",
|
||||
source_index: 0,
|
||||
signature: ("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b"),
|
||||
joint_paths: [
|
||||
"RootNode/RobotArmature/Bone",
|
||||
"RootNode/RobotArmature/Bone/Foot.L",
|
||||
"RootNode/RobotArmature/Bone/Body",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Neck",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Neck/Head",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm2.L",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm2.L/Middle1.L",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm2.L/Middle1.L/Middle2.L",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Thumb.L",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Thumb.L/Thumb2.L",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm1.L",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm1.L/Index.L",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm1.L/Index.L/Index2.L",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm3.L",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm3.L/Ring1.L",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm3.L/Ring1.L/Ring2.L",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm2.R",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm2.R/Middle1.R",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm2.R/Middle1.R/Middle2.R",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Thumb.R",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Thumb.R/Thumb2.R",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm1.R",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm1.R/Index.R",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm1.R/Index.R/Index2.R",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm3.R",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm3.R/Ring1.R",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm3.R/Ring1.R/Ring2.R",
|
||||
"RootNode/RobotArmature/Bone/Body/UpperLeg.L",
|
||||
"RootNode/RobotArmature/Bone/Body/UpperLeg.L/LowerLeg.L",
|
||||
"RootNode/RobotArmature/Bone/Body/UpperLeg.R",
|
||||
"RootNode/RobotArmature/Bone/Body/UpperLeg.R/LowerLeg.R",
|
||||
"RootNode/RobotArmature/Bone/PoleTarget.L",
|
||||
"RootNode/RobotArmature/Bone/Foot.R",
|
||||
"RootNode/RobotArmature/Bone/PoleTarget.R",
|
||||
],
|
||||
),
|
||||
(
|
||||
id: "animation:skeleton:1:skeleton_1",
|
||||
label: "Skeleton 1",
|
||||
source_index: 1,
|
||||
signature: ("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b"),
|
||||
joint_paths: [
|
||||
"RootNode/RobotArmature/Bone",
|
||||
"RootNode/RobotArmature/Bone/Foot.L",
|
||||
"RootNode/RobotArmature/Bone/Body",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Neck",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Neck/Head",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm2.L",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm2.L/Middle1.L",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm2.L/Middle1.L/Middle2.L",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Thumb.L",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Thumb.L/Thumb2.L",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm1.L",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm1.L/Index.L",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm1.L/Index.L/Index2.L",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm3.L",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm3.L/Ring1.L",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/LowerArm.L/Palm3.L/Ring1.L/Ring2.L",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm2.R",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm2.R/Middle1.R",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm2.R/Middle1.R/Middle2.R",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Thumb.R",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Thumb.R/Thumb2.R",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm1.R",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm1.R/Index.R",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm1.R/Index.R/Index2.R",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm3.R",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm3.R/Ring1.R",
|
||||
"RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/LowerArm.R/Palm3.R/Ring1.R/Ring2.R",
|
||||
"RootNode/RobotArmature/Bone/Body/UpperLeg.L",
|
||||
"RootNode/RobotArmature/Bone/Body/UpperLeg.L/LowerLeg.L",
|
||||
"RootNode/RobotArmature/Bone/Body/UpperLeg.R",
|
||||
"RootNode/RobotArmature/Bone/Body/UpperLeg.R/LowerLeg.R",
|
||||
"RootNode/RobotArmature/Bone/PoleTarget.L",
|
||||
"RootNode/RobotArmature/Bone/Foot.R",
|
||||
"RootNode/RobotArmature/Bone/PoleTarget.R",
|
||||
],
|
||||
),
|
||||
],
|
||||
clips: [
|
||||
(
|
||||
id: "animation:clip:0:dance",
|
||||
label: "Dance",
|
||||
source_index: 0,
|
||||
duration_seconds: 3.3333333,
|
||||
target_skeleton_signature: Some(("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b")),
|
||||
events: [],
|
||||
),
|
||||
(
|
||||
id: "animation:clip:1:death",
|
||||
label: "Death",
|
||||
source_index: 1,
|
||||
duration_seconds: 0.9583333,
|
||||
target_skeleton_signature: Some(("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b")),
|
||||
events: [],
|
||||
),
|
||||
(
|
||||
id: "animation:clip:2:idle",
|
||||
label: "Idle",
|
||||
source_index: 2,
|
||||
duration_seconds: 3.3333333,
|
||||
target_skeleton_signature: Some(("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b")),
|
||||
events: [],
|
||||
),
|
||||
(
|
||||
id: "animation:clip:3:jump",
|
||||
label: "Jump",
|
||||
source_index: 3,
|
||||
duration_seconds: 0.7083333,
|
||||
target_skeleton_signature: Some(("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b")),
|
||||
events: [],
|
||||
),
|
||||
(
|
||||
id: "animation:clip:4:no",
|
||||
label: "No",
|
||||
source_index: 4,
|
||||
duration_seconds: 1.6666666,
|
||||
target_skeleton_signature: Some(("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b")),
|
||||
events: [],
|
||||
),
|
||||
(
|
||||
id: "animation:clip:5:punch",
|
||||
label: "Punch",
|
||||
source_index: 5,
|
||||
duration_seconds: 0.8333333,
|
||||
target_skeleton_signature: Some(("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b")),
|
||||
events: [],
|
||||
),
|
||||
(
|
||||
id: "animation:clip:6:running",
|
||||
label: "Running",
|
||||
source_index: 6,
|
||||
duration_seconds: 0.9583333,
|
||||
target_skeleton_signature: Some(("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b")),
|
||||
events: [],
|
||||
),
|
||||
(
|
||||
id: "animation:clip:7:sitting",
|
||||
label: "Sitting",
|
||||
source_index: 7,
|
||||
duration_seconds: 0.41666666,
|
||||
target_skeleton_signature: Some(("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b")),
|
||||
events: [],
|
||||
),
|
||||
(
|
||||
id: "animation:clip:8:standing",
|
||||
label: "Standing",
|
||||
source_index: 8,
|
||||
duration_seconds: 0.41666666,
|
||||
target_skeleton_signature: Some(("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b")),
|
||||
events: [],
|
||||
),
|
||||
(
|
||||
id: "animation:clip:9:thumbsup",
|
||||
label: "ThumbsUp",
|
||||
source_index: 9,
|
||||
duration_seconds: 1.5833334,
|
||||
target_skeleton_signature: Some(("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b")),
|
||||
events: [],
|
||||
),
|
||||
(
|
||||
id: "animation:clip:10:walking",
|
||||
label: "Walking",
|
||||
source_index: 10,
|
||||
duration_seconds: 0.9583333,
|
||||
target_skeleton_signature: Some(("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b")),
|
||||
events: [],
|
||||
),
|
||||
(
|
||||
id: "animation:clip:11:walkjump",
|
||||
label: "WalkJump",
|
||||
source_index: 11,
|
||||
duration_seconds: 0.8333333,
|
||||
target_skeleton_signature: Some(("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b")),
|
||||
events: [],
|
||||
),
|
||||
(
|
||||
id: "animation:clip:12:wave",
|
||||
label: "Wave",
|
||||
source_index: 12,
|
||||
duration_seconds: 1.8333334,
|
||||
target_skeleton_signature: Some(("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b")),
|
||||
events: [],
|
||||
),
|
||||
(
|
||||
id: "animation:clip:13:yes",
|
||||
label: "Yes",
|
||||
source_index: 13,
|
||||
duration_seconds: 1.6666666,
|
||||
target_skeleton_signature: Some(("e9761f2a198d6a280afd0fc2aca6e8783dc6237bd85c90b8bc07a2e11271dc2b")),
|
||||
events: [],
|
||||
),
|
||||
],
|
||||
diagnostics: [],
|
||||
)
|
||||
@ -0,0 +1,20 @@
|
||||
(
|
||||
schema_version: 3,
|
||||
asset_id: "b98ef565-3500-49e7-9935-f685fa9b2594",
|
||||
label: "painted_wooden_chair_02_2k",
|
||||
default_animation_clip_id: None,
|
||||
source: (
|
||||
path: "assets/models/painted_wooden_chair_02_2k.fbx",
|
||||
format: "fbx",
|
||||
fingerprint: (
|
||||
byte_len: 59964,
|
||||
modified_unix_secs: 1780713434,
|
||||
content_hash: "b12973a62dcb44589e380ea833eade726ae98a86c81084c842ee3801866d6a46",
|
||||
),
|
||||
dependencies: [],
|
||||
),
|
||||
runtime_supported: false,
|
||||
skeletons: [],
|
||||
clips: [],
|
||||
diagnostics: [],
|
||||
)
|
||||
@ -1,15 +1,13 @@
|
||||
(schema_version: 2,
|
||||
resources: {},
|
||||
(schema_version: 4,resources: {
|
||||
},
|
||||
entities: {
|
||||
4294969001: (
|
||||
components: {
|
||||
4294969001: (components: {
|
||||
"bevy_ecs::name::Name": "Audition Tone Source",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (0.0, 1.5, -4.0),
|
||||
rotation: (0.0, 0.0, 0.0, 1.0),
|
||||
scale: (1.0, 1.0, 1.0),
|
||||
),
|
||||
"shared::components::LevelObject": (),
|
||||
"shared::components::ActorId": ("audio-showcase-source"),
|
||||
"shared::components::ActorKind": AudioSource,
|
||||
"shared::components::AudioSourceDesc": (
|
||||
@ -32,17 +30,15 @@
|
||||
),
|
||||
bus: "sfx",
|
||||
),
|
||||
},
|
||||
),
|
||||
4294969002: (
|
||||
components: {
|
||||
"shared::components::LevelObject": (),
|
||||
}),
|
||||
4294969002: (components: {
|
||||
"bevy_ecs::name::Name": "Showcase Listener",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (0.0, 1.7, 0.0),
|
||||
rotation: (0.0, 0.0, 0.0, 1.0),
|
||||
scale: (1.0, 1.0, 1.0),
|
||||
),
|
||||
"shared::components::LevelObject": (),
|
||||
"shared::components::ActorId": ("audio-showcase-listener"),
|
||||
"shared::components::ActorKind": AudioListener,
|
||||
"shared::components::AudioListenerDesc": (
|
||||
@ -50,7 +46,7 @@
|
||||
priority: 100,
|
||||
ear_gap: 0.2,
|
||||
),
|
||||
},
|
||||
),
|
||||
"shared::components::LevelObject": (),
|
||||
}),
|
||||
},
|
||||
)
|
||||
@ -1,8 +1,7 @@
|
||||
(schema_version: 2,
|
||||
resources: {},
|
||||
(schema_version: 4,resources: {
|
||||
},
|
||||
entities: {
|
||||
4294967126: (
|
||||
components: {
|
||||
4294967126: (components: {
|
||||
"bevy_ecs::name::Name": "Pillar Copy",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (3.7178926, 3.5, -11.932255),
|
||||
@ -41,10 +40,8 @@
|
||||
shape: Box,
|
||||
size: (1.2, 7.0, 1.2),
|
||||
),
|
||||
},
|
||||
),
|
||||
4294967158: (
|
||||
components: {
|
||||
}),
|
||||
4294967158: (components: {
|
||||
"bevy_ecs::name::Name": "Scene Sun",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (0.0, 5.316672, 0.0),
|
||||
@ -71,10 +68,8 @@
|
||||
inner_angle_deg: 25.0,
|
||||
outer_angle_deg: 35.0,
|
||||
),
|
||||
},
|
||||
),
|
||||
4294967159: (
|
||||
components: {
|
||||
}),
|
||||
4294967159: (components: {
|
||||
"bevy_ecs::name::Name": "Ground",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (0.0, -0.5, 0.0),
|
||||
@ -113,10 +108,8 @@
|
||||
shape: Box,
|
||||
size: (200.0, 1.0, 200.0),
|
||||
),
|
||||
},
|
||||
),
|
||||
8589934420: (
|
||||
components: {
|
||||
}),
|
||||
8589934420: (components: {
|
||||
"bevy_ecs::name::Name": "Pillar Copy",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (-12.079595, 3.5, -13.098544),
|
||||
@ -155,10 +148,8 @@
|
||||
shape: Box,
|
||||
size: (1.2, 7.0, 1.2),
|
||||
),
|
||||
},
|
||||
),
|
||||
8589934456: (
|
||||
components: {
|
||||
}),
|
||||
8589934456: (components: {
|
||||
"bevy_ecs::name::Name": "Player Start",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (0.0, 0.9900649, 12.46175),
|
||||
@ -172,10 +163,8 @@
|
||||
"shared::components::HierarchySiblingIndex": (2),
|
||||
"shared::components::LevelObject": (),
|
||||
"shared::components::PlayerSpawn": (),
|
||||
},
|
||||
),
|
||||
8589934457: (
|
||||
components: {
|
||||
}),
|
||||
8589934457: (components: {
|
||||
"bevy_ecs::name::Name": "ShowcaseSphere",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (-1.3999996, 0.6, 4.0),
|
||||
@ -212,10 +201,8 @@
|
||||
shape: Sphere,
|
||||
size: (1.2, 1.2, 1.2),
|
||||
),
|
||||
},
|
||||
),
|
||||
8589934458: (
|
||||
components: {
|
||||
}),
|
||||
8589934458: (components: {
|
||||
"bevy_ecs::name::Name": "ShowcaseSphere",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (-3.6, 0.6, 4.0),
|
||||
@ -252,10 +239,8 @@
|
||||
shape: Sphere,
|
||||
size: (1.2, 1.2, 1.2),
|
||||
),
|
||||
},
|
||||
),
|
||||
8589934459: (
|
||||
components: {
|
||||
}),
|
||||
8589934459: (components: {
|
||||
"bevy_ecs::name::Name": "ShowcaseSphere",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (-5.8, 0.6, 4.0),
|
||||
@ -292,10 +277,8 @@
|
||||
shape: Sphere,
|
||||
size: (1.2, 1.2, 1.2),
|
||||
),
|
||||
},
|
||||
),
|
||||
8589934460: (
|
||||
components: {
|
||||
}),
|
||||
8589934460: (components: {
|
||||
"bevy_ecs::name::Name": "ShowcaseSphere",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (-8.0, 0.6, 4.0),
|
||||
@ -332,10 +315,8 @@
|
||||
shape: Sphere,
|
||||
size: (1.2, 1.2, 1.2),
|
||||
),
|
||||
},
|
||||
),
|
||||
8589934461: (
|
||||
components: {
|
||||
}),
|
||||
8589934461: (components: {
|
||||
"bevy_ecs::name::Name": "Stair",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (12.0, 0.90000004, 9.5),
|
||||
@ -374,10 +355,8 @@
|
||||
shape: Box,
|
||||
size: (5.0, 1.8000001, 0.5),
|
||||
),
|
||||
},
|
||||
),
|
||||
8589934462: (
|
||||
components: {
|
||||
}),
|
||||
8589934462: (components: {
|
||||
"bevy_ecs::name::Name": "Stair",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (12.0, 0.81000006, 10.0),
|
||||
@ -416,10 +395,8 @@
|
||||
shape: Box,
|
||||
size: (5.0, 1.6200001, 0.5),
|
||||
),
|
||||
},
|
||||
),
|
||||
8589934463: (
|
||||
components: {
|
||||
}),
|
||||
8589934463: (components: {
|
||||
"bevy_ecs::name::Name": "Stair",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (12.0, 0.72, 10.5),
|
||||
@ -458,10 +435,8 @@
|
||||
shape: Box,
|
||||
size: (5.0, 1.44, 0.5),
|
||||
),
|
||||
},
|
||||
),
|
||||
8589934464: (
|
||||
components: {
|
||||
}),
|
||||
8589934464: (components: {
|
||||
"bevy_ecs::name::Name": "Stair",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (12.0, 0.63, 11.0),
|
||||
@ -500,10 +475,8 @@
|
||||
shape: Box,
|
||||
size: (5.0, 1.26, 0.5),
|
||||
),
|
||||
},
|
||||
),
|
||||
8589934465: (
|
||||
components: {
|
||||
}),
|
||||
8589934465: (components: {
|
||||
"bevy_ecs::name::Name": "Stair",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (12.0, 0.54, 11.5),
|
||||
@ -542,10 +515,8 @@
|
||||
shape: Box,
|
||||
size: (5.0, 1.08, 0.5),
|
||||
),
|
||||
},
|
||||
),
|
||||
8589934466: (
|
||||
components: {
|
||||
}),
|
||||
8589934466: (components: {
|
||||
"bevy_ecs::name::Name": "Stair",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (12.0, 0.45000002, 12.0),
|
||||
@ -584,10 +555,8 @@
|
||||
shape: Box,
|
||||
size: (5.0, 0.90000004, 0.5),
|
||||
),
|
||||
},
|
||||
),
|
||||
8589934467: (
|
||||
components: {
|
||||
}),
|
||||
8589934467: (components: {
|
||||
"bevy_ecs::name::Name": "Stair",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (12.0, 0.36, 12.5),
|
||||
@ -626,10 +595,8 @@
|
||||
shape: Box,
|
||||
size: (5.0, 0.72, 0.5),
|
||||
),
|
||||
},
|
||||
),
|
||||
8589934468: (
|
||||
components: {
|
||||
}),
|
||||
8589934468: (components: {
|
||||
"bevy_ecs::name::Name": "Stair",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (12.0, 0.27, 13.0),
|
||||
@ -668,10 +635,8 @@
|
||||
shape: Box,
|
||||
size: (5.0, 0.54, 0.5),
|
||||
),
|
||||
},
|
||||
),
|
||||
8589934469: (
|
||||
components: {
|
||||
}),
|
||||
8589934469: (components: {
|
||||
"bevy_ecs::name::Name": "Stair",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (12.0, 0.18, 13.5),
|
||||
@ -710,10 +675,8 @@
|
||||
shape: Box,
|
||||
size: (5.0, 0.36, 0.5),
|
||||
),
|
||||
},
|
||||
),
|
||||
8589934470: (
|
||||
components: {
|
||||
}),
|
||||
8589934470: (components: {
|
||||
"bevy_ecs::name::Name": "Stair",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (12.0, 0.09, 14.0),
|
||||
@ -752,10 +715,8 @@
|
||||
shape: Box,
|
||||
size: (5.0, 0.18, 0.5),
|
||||
),
|
||||
},
|
||||
),
|
||||
8589934471: (
|
||||
components: {
|
||||
}),
|
||||
8589934471: (components: {
|
||||
"bevy_ecs::name::Name": "Ramp",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (-14.0, 1.6, 0.0),
|
||||
@ -794,10 +755,8 @@
|
||||
shape: Box,
|
||||
size: (5.0, 0.4, 9.0),
|
||||
),
|
||||
},
|
||||
),
|
||||
8589934472: (
|
||||
components: {
|
||||
}),
|
||||
8589934472: (components: {
|
||||
"bevy_ecs::name::Name": "Crate",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (5.4, 0.5, 2.4),
|
||||
@ -836,10 +795,8 @@
|
||||
shape: Box,
|
||||
size: (1.0, 1.0, 1.0),
|
||||
),
|
||||
},
|
||||
),
|
||||
8589934473: (
|
||||
components: {
|
||||
}),
|
||||
8589934473: (components: {
|
||||
"bevy_ecs::name::Name": "Crate",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (4.0, 0.75, 2.0),
|
||||
@ -878,10 +835,8 @@
|
||||
shape: Box,
|
||||
size: (1.5, 1.5, 1.5),
|
||||
),
|
||||
},
|
||||
),
|
||||
8589934474: (
|
||||
components: {
|
||||
}),
|
||||
8589934474: (components: {
|
||||
"bevy_ecs::name::Name": "Crate",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (-2.3, 0.5, -3.1),
|
||||
@ -920,10 +875,8 @@
|
||||
shape: Box,
|
||||
size: (1.0, 1.0, 1.0),
|
||||
),
|
||||
},
|
||||
),
|
||||
8589934475: (
|
||||
components: {
|
||||
}),
|
||||
8589934475: (components: {
|
||||
"bevy_ecs::name::Name": "Crate",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (-1.6, 0.5, -3.2),
|
||||
@ -962,10 +915,8 @@
|
||||
shape: Box,
|
||||
size: (1.0, 1.0, 1.0),
|
||||
),
|
||||
},
|
||||
),
|
||||
8589934476: (
|
||||
components: {
|
||||
}),
|
||||
8589934476: (components: {
|
||||
"bevy_ecs::name::Name": "Crate",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (-3.0, 0.5, -3.0),
|
||||
@ -1004,10 +955,8 @@
|
||||
shape: Box,
|
||||
size: (1.0, 1.0, 1.0),
|
||||
),
|
||||
},
|
||||
),
|
||||
8589934477: (
|
||||
components: {
|
||||
}),
|
||||
8589934477: (components: {
|
||||
"bevy_ecs::name::Name": "Pillar",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (8.0, 3.5, 6.0),
|
||||
@ -1046,10 +995,8 @@
|
||||
shape: Box,
|
||||
size: (1.2, 7.0, 1.2),
|
||||
),
|
||||
},
|
||||
),
|
||||
8589934478: (
|
||||
components: {
|
||||
}),
|
||||
8589934478: (components: {
|
||||
"bevy_ecs::name::Name": "Pillar",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (-8.0, 3.5, 6.0),
|
||||
@ -1088,10 +1035,8 @@
|
||||
shape: Box,
|
||||
size: (1.2, 7.0, 1.2),
|
||||
),
|
||||
},
|
||||
),
|
||||
8589934479: (
|
||||
components: {
|
||||
}),
|
||||
8589934479: (components: {
|
||||
"bevy_ecs::name::Name": "Pillar",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (8.0, 3.5, -6.0),
|
||||
@ -1130,10 +1075,8 @@
|
||||
shape: Box,
|
||||
size: (1.2, 7.0, 1.2),
|
||||
),
|
||||
},
|
||||
),
|
||||
8589934480: (
|
||||
components: {
|
||||
}),
|
||||
8589934480: (components: {
|
||||
"bevy_ecs::name::Name": "Pillar",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (-8.0, 3.5, -6.0),
|
||||
@ -1172,7 +1115,6 @@
|
||||
shape: Box,
|
||||
size: (1.2, 7.0, 1.2),
|
||||
),
|
||||
},
|
||||
),
|
||||
}),
|
||||
},
|
||||
)
|
||||
@ -1,8 +1,7 @@
|
||||
(schema_version: 2,
|
||||
resources: {},
|
||||
(schema_version: 4,resources: {
|
||||
},
|
||||
entities: {
|
||||
4294967124: (
|
||||
components: {
|
||||
4294967124: (components: {
|
||||
"bevy_ecs::name::Name": "Cube",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (-14.0, 0.66941494, -1.9732417),
|
||||
@ -49,10 +48,8 @@
|
||||
shape: Box,
|
||||
size: (1.0, 1.0, 1.0),
|
||||
),
|
||||
},
|
||||
),
|
||||
4294967126: (
|
||||
components: {
|
||||
}),
|
||||
4294967126: (components: {
|
||||
"bevy_ecs::name::Name": "Cube",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (-13.0, 0.5, 2.0),
|
||||
@ -99,10 +96,8 @@
|
||||
shape: Box,
|
||||
size: (1.0, 1.0, 1.0),
|
||||
),
|
||||
},
|
||||
),
|
||||
4294967128: (
|
||||
components: {
|
||||
}),
|
||||
4294967128: (components: {
|
||||
"bevy_ecs::name::Name": "Point Light",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (-10.83656, 1.680895, -1.0713063),
|
||||
@ -129,10 +124,8 @@
|
||||
inner_angle_deg: 25.0,
|
||||
outer_angle_deg: 35.0,
|
||||
),
|
||||
},
|
||||
),
|
||||
4294967158: (
|
||||
components: {
|
||||
}),
|
||||
4294967158: (components: {
|
||||
"bevy_ecs::name::Name": "Scene Sun",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (0.0, 5.316672, 0.0),
|
||||
@ -159,10 +152,8 @@
|
||||
inner_angle_deg: 25.0,
|
||||
outer_angle_deg: 35.0,
|
||||
),
|
||||
},
|
||||
),
|
||||
4294967159: (
|
||||
components: {
|
||||
}),
|
||||
4294967159: (components: {
|
||||
"bevy_ecs::name::Name": "Ground",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (0.0, -0.5, 0.0),
|
||||
@ -209,10 +200,8 @@
|
||||
shape: Box,
|
||||
size: (200.0, 1.0, 200.0),
|
||||
),
|
||||
},
|
||||
),
|
||||
8589934417: (
|
||||
components: {
|
||||
}),
|
||||
8589934417: (components: {
|
||||
"bevy_ecs::name::Name": "Spot Light",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (-13.387727, 0.9319196, -5.454666),
|
||||
@ -239,10 +228,8 @@
|
||||
inner_angle_deg: 25.0,
|
||||
outer_angle_deg: 35.0,
|
||||
),
|
||||
},
|
||||
),
|
||||
8589934456: (
|
||||
components: {
|
||||
}),
|
||||
8589934456: (components: {
|
||||
"bevy_ecs::name::Name": "Player Start",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (0.0, 0.9900649, 12.46175),
|
||||
@ -256,10 +243,8 @@
|
||||
"shared::components::HierarchySiblingIndex": (2),
|
||||
"shared::components::LevelObject": (),
|
||||
"shared::components::PlayerSpawn": (),
|
||||
},
|
||||
),
|
||||
8589934457: (
|
||||
components: {
|
||||
}),
|
||||
8589934457: (components: {
|
||||
"bevy_ecs::name::Name": "ShowcaseSphere",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (-1.3999996, 0.6, 4.0),
|
||||
@ -304,10 +289,8 @@
|
||||
shape: Sphere,
|
||||
size: (1.2, 1.2, 1.2),
|
||||
),
|
||||
},
|
||||
),
|
||||
8589934458: (
|
||||
components: {
|
||||
}),
|
||||
8589934458: (components: {
|
||||
"bevy_ecs::name::Name": "ShowcaseSphere",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (-3.6, 0.6, 4.0),
|
||||
@ -352,10 +335,8 @@
|
||||
shape: Sphere,
|
||||
size: (1.2, 1.2, 1.2),
|
||||
),
|
||||
},
|
||||
),
|
||||
8589934459: (
|
||||
components: {
|
||||
}),
|
||||
8589934459: (components: {
|
||||
"bevy_ecs::name::Name": "ShowcaseSphere",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (-5.8, 0.6, 4.0),
|
||||
@ -400,10 +381,8 @@
|
||||
shape: Sphere,
|
||||
size: (1.2, 1.2, 1.2),
|
||||
),
|
||||
},
|
||||
),
|
||||
8589934460: (
|
||||
components: {
|
||||
}),
|
||||
8589934460: (components: {
|
||||
"bevy_ecs::name::Name": "ShowcaseSphere",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (-8.0, 0.6, 4.0),
|
||||
@ -448,10 +427,8 @@
|
||||
shape: Sphere,
|
||||
size: (1.2, 1.2, 1.2),
|
||||
),
|
||||
},
|
||||
),
|
||||
8589934461: (
|
||||
components: {
|
||||
}),
|
||||
8589934461: (components: {
|
||||
"bevy_ecs::name::Name": "Stair",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (12.0, 0.90000004, 9.5),
|
||||
@ -498,10 +475,8 @@
|
||||
shape: Box,
|
||||
size: (5.0, 1.8000001, 0.5),
|
||||
),
|
||||
},
|
||||
),
|
||||
8589934462: (
|
||||
components: {
|
||||
}),
|
||||
8589934462: (components: {
|
||||
"bevy_ecs::name::Name": "Stair",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (12.0, 0.81000006, 10.0),
|
||||
@ -548,10 +523,8 @@
|
||||
shape: Box,
|
||||
size: (5.0, 1.6200001, 0.5),
|
||||
),
|
||||
},
|
||||
),
|
||||
8589934463: (
|
||||
components: {
|
||||
}),
|
||||
8589934463: (components: {
|
||||
"bevy_ecs::name::Name": "Stair",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (12.0, 0.72, 10.5),
|
||||
@ -598,10 +571,8 @@
|
||||
shape: Box,
|
||||
size: (5.0, 1.44, 0.5),
|
||||
),
|
||||
},
|
||||
),
|
||||
8589934464: (
|
||||
components: {
|
||||
}),
|
||||
8589934464: (components: {
|
||||
"bevy_ecs::name::Name": "Stair",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (12.0, 0.63, 11.0),
|
||||
@ -648,10 +619,8 @@
|
||||
shape: Box,
|
||||
size: (5.0, 1.26, 0.5),
|
||||
),
|
||||
},
|
||||
),
|
||||
8589934465: (
|
||||
components: {
|
||||
}),
|
||||
8589934465: (components: {
|
||||
"bevy_ecs::name::Name": "Stair",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (12.0, 0.54, 11.5),
|
||||
@ -698,10 +667,8 @@
|
||||
shape: Box,
|
||||
size: (5.0, 1.08, 0.5),
|
||||
),
|
||||
},
|
||||
),
|
||||
8589934466: (
|
||||
components: {
|
||||
}),
|
||||
8589934466: (components: {
|
||||
"bevy_ecs::name::Name": "Stair",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (12.0, 0.45000002, 12.0),
|
||||
@ -748,10 +715,8 @@
|
||||
shape: Box,
|
||||
size: (5.0, 0.90000004, 0.5),
|
||||
),
|
||||
},
|
||||
),
|
||||
8589934467: (
|
||||
components: {
|
||||
}),
|
||||
8589934467: (components: {
|
||||
"bevy_ecs::name::Name": "Stair",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (12.0, 0.36, 12.5),
|
||||
@ -798,10 +763,8 @@
|
||||
shape: Box,
|
||||
size: (5.0, 0.72, 0.5),
|
||||
),
|
||||
},
|
||||
),
|
||||
8589934468: (
|
||||
components: {
|
||||
}),
|
||||
8589934468: (components: {
|
||||
"bevy_ecs::name::Name": "Stair",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (12.0, 0.27, 13.0),
|
||||
@ -848,10 +811,8 @@
|
||||
shape: Box,
|
||||
size: (5.0, 0.54, 0.5),
|
||||
),
|
||||
},
|
||||
),
|
||||
8589934469: (
|
||||
components: {
|
||||
}),
|
||||
8589934469: (components: {
|
||||
"bevy_ecs::name::Name": "Stair",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (12.0, 0.18, 13.5),
|
||||
@ -898,10 +859,8 @@
|
||||
shape: Box,
|
||||
size: (5.0, 0.36, 0.5),
|
||||
),
|
||||
},
|
||||
),
|
||||
8589934470: (
|
||||
components: {
|
||||
}),
|
||||
8589934470: (components: {
|
||||
"bevy_ecs::name::Name": "Stair",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (12.0, 0.09, 14.0),
|
||||
@ -948,10 +907,8 @@
|
||||
shape: Box,
|
||||
size: (5.0, 0.18, 0.5),
|
||||
),
|
||||
},
|
||||
),
|
||||
8589934471: (
|
||||
components: {
|
||||
}),
|
||||
8589934471: (components: {
|
||||
"bevy_ecs::name::Name": "Ramp",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (-14.0, 2.292737, 0.0),
|
||||
@ -998,10 +955,8 @@
|
||||
shape: Box,
|
||||
size: (5.0, 0.4, 9.0),
|
||||
),
|
||||
},
|
||||
),
|
||||
8589934472: (
|
||||
components: {
|
||||
}),
|
||||
8589934472: (components: {
|
||||
"bevy_ecs::name::Name": "Crate",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (5.4, 0.5, 2.4),
|
||||
@ -1048,10 +1003,8 @@
|
||||
shape: Box,
|
||||
size: (1.0, 1.0, 1.0),
|
||||
),
|
||||
},
|
||||
),
|
||||
8589934473: (
|
||||
components: {
|
||||
}),
|
||||
8589934473: (components: {
|
||||
"bevy_ecs::name::Name": "Crate",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (4.0, 0.75, 2.0),
|
||||
@ -1098,10 +1051,8 @@
|
||||
shape: Box,
|
||||
size: (1.5, 1.5, 1.5),
|
||||
),
|
||||
},
|
||||
),
|
||||
8589934474: (
|
||||
components: {
|
||||
}),
|
||||
8589934474: (components: {
|
||||
"bevy_ecs::name::Name": "Crate",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (-2.3, 0.5, -3.1),
|
||||
@ -1148,10 +1099,8 @@
|
||||
shape: Box,
|
||||
size: (1.0, 1.0, 1.0),
|
||||
),
|
||||
},
|
||||
),
|
||||
8589934475: (
|
||||
components: {
|
||||
}),
|
||||
8589934475: (components: {
|
||||
"bevy_ecs::name::Name": "Crate",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (-1.6, 0.5, -3.2),
|
||||
@ -1198,10 +1147,8 @@
|
||||
shape: Box,
|
||||
size: (1.0, 1.0, 1.0),
|
||||
),
|
||||
},
|
||||
),
|
||||
8589934476: (
|
||||
components: {
|
||||
}),
|
||||
8589934476: (components: {
|
||||
"bevy_ecs::name::Name": "Crate",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (-3.0, 0.5, -3.0),
|
||||
@ -1248,10 +1195,8 @@
|
||||
shape: Box,
|
||||
size: (1.0, 1.0, 1.0),
|
||||
),
|
||||
},
|
||||
),
|
||||
8589934477: (
|
||||
components: {
|
||||
}),
|
||||
8589934477: (components: {
|
||||
"bevy_ecs::name::Name": "Pillar",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (8.0, 3.5, 6.0),
|
||||
@ -1298,10 +1243,8 @@
|
||||
shape: Box,
|
||||
size: (1.2, 7.0, 1.2),
|
||||
),
|
||||
},
|
||||
),
|
||||
8589934478: (
|
||||
components: {
|
||||
}),
|
||||
8589934478: (components: {
|
||||
"bevy_ecs::name::Name": "Pillar",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (-8.0, 3.5, 6.0),
|
||||
@ -1348,10 +1291,8 @@
|
||||
shape: Box,
|
||||
size: (1.2, 7.0, 1.2),
|
||||
),
|
||||
},
|
||||
),
|
||||
8589934479: (
|
||||
components: {
|
||||
}),
|
||||
8589934479: (components: {
|
||||
"bevy_ecs::name::Name": "Pillar",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (8.0, 3.5, -6.0),
|
||||
@ -1398,10 +1339,8 @@
|
||||
shape: Box,
|
||||
size: (1.2, 7.0, 1.2),
|
||||
),
|
||||
},
|
||||
),
|
||||
8589934480: (
|
||||
components: {
|
||||
}),
|
||||
8589934480: (components: {
|
||||
"bevy_ecs::name::Name": "Pillar",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (-8.0, 3.5, -6.0),
|
||||
@ -1448,10 +1387,8 @@
|
||||
shape: Box,
|
||||
size: (1.2, 7.0, 1.2),
|
||||
),
|
||||
},
|
||||
),
|
||||
12884901711: (
|
||||
components: {
|
||||
}),
|
||||
12884901711: (components: {
|
||||
"bevy_ecs::name::Name": "Cube",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (-13.391208, 0.5, -6.673126),
|
||||
@ -1498,7 +1435,6 @@
|
||||
shape: Box,
|
||||
size: (1.0, 1.0, 1.0),
|
||||
),
|
||||
},
|
||||
),
|
||||
}),
|
||||
},
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
70
assets/levels/navigation_authoring_showcase.scn.ron
Normal file
70
assets/levels/navigation_authoring_showcase.scn.ron
Normal file
@ -0,0 +1,70 @@
|
||||
(schema_version: 4,resources: {
|
||||
},
|
||||
entities: {
|
||||
1: (components: {
|
||||
"bevy_ecs::name::Name": "Navigation Bounds",
|
||||
"bevy_transform::components::transform::Transform": (translation: (0.0, 0.0, 0.0), rotation: (0.0, 0.0, 0.0, 1.0), scale: (1.0, 1.0, 1.0)),
|
||||
"shared::components::ActorId": ("navigation-bounds-main"),
|
||||
"shared::components::ActorKind": Navigation,
|
||||
"shared::components::EditorVisibility": (visible: true),
|
||||
"shared::components::HierarchySiblingIndex": (0),
|
||||
"shared::components::LevelObject": (),
|
||||
"shared::navigation::NavigationBounds": (
|
||||
half_extents: (8.0, 2.0, 8.0),
|
||||
agent: (
|
||||
id: "humanoid",
|
||||
radius: 0.35,
|
||||
height: 1.8,
|
||||
max_climb: 0.45,
|
||||
max_slope_deg: 45.0,
|
||||
cell_size_fraction: 3.0,
|
||||
cell_height_fraction: 6.0,
|
||||
min_region_size: 1,
|
||||
merge_region_size: 2,
|
||||
),
|
||||
artifact_path: "assets/navigation/generated/navigation_showcase_humanoid.nav.ron",
|
||||
auto_bake: false,
|
||||
),
|
||||
}),
|
||||
2: (components: {
|
||||
"bevy_ecs::name::Name": "Center Obstacle",
|
||||
"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::ActorId": ("navigation-obstacle-center"),
|
||||
"shared::components::ActorKind": Navigation,
|
||||
"shared::components::EditorVisibility": (visible: true),
|
||||
"shared::components::HierarchySiblingIndex": (1),
|
||||
"shared::components::LevelObject": (),
|
||||
"shared::navigation::NavigationObstacle": (half_extents: (1.0, 1.0, 2.5), dynamic: false),
|
||||
}),
|
||||
3: (components: {
|
||||
"bevy_ecs::name::Name": "Slow Area",
|
||||
"bevy_transform::components::transform::Transform": (translation: (-4.0, 0.5, -4.0), rotation: (0.0, 0.0, 0.0, 1.0), scale: (1.0, 1.0, 1.0)),
|
||||
"shared::components::ActorId": ("navigation-area-slow"),
|
||||
"shared::components::ActorKind": Navigation,
|
||||
"shared::components::EditorVisibility": (visible: true),
|
||||
"shared::components::HierarchySiblingIndex": (2),
|
||||
"shared::components::LevelObject": (),
|
||||
"shared::navigation::NavigationArea": (id: "slow", half_extents: (1.5, 1.0, 1.5), cost: 1.5, walkable: true),
|
||||
}),
|
||||
4: (components: {
|
||||
"bevy_ecs::name::Name": "Obstacle Link",
|
||||
"bevy_transform::components::transform::Transform": (translation: (0.0, 0.0, 0.0), rotation: (0.0, 0.0, 0.0, 1.0), scale: (1.0, 1.0, 1.0)),
|
||||
"shared::components::ActorId": ("navigation-link-center"),
|
||||
"shared::components::ActorKind": Navigation,
|
||||
"shared::components::EditorVisibility": (visible: true),
|
||||
"shared::components::HierarchySiblingIndex": (3),
|
||||
"shared::components::LevelObject": (),
|
||||
"shared::navigation::NavigationLink": (start: (-1.5, 0.0, 0.0), end: (1.5, 0.0, 0.0), bidirectional: true, cost: 1.2, enabled: true),
|
||||
}),
|
||||
5: (components: {
|
||||
"bevy_ecs::name::Name": "Walkable Floor Geometry",
|
||||
"bevy_transform::components::transform::Transform": (translation: (0.0, -0.25, 0.0), rotation: (0.0, 0.0, 0.0, 1.0), scale: (1.0, 1.0, 1.0)),
|
||||
"shared::components::ActorId": ("navigation-floor-geometry"),
|
||||
"shared::components::ActorKind": StaticMesh,
|
||||
"shared::components::EditorVisibility": (visible: true),
|
||||
"shared::components::HierarchySiblingIndex": (4),
|
||||
"shared::components::LevelObject": (),
|
||||
"shared::components::Primitive": (shape: Box, size: (16.0, 0.5, 16.0)),
|
||||
}),
|
||||
},
|
||||
)
|
||||
@ -1,16 +1,15 @@
|
||||
(schema_version: 2,
|
||||
resources: {},
|
||||
(schema_version: 4,resources: {
|
||||
},
|
||||
entities: {
|
||||
4294968001: (
|
||||
components: {
|
||||
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::LevelObject": (),
|
||||
"shared::components::PostProcessVolumeDesc": (
|
||||
half_extents: (6.0, 3.0, 6.0),
|
||||
priority: 0,
|
||||
@ -23,18 +22,16 @@
|
||||
profile: None,
|
||||
label: Some("Foggy courtyard"),
|
||||
),
|
||||
},
|
||||
),
|
||||
4294968002: (
|
||||
components: {
|
||||
}),
|
||||
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::LevelObject": (),
|
||||
"shared::components::PostProcessVolumeDesc": (
|
||||
half_extents: (4.0, 2.5, 4.0),
|
||||
priority: 5,
|
||||
@ -47,18 +44,16 @@
|
||||
profile: Some("assets/rendering_profiles/cave_dark.ron"),
|
||||
label: Some("Cave mouth"),
|
||||
),
|
||||
},
|
||||
),
|
||||
4294968003: (
|
||||
components: {
|
||||
}),
|
||||
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::LevelObject": (),
|
||||
"shared::components::PostProcessVolumeDesc": (
|
||||
half_extents: (5.0, 2.0, 5.0),
|
||||
priority: 2,
|
||||
@ -68,7 +63,6 @@
|
||||
profile: None,
|
||||
label: Some("Vignette demo"),
|
||||
),
|
||||
},
|
||||
),
|
||||
}),
|
||||
},
|
||||
)
|
||||
@ -1,12 +1,40 @@
|
||||
(
|
||||
schema_version: 1,
|
||||
label: "Concrete",
|
||||
shader: None,
|
||||
shader_ref: None,
|
||||
render_state: (
|
||||
alpha_mode: Opaque,
|
||||
alpha_cutoff: 0.5,
|
||||
double_sided: false,
|
||||
),
|
||||
material: (
|
||||
base_color: (r: 0.55, g: 0.54, b: 0.52, a: 1.0),
|
||||
shader: (
|
||||
kind: StandardLit,
|
||||
schema_path: None,
|
||||
shader_path: None,
|
||||
),
|
||||
base_color: (
|
||||
r: 0.55,
|
||||
g: 0.54,
|
||||
b: 0.52,
|
||||
a: 1.0,
|
||||
),
|
||||
metallic: 0.0,
|
||||
roughness: 0.85,
|
||||
emissive_color: (
|
||||
r: 1.0,
|
||||
g: 1.0,
|
||||
b: 1.0,
|
||||
a: 1.0,
|
||||
),
|
||||
emissive_intensity: 0.0,
|
||||
base_color_texture: None,
|
||||
emissive_texture: None,
|
||||
normal_map_texture: None,
|
||||
metallic_roughness_texture: None,
|
||||
material_asset_path: None,
|
||||
parameters: [],
|
||||
textures: [],
|
||||
),
|
||||
)
|
||||
@ -1,15 +1,40 @@
|
||||
(
|
||||
schema_version: 1,
|
||||
label: "Emissive Panel",
|
||||
shader: None,
|
||||
shader_ref: None,
|
||||
render_state: (
|
||||
alpha_mode: Opaque,
|
||||
alpha_cutoff: 0.5,
|
||||
double_sided: false,
|
||||
),
|
||||
material: (
|
||||
base_color: (r: 0.15, g: 0.22, b: 0.28, a: 1.0),
|
||||
shader: (
|
||||
kind: StandardLit,
|
||||
schema_path: None,
|
||||
shader_path: None,
|
||||
),
|
||||
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_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,
|
||||
parameters: [],
|
||||
textures: [],
|
||||
),
|
||||
)
|
||||
71
assets/materials/surface_tint.ron
Normal file
71
assets/materials/surface_tint.ron
Normal file
@ -0,0 +1,71 @@
|
||||
(
|
||||
schema_version: 1,
|
||||
label: "Surface Tint",
|
||||
shader: Some("assets/shaders/surface_tint.shader.ron"),
|
||||
shader_ref: None,
|
||||
render_state: (
|
||||
alpha_mode: Opaque,
|
||||
alpha_cutoff: 0.5,
|
||||
double_sided: false,
|
||||
),
|
||||
material: (
|
||||
shader: (
|
||||
kind: Custom,
|
||||
schema_path: Some("assets/shaders/surface_tint.shader.ron"),
|
||||
shader_path: Some("assets/shaders/surface_tint.wgsl"),
|
||||
),
|
||||
base_color: (
|
||||
r: 0.12,
|
||||
g: 0.42,
|
||||
b: 0.95,
|
||||
a: 1.0,
|
||||
),
|
||||
metallic: 0.15,
|
||||
roughness: 0.28,
|
||||
emissive_color: (
|
||||
r: 0.02,
|
||||
g: 0.08,
|
||||
b: 0.25,
|
||||
a: 1.0,
|
||||
),
|
||||
emissive_intensity: 3.0,
|
||||
base_color_texture: None,
|
||||
emissive_texture: None,
|
||||
normal_map_texture: None,
|
||||
metallic_roughness_texture: None,
|
||||
material_asset_path: None,
|
||||
parameters: [
|
||||
(
|
||||
name: "tint",
|
||||
value: Color((
|
||||
r: 0.12,
|
||||
g: 0.42,
|
||||
b: 0.95,
|
||||
a: 1.0,
|
||||
)),
|
||||
),
|
||||
(
|
||||
name: "roughness",
|
||||
value: Float(0.28),
|
||||
),
|
||||
(
|
||||
name: "metallic",
|
||||
value: Float(0.15),
|
||||
),
|
||||
(
|
||||
name: "emissive",
|
||||
value: Color((
|
||||
r: 0.02,
|
||||
g: 0.08,
|
||||
b: 0.25,
|
||||
a: 1.0,
|
||||
)),
|
||||
),
|
||||
(
|
||||
name: "emissive_intensity",
|
||||
value: Float(3.0),
|
||||
),
|
||||
],
|
||||
textures: [],
|
||||
),
|
||||
)
|
||||
12
assets/materials/surface_tint_instance.ron
Normal file
12
assets/materials/surface_tint_instance.ron
Normal file
@ -0,0 +1,12 @@
|
||||
(
|
||||
schema_version: 1,
|
||||
label: "surface_tint Instance",
|
||||
base: ((
|
||||
asset_id: "d6cb4151-7124-4237-aaf9-f7f8abd5fb76",
|
||||
sub_asset_id: "material:source",
|
||||
label: "surface_tint",
|
||||
source_path: Some("assets/materials/surface_tint.ron"),
|
||||
)),
|
||||
parameters: [],
|
||||
textures: [],
|
||||
)
|
||||
@ -0,0 +1,360 @@
|
||||
(
|
||||
schema_version: 3,
|
||||
asset_id: "113f74df-e39c-41d4-9b5b-e48efe541f7f",
|
||||
label: "RobotExpressive",
|
||||
source: (
|
||||
path: "assets/models/RobotExpressive.glb",
|
||||
format: "glb",
|
||||
fingerprint: (
|
||||
byte_len: 463988,
|
||||
modified_unix_secs: 1783799272,
|
||||
),
|
||||
dependencies: [],
|
||||
),
|
||||
import: (
|
||||
scale: 1.0,
|
||||
generate_collider: true,
|
||||
lod0_only: true,
|
||||
placement_mode: SceneInstance,
|
||||
hierarchy_mode: SourceHierarchy,
|
||||
material_policy: SourceMaterials,
|
||||
),
|
||||
metadata: (
|
||||
mesh_count: 14,
|
||||
material_count: 3,
|
||||
node_count: 74,
|
||||
animation_count: 14,
|
||||
skin_count: 2,
|
||||
light_count: 0,
|
||||
camera_count: 0,
|
||||
),
|
||||
parts: [
|
||||
(
|
||||
id: "draw:scene0:node4:mesh0:primitive0",
|
||||
name: "Foot.L / Primitive 0",
|
||||
mesh_label: "Mesh0/Primitive0",
|
||||
material_id: Some("material:material0"),
|
||||
material_slot_name: "Grey",
|
||||
material_label: Some("Material0"),
|
||||
local_transform: (
|
||||
translation: (0.62405795, 0.39766362, 0.19111444),
|
||||
rotation: (0.7071068, 0.00000005228401, 0.000000015504241, -0.70710665),
|
||||
scale: (100.0, 99.999985, 100.0),
|
||||
),
|
||||
source_node: Some("RootNode/RobotArmature/Bone/Foot.L/Foot.L"),
|
||||
source_mesh: Some("Mesh0"),
|
||||
source_material: Some("Grey"),
|
||||
skinned: false,
|
||||
),
|
||||
(
|
||||
id: "draw:scene0:node7:mesh1:primitive0",
|
||||
name: "Torso / Primitive 0",
|
||||
mesh_label: "Mesh1/Primitive0",
|
||||
material_id: Some("material:material0"),
|
||||
material_slot_name: "Grey",
|
||||
material_label: Some("Material0"),
|
||||
local_transform: (
|
||||
translation: (-0.0027156367, 2.1737509, -0.019581214),
|
||||
rotation: (0.70710677, 0.000000000000031351603, 0.000000000000055267226, -0.7071067),
|
||||
scale: (100.0, 99.99998, 99.99998),
|
||||
),
|
||||
source_node: Some("RootNode/RobotArmature/Bone/Body/Torso"),
|
||||
source_mesh: Some("Mesh1"),
|
||||
source_material: Some("Grey"),
|
||||
skinned: false,
|
||||
),
|
||||
(
|
||||
id: "draw:scene0:node7:mesh1:primitive1",
|
||||
name: "Torso / Primitive 1",
|
||||
mesh_label: "Mesh1/Primitive1",
|
||||
material_id: Some("material:material1"),
|
||||
material_slot_name: "Main",
|
||||
material_label: Some("Material1"),
|
||||
local_transform: (
|
||||
translation: (-0.0027156367, 2.1737509, -0.019581214),
|
||||
rotation: (0.70710677, 0.000000000000031351603, 0.000000000000055267226, -0.7071067),
|
||||
scale: (100.0, 99.99998, 99.99998),
|
||||
),
|
||||
source_node: Some("RootNode/RobotArmature/Bone/Body/Torso"),
|
||||
source_mesh: Some("Mesh1"),
|
||||
source_material: Some("Main"),
|
||||
skinned: false,
|
||||
),
|
||||
(
|
||||
id: "draw:scene0:node61:mesh10:primitive0",
|
||||
name: "Leg.R / Primitive 0",
|
||||
mesh_label: "Mesh10/Primitive0",
|
||||
material_id: Some("material:material1"),
|
||||
material_slot_name: "Main",
|
||||
material_label: Some("Material1"),
|
||||
local_transform: (
|
||||
translation: (-0.6746981, 1.1586236, 0.046261936),
|
||||
rotation: (0.7693698, 0.080835514, 0.110868365, -0.6238942),
|
||||
scale: (99.99999, 99.99886, 100.00024),
|
||||
),
|
||||
source_node: Some("RootNode/RobotArmature/Bone/Body/UpperLeg.R/Leg.R"),
|
||||
source_mesh: Some("Mesh10"),
|
||||
source_material: Some("Main"),
|
||||
skinned: false,
|
||||
),
|
||||
(
|
||||
id: "draw:scene0:node63:mesh11:primitive0",
|
||||
name: "LowerLeg.R / Primitive 0",
|
||||
mesh_label: "Mesh11/Primitive0",
|
||||
material_id: Some("material:material1"),
|
||||
material_slot_name: "Main",
|
||||
material_label: Some("Material1"),
|
||||
local_transform: (
|
||||
translation: (-0.73626167, 0.34982973, 0.22594015),
|
||||
rotation: (-0.65091395, -0.099058665, -0.09436109, 0.7467224),
|
||||
scale: (99.99999, 99.998604, 100.00058),
|
||||
),
|
||||
source_node: Some("RootNode/RobotArmature/Bone/Body/UpperLeg.R/LowerLeg.R/LowerLeg.R"),
|
||||
source_mesh: Some("Mesh11"),
|
||||
source_material: Some("Main"),
|
||||
skinned: false,
|
||||
),
|
||||
(
|
||||
id: "draw:scene0:node72:mesh12:primitive0",
|
||||
name: "Hand.R / Primitive 0",
|
||||
mesh_label: "Mesh12/Primitive0",
|
||||
material_id: Some("material:material1"),
|
||||
material_slot_name: "Main",
|
||||
material_label: Some("Material1"),
|
||||
local_transform: (
|
||||
translation: (-0.002715637, 2.3702974, -0.020994147),
|
||||
rotation: (0.70710677, 0.0, 0.0, -0.7071067),
|
||||
scale: (100.0, 99.99999, 99.99999),
|
||||
),
|
||||
source_node: Some("RootNode/Hand.R"),
|
||||
source_mesh: Some("Mesh12"),
|
||||
source_material: Some("Main"),
|
||||
skinned: true,
|
||||
),
|
||||
(
|
||||
id: "draw:scene0:node72:mesh12:primitive1",
|
||||
name: "Hand.R / Primitive 1",
|
||||
mesh_label: "Mesh12/Primitive1",
|
||||
material_id: Some("material:material0"),
|
||||
material_slot_name: "Grey",
|
||||
material_label: Some("Material0"),
|
||||
local_transform: (
|
||||
translation: (-0.002715637, 2.3702974, -0.020994147),
|
||||
rotation: (0.70710677, 0.0, 0.0, -0.7071067),
|
||||
scale: (100.0, 99.99999, 99.99999),
|
||||
),
|
||||
source_node: Some("RootNode/Hand.R"),
|
||||
source_mesh: Some("Mesh12"),
|
||||
source_material: Some("Grey"),
|
||||
skinned: true,
|
||||
),
|
||||
(
|
||||
id: "draw:scene0:node73:mesh13:primitive0",
|
||||
name: "Hand.L / Primitive 0",
|
||||
mesh_label: "Mesh13/Primitive0",
|
||||
material_id: Some("material:material1"),
|
||||
material_slot_name: "Main",
|
||||
material_label: Some("Material1"),
|
||||
local_transform: (
|
||||
translation: (-0.002715637, 2.3702974, -0.020994147),
|
||||
rotation: (0.70710677, 0.0, 0.0, -0.7071067),
|
||||
scale: (100.0, 99.99999, 99.99999),
|
||||
),
|
||||
source_node: Some("RootNode/Hand.L"),
|
||||
source_mesh: Some("Mesh13"),
|
||||
source_material: Some("Main"),
|
||||
skinned: true,
|
||||
),
|
||||
(
|
||||
id: "draw:scene0:node73:mesh13:primitive1",
|
||||
name: "Hand.L / Primitive 1",
|
||||
mesh_label: "Mesh13/Primitive1",
|
||||
material_id: Some("material:material0"),
|
||||
material_slot_name: "Grey",
|
||||
material_label: Some("Material0"),
|
||||
local_transform: (
|
||||
translation: (-0.002715637, 2.3702974, -0.020994147),
|
||||
rotation: (0.70710677, 0.0, 0.0, -0.7071067),
|
||||
scale: (100.0, 99.99999, 99.99999),
|
||||
),
|
||||
source_node: Some("RootNode/Hand.L"),
|
||||
source_mesh: Some("Mesh13"),
|
||||
source_material: Some("Grey"),
|
||||
skinned: true,
|
||||
),
|
||||
(
|
||||
id: "draw:scene0:node13:mesh2:primitive0",
|
||||
name: "Head / Primitive 0",
|
||||
mesh_label: "Mesh2/Primitive0",
|
||||
material_id: Some("material:material0"),
|
||||
material_slot_name: "Grey",
|
||||
material_label: Some("Material0"),
|
||||
local_transform: (
|
||||
translation: (-0.05334633, 3.6175368, -0.0068152454),
|
||||
rotation: (0.7249889, 0.0852804, 0.05460569, -0.6812757),
|
||||
scale: (99.99997, 99.99996, 99.99997),
|
||||
),
|
||||
source_node: Some("RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Neck/Head/Head"),
|
||||
source_mesh: Some("Mesh2"),
|
||||
source_material: Some("Grey"),
|
||||
skinned: false,
|
||||
),
|
||||
(
|
||||
id: "draw:scene0:node13:mesh2:primitive1",
|
||||
name: "Head / Primitive 1",
|
||||
mesh_label: "Mesh2/Primitive1",
|
||||
material_id: Some("material:material1"),
|
||||
material_slot_name: "Main",
|
||||
material_label: Some("Material1"),
|
||||
local_transform: (
|
||||
translation: (-0.05334633, 3.6175368, -0.0068152454),
|
||||
rotation: (0.7249889, 0.0852804, 0.05460569, -0.6812757),
|
||||
scale: (99.99997, 99.99996, 99.99997),
|
||||
),
|
||||
source_node: Some("RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Neck/Head/Head"),
|
||||
source_mesh: Some("Mesh2"),
|
||||
source_material: Some("Main"),
|
||||
skinned: false,
|
||||
),
|
||||
(
|
||||
id: "draw:scene0:node13:mesh2:primitive2",
|
||||
name: "Head / Primitive 2",
|
||||
mesh_label: "Mesh2/Primitive2",
|
||||
material_id: Some("material:material2"),
|
||||
material_slot_name: "Black",
|
||||
material_label: Some("Material2"),
|
||||
local_transform: (
|
||||
translation: (-0.05334633, 3.6175368, -0.0068152454),
|
||||
rotation: (0.7249889, 0.0852804, 0.05460569, -0.6812757),
|
||||
scale: (99.99997, 99.99996, 99.99997),
|
||||
),
|
||||
source_node: Some("RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Neck/Head/Head"),
|
||||
source_mesh: Some("Mesh2"),
|
||||
source_material: Some("Black"),
|
||||
skinned: false,
|
||||
),
|
||||
(
|
||||
id: "draw:scene0:node68:mesh3:primitive0",
|
||||
name: "Foot.R / Primitive 0",
|
||||
mesh_label: "Mesh3/Primitive0",
|
||||
material_id: Some("material:material0"),
|
||||
material_slot_name: "Grey",
|
||||
material_label: Some("Material0"),
|
||||
local_transform: (
|
||||
translation: (-0.637997, 0.3976636, 0.19111452),
|
||||
rotation: (0.70710677, 0.00000005201811, 0.000000015770217, -0.7071067),
|
||||
scale: (100.0, 99.999985, 100.0),
|
||||
),
|
||||
source_node: Some("RootNode/RobotArmature/Bone/Foot.R/Foot.R"),
|
||||
source_mesh: Some("Mesh3"),
|
||||
source_material: Some("Grey"),
|
||||
skinned: false,
|
||||
),
|
||||
(
|
||||
id: "draw:scene0:node16:mesh4:primitive0",
|
||||
name: "Shoulder.L / Primitive 0",
|
||||
mesh_label: "Mesh4/Primitive0",
|
||||
material_id: Some("material:material1"),
|
||||
material_slot_name: "Main",
|
||||
material_label: Some("Material1"),
|
||||
local_transform: (
|
||||
translation: (0.58268076, 2.3724597, -0.020994142),
|
||||
rotation: (-0.59886926, 0.37597278, -0.37597266, 0.59886944),
|
||||
scale: (99.99996, 99.999954, 99.99997),
|
||||
),
|
||||
source_node: Some("RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/Shoulder.L"),
|
||||
source_mesh: Some("Mesh4"),
|
||||
source_material: Some("Main"),
|
||||
skinned: false,
|
||||
),
|
||||
(
|
||||
id: "draw:scene0:node18:mesh5:primitive0",
|
||||
name: "Arm.L / Primitive 0",
|
||||
mesh_label: "Mesh5/Primitive0",
|
||||
material_id: Some("material:material1"),
|
||||
material_slot_name: "Main",
|
||||
material_label: Some("Material1"),
|
||||
local_transform: (
|
||||
translation: (0.26500192, 2.8489676, 0.13069445),
|
||||
rotation: (-0.5164575, 0.4218856, -0.32294926, 0.67155623),
|
||||
scale: (99.99994, 99.99994, 99.99999),
|
||||
),
|
||||
source_node: Some("RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/Arm.L"),
|
||||
source_mesh: Some("Mesh5"),
|
||||
source_material: Some("Main"),
|
||||
skinned: false,
|
||||
),
|
||||
(
|
||||
id: "draw:scene0:node36:mesh6:primitive0",
|
||||
name: "Shoulder.R / Primitive 0",
|
||||
mesh_label: "Mesh6/Primitive0",
|
||||
material_id: Some("material:material1"),
|
||||
material_slot_name: "Main",
|
||||
material_label: Some("Material1"),
|
||||
local_transform: (
|
||||
translation: (-0.6369861, 2.3718858, -0.020994166),
|
||||
rotation: (0.6017577, 0.37133226, -0.37133226, -0.6017577),
|
||||
scale: (99.999954, 99.99997, 99.99994),
|
||||
),
|
||||
source_node: Some("RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/Shoulder.R"),
|
||||
source_mesh: Some("Mesh6"),
|
||||
source_material: Some("Main"),
|
||||
skinned: false,
|
||||
),
|
||||
(
|
||||
id: "draw:scene0:node38:mesh7:primitive0",
|
||||
name: "Arm.R / Primitive 0",
|
||||
mesh_label: "Mesh7/Primitive0",
|
||||
material_id: Some("material:material1"),
|
||||
material_slot_name: "Main",
|
||||
material_label: Some("Material1"),
|
||||
local_transform: (
|
||||
translation: (-0.82568955, 1.9825263, 0.040243305),
|
||||
rotation: (0.6276481, 0.35463694, -0.3873087, -0.5747021),
|
||||
scale: (22.70405, 22.704054, 22.704046),
|
||||
),
|
||||
source_node: Some("RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/Arm.R"),
|
||||
source_mesh: Some("Mesh7"),
|
||||
source_material: Some("Main"),
|
||||
skinned: false,
|
||||
),
|
||||
(
|
||||
id: "draw:scene0:node56:mesh8:primitive0",
|
||||
name: "Leg.L / Primitive 0",
|
||||
mesh_label: "Mesh8/Primitive0",
|
||||
material_id: Some("material:material1"),
|
||||
material_slot_name: "Main",
|
||||
material_label: Some("Material1"),
|
||||
local_transform: (
|
||||
translation: (0.6488191, 1.1620699, 0.057466835),
|
||||
rotation: (0.76387787, -0.05410902, -0.070370995, -0.6392268),
|
||||
scale: (99.99999, 100.00079, 100.00024),
|
||||
),
|
||||
source_node: Some("RootNode/RobotArmature/Bone/Body/UpperLeg.L/Leg.L"),
|
||||
source_mesh: Some("Mesh8"),
|
||||
source_material: Some("Main"),
|
||||
skinned: false,
|
||||
),
|
||||
(
|
||||
id: "draw:scene0:node58:mesh9:primitive0",
|
||||
name: "LowerLeg.L / Primitive 0",
|
||||
mesh_label: "Mesh9/Primitive0",
|
||||
material_id: Some("material:material1"),
|
||||
material_slot_name: "Main",
|
||||
material_label: Some("Material1"),
|
||||
local_transform: (
|
||||
translation: (0.68805873, 0.3497491, 0.23465614),
|
||||
rotation: (-0.6547612, 0.06408298, 0.061960325, 0.7505613),
|
||||
scale: (100.000015, 100.0006, 100.000496),
|
||||
),
|
||||
source_node: Some("RootNode/RobotArmature/Bone/Body/UpperLeg.L/LowerLeg.L/LowerLeg.L"),
|
||||
source_mesh: Some("Mesh9"),
|
||||
source_material: Some("Main"),
|
||||
skinned: false,
|
||||
),
|
||||
],
|
||||
warnings: [
|
||||
"Animations are recorded as metadata; animated placement uses SkinnedMeshRenderer.",
|
||||
"Skinned primitives are excluded from StaticMeshRenderer and use SkinnedMeshRenderer.",
|
||||
],
|
||||
)
|
||||
@ -0,0 +1,360 @@
|
||||
(
|
||||
schema_version: 3,
|
||||
asset_id: "3f63f359-45eb-4cb2-8970-71921cbd7bd0",
|
||||
label: "robot_expressive",
|
||||
source: (
|
||||
path: "assets/models/robot_expressive.glb",
|
||||
format: "glb",
|
||||
fingerprint: (
|
||||
byte_len: 463988,
|
||||
modified_unix_secs: 1783750957,
|
||||
),
|
||||
dependencies: [],
|
||||
),
|
||||
import: (
|
||||
scale: 1.0,
|
||||
generate_collider: true,
|
||||
lod0_only: true,
|
||||
placement_mode: StaticAsset,
|
||||
hierarchy_mode: SingleActor,
|
||||
material_policy: SourceMaterials,
|
||||
),
|
||||
metadata: (
|
||||
mesh_count: 14,
|
||||
material_count: 3,
|
||||
node_count: 74,
|
||||
animation_count: 14,
|
||||
skin_count: 2,
|
||||
light_count: 0,
|
||||
camera_count: 0,
|
||||
),
|
||||
parts: [
|
||||
(
|
||||
id: "draw:scene0:node4:mesh0:primitive0",
|
||||
name: "Foot.L / Primitive 0",
|
||||
mesh_label: "Mesh0/Primitive0",
|
||||
material_id: Some("material:material0"),
|
||||
material_slot_name: "Grey",
|
||||
material_label: Some("Material0"),
|
||||
local_transform: (
|
||||
translation: (0.62405795, 0.39766362, 0.19111444),
|
||||
rotation: (0.7071068, 0.00000005228401, 0.000000015504241, -0.70710665),
|
||||
scale: (100.0, 99.999985, 100.0),
|
||||
),
|
||||
source_node: Some("RootNode/RobotArmature/Bone/Foot.L/Foot.L"),
|
||||
source_mesh: Some("Mesh0"),
|
||||
source_material: Some("Grey"),
|
||||
skinned: false,
|
||||
),
|
||||
(
|
||||
id: "draw:scene0:node7:mesh1:primitive0",
|
||||
name: "Torso / Primitive 0",
|
||||
mesh_label: "Mesh1/Primitive0",
|
||||
material_id: Some("material:material0"),
|
||||
material_slot_name: "Grey",
|
||||
material_label: Some("Material0"),
|
||||
local_transform: (
|
||||
translation: (-0.0027156367, 2.1737509, -0.019581214),
|
||||
rotation: (0.70710677, 0.000000000000031351603, 0.000000000000055267226, -0.7071067),
|
||||
scale: (100.0, 99.99998, 99.99998),
|
||||
),
|
||||
source_node: Some("RootNode/RobotArmature/Bone/Body/Torso"),
|
||||
source_mesh: Some("Mesh1"),
|
||||
source_material: Some("Grey"),
|
||||
skinned: false,
|
||||
),
|
||||
(
|
||||
id: "draw:scene0:node7:mesh1:primitive1",
|
||||
name: "Torso / Primitive 1",
|
||||
mesh_label: "Mesh1/Primitive1",
|
||||
material_id: Some("material:material1"),
|
||||
material_slot_name: "Main",
|
||||
material_label: Some("Material1"),
|
||||
local_transform: (
|
||||
translation: (-0.0027156367, 2.1737509, -0.019581214),
|
||||
rotation: (0.70710677, 0.000000000000031351603, 0.000000000000055267226, -0.7071067),
|
||||
scale: (100.0, 99.99998, 99.99998),
|
||||
),
|
||||
source_node: Some("RootNode/RobotArmature/Bone/Body/Torso"),
|
||||
source_mesh: Some("Mesh1"),
|
||||
source_material: Some("Main"),
|
||||
skinned: false,
|
||||
),
|
||||
(
|
||||
id: "draw:scene0:node61:mesh10:primitive0",
|
||||
name: "Leg.R / Primitive 0",
|
||||
mesh_label: "Mesh10/Primitive0",
|
||||
material_id: Some("material:material1"),
|
||||
material_slot_name: "Main",
|
||||
material_label: Some("Material1"),
|
||||
local_transform: (
|
||||
translation: (-0.6746981, 1.1586236, 0.046261936),
|
||||
rotation: (0.7693698, 0.080835514, 0.110868365, -0.6238942),
|
||||
scale: (99.99999, 99.99886, 100.00024),
|
||||
),
|
||||
source_node: Some("RootNode/RobotArmature/Bone/Body/UpperLeg.R/Leg.R"),
|
||||
source_mesh: Some("Mesh10"),
|
||||
source_material: Some("Main"),
|
||||
skinned: false,
|
||||
),
|
||||
(
|
||||
id: "draw:scene0:node63:mesh11:primitive0",
|
||||
name: "LowerLeg.R / Primitive 0",
|
||||
mesh_label: "Mesh11/Primitive0",
|
||||
material_id: Some("material:material1"),
|
||||
material_slot_name: "Main",
|
||||
material_label: Some("Material1"),
|
||||
local_transform: (
|
||||
translation: (-0.73626167, 0.34982973, 0.22594015),
|
||||
rotation: (-0.65091395, -0.099058665, -0.09436109, 0.7467224),
|
||||
scale: (99.99999, 99.998604, 100.00058),
|
||||
),
|
||||
source_node: Some("RootNode/RobotArmature/Bone/Body/UpperLeg.R/LowerLeg.R/LowerLeg.R"),
|
||||
source_mesh: Some("Mesh11"),
|
||||
source_material: Some("Main"),
|
||||
skinned: false,
|
||||
),
|
||||
(
|
||||
id: "draw:scene0:node72:mesh12:primitive0",
|
||||
name: "Hand.R / Primitive 0",
|
||||
mesh_label: "Mesh12/Primitive0",
|
||||
material_id: Some("material:material1"),
|
||||
material_slot_name: "Main",
|
||||
material_label: Some("Material1"),
|
||||
local_transform: (
|
||||
translation: (-0.002715637, 2.3702974, -0.020994147),
|
||||
rotation: (0.70710677, 0.0, 0.0, -0.7071067),
|
||||
scale: (100.0, 99.99999, 99.99999),
|
||||
),
|
||||
source_node: Some("RootNode/Hand.R"),
|
||||
source_mesh: Some("Mesh12"),
|
||||
source_material: Some("Main"),
|
||||
skinned: true,
|
||||
),
|
||||
(
|
||||
id: "draw:scene0:node72:mesh12:primitive1",
|
||||
name: "Hand.R / Primitive 1",
|
||||
mesh_label: "Mesh12/Primitive1",
|
||||
material_id: Some("material:material0"),
|
||||
material_slot_name: "Grey",
|
||||
material_label: Some("Material0"),
|
||||
local_transform: (
|
||||
translation: (-0.002715637, 2.3702974, -0.020994147),
|
||||
rotation: (0.70710677, 0.0, 0.0, -0.7071067),
|
||||
scale: (100.0, 99.99999, 99.99999),
|
||||
),
|
||||
source_node: Some("RootNode/Hand.R"),
|
||||
source_mesh: Some("Mesh12"),
|
||||
source_material: Some("Grey"),
|
||||
skinned: true,
|
||||
),
|
||||
(
|
||||
id: "draw:scene0:node73:mesh13:primitive0",
|
||||
name: "Hand.L / Primitive 0",
|
||||
mesh_label: "Mesh13/Primitive0",
|
||||
material_id: Some("material:material1"),
|
||||
material_slot_name: "Main",
|
||||
material_label: Some("Material1"),
|
||||
local_transform: (
|
||||
translation: (-0.002715637, 2.3702974, -0.020994147),
|
||||
rotation: (0.70710677, 0.0, 0.0, -0.7071067),
|
||||
scale: (100.0, 99.99999, 99.99999),
|
||||
),
|
||||
source_node: Some("RootNode/Hand.L"),
|
||||
source_mesh: Some("Mesh13"),
|
||||
source_material: Some("Main"),
|
||||
skinned: true,
|
||||
),
|
||||
(
|
||||
id: "draw:scene0:node73:mesh13:primitive1",
|
||||
name: "Hand.L / Primitive 1",
|
||||
mesh_label: "Mesh13/Primitive1",
|
||||
material_id: Some("material:material0"),
|
||||
material_slot_name: "Grey",
|
||||
material_label: Some("Material0"),
|
||||
local_transform: (
|
||||
translation: (-0.002715637, 2.3702974, -0.020994147),
|
||||
rotation: (0.70710677, 0.0, 0.0, -0.7071067),
|
||||
scale: (100.0, 99.99999, 99.99999),
|
||||
),
|
||||
source_node: Some("RootNode/Hand.L"),
|
||||
source_mesh: Some("Mesh13"),
|
||||
source_material: Some("Grey"),
|
||||
skinned: true,
|
||||
),
|
||||
(
|
||||
id: "draw:scene0:node13:mesh2:primitive0",
|
||||
name: "Head / Primitive 0",
|
||||
mesh_label: "Mesh2/Primitive0",
|
||||
material_id: Some("material:material0"),
|
||||
material_slot_name: "Grey",
|
||||
material_label: Some("Material0"),
|
||||
local_transform: (
|
||||
translation: (-0.05334633, 3.6175368, -0.0068152454),
|
||||
rotation: (0.7249889, 0.0852804, 0.05460569, -0.6812757),
|
||||
scale: (99.99997, 99.99996, 99.99997),
|
||||
),
|
||||
source_node: Some("RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Neck/Head/Head"),
|
||||
source_mesh: Some("Mesh2"),
|
||||
source_material: Some("Grey"),
|
||||
skinned: false,
|
||||
),
|
||||
(
|
||||
id: "draw:scene0:node13:mesh2:primitive1",
|
||||
name: "Head / Primitive 1",
|
||||
mesh_label: "Mesh2/Primitive1",
|
||||
material_id: Some("material:material1"),
|
||||
material_slot_name: "Main",
|
||||
material_label: Some("Material1"),
|
||||
local_transform: (
|
||||
translation: (-0.05334633, 3.6175368, -0.0068152454),
|
||||
rotation: (0.7249889, 0.0852804, 0.05460569, -0.6812757),
|
||||
scale: (99.99997, 99.99996, 99.99997),
|
||||
),
|
||||
source_node: Some("RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Neck/Head/Head"),
|
||||
source_mesh: Some("Mesh2"),
|
||||
source_material: Some("Main"),
|
||||
skinned: false,
|
||||
),
|
||||
(
|
||||
id: "draw:scene0:node13:mesh2:primitive2",
|
||||
name: "Head / Primitive 2",
|
||||
mesh_label: "Mesh2/Primitive2",
|
||||
material_id: Some("material:material2"),
|
||||
material_slot_name: "Black",
|
||||
material_label: Some("Material2"),
|
||||
local_transform: (
|
||||
translation: (-0.05334633, 3.6175368, -0.0068152454),
|
||||
rotation: (0.7249889, 0.0852804, 0.05460569, -0.6812757),
|
||||
scale: (99.99997, 99.99996, 99.99997),
|
||||
),
|
||||
source_node: Some("RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Neck/Head/Head"),
|
||||
source_mesh: Some("Mesh2"),
|
||||
source_material: Some("Black"),
|
||||
skinned: false,
|
||||
),
|
||||
(
|
||||
id: "draw:scene0:node68:mesh3:primitive0",
|
||||
name: "Foot.R / Primitive 0",
|
||||
mesh_label: "Mesh3/Primitive0",
|
||||
material_id: Some("material:material0"),
|
||||
material_slot_name: "Grey",
|
||||
material_label: Some("Material0"),
|
||||
local_transform: (
|
||||
translation: (-0.637997, 0.3976636, 0.19111452),
|
||||
rotation: (0.70710677, 0.00000005201811, 0.000000015770217, -0.7071067),
|
||||
scale: (100.0, 99.999985, 100.0),
|
||||
),
|
||||
source_node: Some("RootNode/RobotArmature/Bone/Foot.R/Foot.R"),
|
||||
source_mesh: Some("Mesh3"),
|
||||
source_material: Some("Grey"),
|
||||
skinned: false,
|
||||
),
|
||||
(
|
||||
id: "draw:scene0:node16:mesh4:primitive0",
|
||||
name: "Shoulder.L / Primitive 0",
|
||||
mesh_label: "Mesh4/Primitive0",
|
||||
material_id: Some("material:material1"),
|
||||
material_slot_name: "Main",
|
||||
material_label: Some("Material1"),
|
||||
local_transform: (
|
||||
translation: (0.58268076, 2.3724597, -0.020994142),
|
||||
rotation: (-0.59886926, 0.37597278, -0.37597266, 0.59886944),
|
||||
scale: (99.99996, 99.999954, 99.99997),
|
||||
),
|
||||
source_node: Some("RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/Shoulder.L"),
|
||||
source_mesh: Some("Mesh4"),
|
||||
source_material: Some("Main"),
|
||||
skinned: false,
|
||||
),
|
||||
(
|
||||
id: "draw:scene0:node18:mesh5:primitive0",
|
||||
name: "Arm.L / Primitive 0",
|
||||
mesh_label: "Mesh5/Primitive0",
|
||||
material_id: Some("material:material1"),
|
||||
material_slot_name: "Main",
|
||||
material_label: Some("Material1"),
|
||||
local_transform: (
|
||||
translation: (0.26500192, 2.8489676, 0.13069445),
|
||||
rotation: (-0.5164575, 0.4218856, -0.32294926, 0.67155623),
|
||||
scale: (99.99994, 99.99994, 99.99999),
|
||||
),
|
||||
source_node: Some("RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.L/UpperArm.L/Arm.L"),
|
||||
source_mesh: Some("Mesh5"),
|
||||
source_material: Some("Main"),
|
||||
skinned: false,
|
||||
),
|
||||
(
|
||||
id: "draw:scene0:node36:mesh6:primitive0",
|
||||
name: "Shoulder.R / Primitive 0",
|
||||
mesh_label: "Mesh6/Primitive0",
|
||||
material_id: Some("material:material1"),
|
||||
material_slot_name: "Main",
|
||||
material_label: Some("Material1"),
|
||||
local_transform: (
|
||||
translation: (-0.6369861, 2.3718858, -0.020994166),
|
||||
rotation: (0.6017577, 0.37133226, -0.37133226, -0.6017577),
|
||||
scale: (99.999954, 99.99997, 99.99994),
|
||||
),
|
||||
source_node: Some("RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/Shoulder.R"),
|
||||
source_mesh: Some("Mesh6"),
|
||||
source_material: Some("Main"),
|
||||
skinned: false,
|
||||
),
|
||||
(
|
||||
id: "draw:scene0:node38:mesh7:primitive0",
|
||||
name: "Arm.R / Primitive 0",
|
||||
mesh_label: "Mesh7/Primitive0",
|
||||
material_id: Some("material:material1"),
|
||||
material_slot_name: "Main",
|
||||
material_label: Some("Material1"),
|
||||
local_transform: (
|
||||
translation: (-0.82568955, 1.9825263, 0.040243305),
|
||||
rotation: (0.6276481, 0.35463694, -0.3873087, -0.5747021),
|
||||
scale: (22.70405, 22.704054, 22.704046),
|
||||
),
|
||||
source_node: Some("RootNode/RobotArmature/Bone/Body/Hips/Abdomen/Torso/Shoulder.R/UpperArm.R/Arm.R"),
|
||||
source_mesh: Some("Mesh7"),
|
||||
source_material: Some("Main"),
|
||||
skinned: false,
|
||||
),
|
||||
(
|
||||
id: "draw:scene0:node56:mesh8:primitive0",
|
||||
name: "Leg.L / Primitive 0",
|
||||
mesh_label: "Mesh8/Primitive0",
|
||||
material_id: Some("material:material1"),
|
||||
material_slot_name: "Main",
|
||||
material_label: Some("Material1"),
|
||||
local_transform: (
|
||||
translation: (0.6488191, 1.1620699, 0.057466835),
|
||||
rotation: (0.76387787, -0.05410902, -0.070370995, -0.6392268),
|
||||
scale: (99.99999, 100.00079, 100.00024),
|
||||
),
|
||||
source_node: Some("RootNode/RobotArmature/Bone/Body/UpperLeg.L/Leg.L"),
|
||||
source_mesh: Some("Mesh8"),
|
||||
source_material: Some("Main"),
|
||||
skinned: false,
|
||||
),
|
||||
(
|
||||
id: "draw:scene0:node58:mesh9:primitive0",
|
||||
name: "LowerLeg.L / Primitive 0",
|
||||
mesh_label: "Mesh9/Primitive0",
|
||||
material_id: Some("material:material1"),
|
||||
material_slot_name: "Main",
|
||||
material_label: Some("Material1"),
|
||||
local_transform: (
|
||||
translation: (0.68805873, 0.3497491, 0.23465614),
|
||||
rotation: (-0.6547612, 0.06408298, 0.061960325, 0.7505613),
|
||||
scale: (100.000015, 100.0006, 100.000496),
|
||||
),
|
||||
source_node: Some("RootNode/RobotArmature/Bone/Body/UpperLeg.L/LowerLeg.L/LowerLeg.L"),
|
||||
source_mesh: Some("Mesh9"),
|
||||
source_material: Some("Main"),
|
||||
skinned: false,
|
||||
),
|
||||
],
|
||||
warnings: [
|
||||
"Animations are recorded as metadata; animated placement uses SkinnedMeshRenderer.",
|
||||
"Skinned primitives are excluded from StaticMeshRenderer and use SkinnedMeshRenderer.",
|
||||
],
|
||||
)
|
||||
@ -1,5 +1,5 @@
|
||||
(
|
||||
schema_version: 1,
|
||||
schema_version: 3,
|
||||
asset_id: "b98ef565-3500-49e7-9935-f685fa9b2594",
|
||||
label: "painted_wooden_chair_02_2k",
|
||||
source: (
|
||||
@ -30,7 +30,7 @@
|
||||
),
|
||||
parts: [
|
||||
(
|
||||
id: "mesh:mesh1000",
|
||||
id: "draw:scene0:node1:material0",
|
||||
name: "painted_wooden_chair_02 / Material 0",
|
||||
mesh_label: "Mesh1000",
|
||||
material_id: Some("material:material0"),
|
||||
@ -44,6 +44,7 @@
|
||||
source_node: Some("Node1"),
|
||||
source_mesh: Some("Mesh1"),
|
||||
source_material: Some("painted_wooden_chair_02"),
|
||||
skinned: false,
|
||||
),
|
||||
],
|
||||
warnings: [],
|
||||
|
||||
BIN
assets/models/RobotExpressive.glb
(Stored with Git LFS)
Normal file
BIN
assets/models/RobotExpressive.glb
(Stored with Git LFS)
Normal file
Binary file not shown.
963
assets/navigation/generated/navigation_showcase_humanoid.nav.ron
Normal file
963
assets/navigation/generated/navigation_showcase_humanoid.nav.ron
Normal file
@ -0,0 +1,963 @@
|
||||
(
|
||||
schema_version: 1,
|
||||
generator: "rerecast-0.3/polyanya-0.16",
|
||||
source_scene: "assets/levels/navigation_authoring_showcase.scn.ron",
|
||||
source_fingerprint: "c3f7b1f6eb8cb9ab5d4bb3a9b0d09ce47641c563f47fa75df4e7eb30862d039c",
|
||||
bounds_actor_id: "navigation-bounds-main",
|
||||
center: (0.0, 0.0, 0.0),
|
||||
half_extents: (8.0, 2.0, 8.0),
|
||||
agent: (
|
||||
id: "humanoid",
|
||||
radius: 0.35,
|
||||
height: 1.8,
|
||||
max_climb: 0.45,
|
||||
max_slope_deg: 45.0,
|
||||
cell_size_fraction: 3.0,
|
||||
cell_height_fraction: 6.0,
|
||||
min_region_size: 1,
|
||||
merge_region_size: 2,
|
||||
),
|
||||
mesh: (
|
||||
layers: [
|
||||
(
|
||||
vertices: [
|
||||
(
|
||||
coords: (-1.0, -2.516667),
|
||||
polygons: [
|
||||
0,
|
||||
4294967295,
|
||||
1,
|
||||
3,
|
||||
2,
|
||||
],
|
||||
is_corner: true,
|
||||
),
|
||||
(
|
||||
coords: (-0.3000002, -2.516667),
|
||||
polygons: [
|
||||
4,
|
||||
4294967295,
|
||||
0,
|
||||
],
|
||||
is_corner: true,
|
||||
),
|
||||
(
|
||||
coords: (-0.41666698, -7.3),
|
||||
polygons: [
|
||||
5,
|
||||
4,
|
||||
0,
|
||||
2,
|
||||
4294967295,
|
||||
],
|
||||
is_corner: true,
|
||||
),
|
||||
(
|
||||
coords: (-7.3, -1.5833335),
|
||||
polygons: [
|
||||
3,
|
||||
1,
|
||||
10,
|
||||
12,
|
||||
4294967295,
|
||||
],
|
||||
is_corner: true,
|
||||
),
|
||||
(
|
||||
coords: (-1.0, -1.5833335),
|
||||
polygons: [
|
||||
10,
|
||||
1,
|
||||
4294967295,
|
||||
],
|
||||
is_corner: true,
|
||||
),
|
||||
(
|
||||
coords: (-1.0, -2.516667),
|
||||
polygons: [
|
||||
0,
|
||||
4294967295,
|
||||
1,
|
||||
3,
|
||||
2,
|
||||
],
|
||||
is_corner: true,
|
||||
),
|
||||
(
|
||||
coords: (-1.0, -2.516667),
|
||||
polygons: [
|
||||
0,
|
||||
4294967295,
|
||||
1,
|
||||
3,
|
||||
2,
|
||||
],
|
||||
is_corner: true,
|
||||
),
|
||||
(
|
||||
coords: (-0.41666698, -7.3),
|
||||
polygons: [
|
||||
5,
|
||||
4,
|
||||
0,
|
||||
2,
|
||||
4294967295,
|
||||
],
|
||||
is_corner: true,
|
||||
),
|
||||
(
|
||||
coords: (-7.3, -7.3),
|
||||
polygons: [
|
||||
2,
|
||||
3,
|
||||
4294967295,
|
||||
],
|
||||
is_corner: true,
|
||||
),
|
||||
(
|
||||
coords: (-7.3, -1.5833335),
|
||||
polygons: [
|
||||
3,
|
||||
1,
|
||||
10,
|
||||
12,
|
||||
4294967295,
|
||||
],
|
||||
is_corner: true,
|
||||
),
|
||||
(
|
||||
coords: (-0.3000002, -2.516667),
|
||||
polygons: [
|
||||
4,
|
||||
4294967295,
|
||||
0,
|
||||
],
|
||||
is_corner: true,
|
||||
),
|
||||
(
|
||||
coords: (0.2833333, -2.516667),
|
||||
polygons: [
|
||||
6,
|
||||
4294967295,
|
||||
4,
|
||||
5,
|
||||
],
|
||||
is_corner: true,
|
||||
),
|
||||
(
|
||||
coords: (0.39999962, -7.3),
|
||||
polygons: [
|
||||
8,
|
||||
6,
|
||||
5,
|
||||
4294967295,
|
||||
],
|
||||
is_corner: true,
|
||||
),
|
||||
(
|
||||
coords: (-0.41666698, -7.3),
|
||||
polygons: [
|
||||
5,
|
||||
4,
|
||||
0,
|
||||
2,
|
||||
4294967295,
|
||||
],
|
||||
is_corner: true,
|
||||
),
|
||||
(
|
||||
coords: (0.39999962, -7.3),
|
||||
polygons: [
|
||||
8,
|
||||
6,
|
||||
5,
|
||||
4294967295,
|
||||
],
|
||||
is_corner: true,
|
||||
),
|
||||
(
|
||||
coords: (0.2833333, -2.516667),
|
||||
polygons: [
|
||||
6,
|
||||
4294967295,
|
||||
4,
|
||||
5,
|
||||
],
|
||||
is_corner: true,
|
||||
),
|
||||
(
|
||||
coords: (0.9833336, -2.516667),
|
||||
polygons: [
|
||||
8,
|
||||
9,
|
||||
7,
|
||||
4294967295,
|
||||
6,
|
||||
],
|
||||
is_corner: true,
|
||||
),
|
||||
(
|
||||
coords: (0.9833336, -2.516667),
|
||||
polygons: [
|
||||
8,
|
||||
9,
|
||||
7,
|
||||
4294967295,
|
||||
6,
|
||||
],
|
||||
is_corner: true,
|
||||
),
|
||||
(
|
||||
coords: (0.9833336, -1.5833335),
|
||||
polygons: [
|
||||
7,
|
||||
13,
|
||||
4294967295,
|
||||
],
|
||||
is_corner: true,
|
||||
),
|
||||
(
|
||||
coords: (7.283333, -1.5833335),
|
||||
polygons: [
|
||||
15,
|
||||
13,
|
||||
7,
|
||||
9,
|
||||
4294967295,
|
||||
],
|
||||
is_corner: true,
|
||||
),
|
||||
(
|
||||
coords: (0.9833336, -2.516667),
|
||||
polygons: [
|
||||
8,
|
||||
9,
|
||||
7,
|
||||
4294967295,
|
||||
6,
|
||||
],
|
||||
is_corner: true,
|
||||
),
|
||||
(
|
||||
coords: (7.283333, -1.5833335),
|
||||
polygons: [
|
||||
15,
|
||||
13,
|
||||
7,
|
||||
9,
|
||||
4294967295,
|
||||
],
|
||||
is_corner: true,
|
||||
),
|
||||
(
|
||||
coords: (7.283333, -7.3),
|
||||
polygons: [
|
||||
9,
|
||||
8,
|
||||
4294967295,
|
||||
],
|
||||
is_corner: true,
|
||||
),
|
||||
(
|
||||
coords: (0.39999962, -7.3),
|
||||
polygons: [
|
||||
8,
|
||||
6,
|
||||
5,
|
||||
4294967295,
|
||||
],
|
||||
is_corner: true,
|
||||
),
|
||||
(
|
||||
coords: (-1.0, -0.0666666),
|
||||
polygons: [
|
||||
11,
|
||||
12,
|
||||
10,
|
||||
4294967295,
|
||||
],
|
||||
is_corner: true,
|
||||
),
|
||||
(
|
||||
coords: (-1.0, -1.5833335),
|
||||
polygons: [
|
||||
10,
|
||||
1,
|
||||
4294967295,
|
||||
],
|
||||
is_corner: true,
|
||||
),
|
||||
(
|
||||
coords: (-7.3, -1.5833335),
|
||||
polygons: [
|
||||
3,
|
||||
1,
|
||||
10,
|
||||
12,
|
||||
4294967295,
|
||||
],
|
||||
is_corner: true,
|
||||
),
|
||||
(
|
||||
coords: (-7.3, 1.5666666),
|
||||
polygons: [
|
||||
12,
|
||||
11,
|
||||
17,
|
||||
19,
|
||||
4294967295,
|
||||
],
|
||||
is_corner: true,
|
||||
),
|
||||
(
|
||||
coords: (-1.0, 1.5666666),
|
||||
polygons: [
|
||||
17,
|
||||
11,
|
||||
4294967295,
|
||||
],
|
||||
is_corner: true,
|
||||
),
|
||||
(
|
||||
coords: (-1.0, -0.0666666),
|
||||
polygons: [
|
||||
11,
|
||||
12,
|
||||
10,
|
||||
4294967295,
|
||||
],
|
||||
is_corner: true,
|
||||
),
|
||||
(
|
||||
coords: (-7.3, -1.5833335),
|
||||
polygons: [
|
||||
3,
|
||||
1,
|
||||
10,
|
||||
12,
|
||||
4294967295,
|
||||
],
|
||||
is_corner: true,
|
||||
),
|
||||
(
|
||||
coords: (7.283333, -1.5833335),
|
||||
polygons: [
|
||||
15,
|
||||
13,
|
||||
7,
|
||||
9,
|
||||
4294967295,
|
||||
],
|
||||
is_corner: true,
|
||||
),
|
||||
(
|
||||
coords: (0.9833336, -1.5833335),
|
||||
polygons: [
|
||||
7,
|
||||
13,
|
||||
4294967295,
|
||||
],
|
||||
is_corner: true,
|
||||
),
|
||||
(
|
||||
coords: (0.9833336, -0.0666666),
|
||||
polygons: [
|
||||
13,
|
||||
15,
|
||||
14,
|
||||
4294967295,
|
||||
],
|
||||
is_corner: true,
|
||||
),
|
||||
(
|
||||
coords: (7.283333, 1.5666666),
|
||||
polygons: [
|
||||
23,
|
||||
21,
|
||||
14,
|
||||
15,
|
||||
4294967295,
|
||||
],
|
||||
is_corner: true,
|
||||
),
|
||||
(
|
||||
coords: (7.283333, -1.5833335),
|
||||
polygons: [
|
||||
15,
|
||||
13,
|
||||
7,
|
||||
9,
|
||||
4294967295,
|
||||
],
|
||||
is_corner: true,
|
||||
),
|
||||
(
|
||||
coords: (0.9833336, -0.0666666),
|
||||
polygons: [
|
||||
13,
|
||||
15,
|
||||
14,
|
||||
4294967295,
|
||||
],
|
||||
is_corner: true,
|
||||
),
|
||||
(
|
||||
coords: (0.9833336, 1.5666666),
|
||||
polygons: [
|
||||
14,
|
||||
21,
|
||||
4294967295,
|
||||
],
|
||||
is_corner: true,
|
||||
),
|
||||
(
|
||||
coords: (-0.41666698, 7.283333),
|
||||
polygons: [
|
||||
24,
|
||||
25,
|
||||
4294967295,
|
||||
18,
|
||||
16,
|
||||
],
|
||||
is_corner: true,
|
||||
),
|
||||
(
|
||||
coords: (-0.3000002, 2.5),
|
||||
polygons: [
|
||||
24,
|
||||
16,
|
||||
4294967295,
|
||||
],
|
||||
is_corner: true,
|
||||
),
|
||||
(
|
||||
coords: (-1.0, 2.5),
|
||||
polygons: [
|
||||
16,
|
||||
18,
|
||||
19,
|
||||
17,
|
||||
4294967295,
|
||||
],
|
||||
is_corner: true,
|
||||
),
|
||||
(
|
||||
coords: (-1.0, 2.5),
|
||||
polygons: [
|
||||
16,
|
||||
18,
|
||||
19,
|
||||
17,
|
||||
4294967295,
|
||||
],
|
||||
is_corner: true,
|
||||
),
|
||||
(
|
||||
coords: (-1.0, 1.5666666),
|
||||
polygons: [
|
||||
17,
|
||||
11,
|
||||
4294967295,
|
||||
],
|
||||
is_corner: true,
|
||||
),
|
||||
(
|
||||
coords: (-7.3, 1.5666666),
|
||||
polygons: [
|
||||
12,
|
||||
11,
|
||||
17,
|
||||
19,
|
||||
4294967295,
|
||||
],
|
||||
is_corner: true,
|
||||
),
|
||||
(
|
||||
coords: (-7.3, 7.283333),
|
||||
polygons: [
|
||||
19,
|
||||
18,
|
||||
4294967295,
|
||||
],
|
||||
is_corner: true,
|
||||
),
|
||||
(
|
||||
coords: (-0.41666698, 7.283333),
|
||||
polygons: [
|
||||
24,
|
||||
25,
|
||||
4294967295,
|
||||
18,
|
||||
16,
|
||||
],
|
||||
is_corner: true,
|
||||
),
|
||||
(
|
||||
coords: (-1.0, 2.5),
|
||||
polygons: [
|
||||
16,
|
||||
18,
|
||||
19,
|
||||
17,
|
||||
4294967295,
|
||||
],
|
||||
is_corner: true,
|
||||
),
|
||||
(
|
||||
coords: (-7.3, 1.5666666),
|
||||
polygons: [
|
||||
12,
|
||||
11,
|
||||
17,
|
||||
19,
|
||||
4294967295,
|
||||
],
|
||||
is_corner: true,
|
||||
),
|
||||
(
|
||||
coords: (0.9833336, 2.5),
|
||||
polygons: [
|
||||
21,
|
||||
23,
|
||||
22,
|
||||
20,
|
||||
4294967295,
|
||||
],
|
||||
is_corner: true,
|
||||
),
|
||||
(
|
||||
coords: (0.2833333, 2.5),
|
||||
polygons: [
|
||||
20,
|
||||
25,
|
||||
24,
|
||||
4294967295,
|
||||
],
|
||||
is_corner: true,
|
||||
),
|
||||
(
|
||||
coords: (0.39999962, 7.283333),
|
||||
polygons: [
|
||||
20,
|
||||
22,
|
||||
4294967295,
|
||||
25,
|
||||
],
|
||||
is_corner: true,
|
||||
),
|
||||
(
|
||||
coords: (7.283333, 1.5666666),
|
||||
polygons: [
|
||||
23,
|
||||
21,
|
||||
14,
|
||||
15,
|
||||
4294967295,
|
||||
],
|
||||
is_corner: true,
|
||||
),
|
||||
(
|
||||
coords: (0.9833336, 1.5666666),
|
||||
polygons: [
|
||||
14,
|
||||
21,
|
||||
4294967295,
|
||||
],
|
||||
is_corner: true,
|
||||
),
|
||||
(
|
||||
coords: (0.9833336, 2.5),
|
||||
polygons: [
|
||||
21,
|
||||
23,
|
||||
22,
|
||||
20,
|
||||
4294967295,
|
||||
],
|
||||
is_corner: true,
|
||||
),
|
||||
(
|
||||
coords: (0.9833336, 2.5),
|
||||
polygons: [
|
||||
21,
|
||||
23,
|
||||
22,
|
||||
20,
|
||||
4294967295,
|
||||
],
|
||||
is_corner: true,
|
||||
),
|
||||
(
|
||||
coords: (0.39999962, 7.283333),
|
||||
polygons: [
|
||||
20,
|
||||
22,
|
||||
4294967295,
|
||||
25,
|
||||
],
|
||||
is_corner: true,
|
||||
),
|
||||
(
|
||||
coords: (7.283333, 7.283333),
|
||||
polygons: [
|
||||
22,
|
||||
23,
|
||||
4294967295,
|
||||
],
|
||||
is_corner: true,
|
||||
),
|
||||
(
|
||||
coords: (7.283333, 1.5666666),
|
||||
polygons: [
|
||||
23,
|
||||
21,
|
||||
14,
|
||||
15,
|
||||
4294967295,
|
||||
],
|
||||
is_corner: true,
|
||||
),
|
||||
(
|
||||
coords: (-0.41666698, 7.283333),
|
||||
polygons: [
|
||||
24,
|
||||
25,
|
||||
4294967295,
|
||||
18,
|
||||
16,
|
||||
],
|
||||
is_corner: true,
|
||||
),
|
||||
(
|
||||
coords: (0.39999962, 7.283333),
|
||||
polygons: [
|
||||
20,
|
||||
22,
|
||||
4294967295,
|
||||
25,
|
||||
],
|
||||
is_corner: true,
|
||||
),
|
||||
(
|
||||
coords: (0.2833333, 2.5),
|
||||
polygons: [
|
||||
20,
|
||||
25,
|
||||
24,
|
||||
4294967295,
|
||||
],
|
||||
is_corner: true,
|
||||
),
|
||||
(
|
||||
coords: (-0.3000002, 2.5),
|
||||
polygons: [
|
||||
24,
|
||||
16,
|
||||
4294967295,
|
||||
],
|
||||
is_corner: true,
|
||||
),
|
||||
],
|
||||
polygons: [
|
||||
(
|
||||
vertices: [
|
||||
2,
|
||||
1,
|
||||
0,
|
||||
],
|
||||
is_one_way: false,
|
||||
),
|
||||
(
|
||||
vertices: [
|
||||
4,
|
||||
3,
|
||||
5,
|
||||
],
|
||||
is_one_way: false,
|
||||
),
|
||||
(
|
||||
vertices: [
|
||||
6,
|
||||
8,
|
||||
7,
|
||||
],
|
||||
is_one_way: false,
|
||||
),
|
||||
(
|
||||
vertices: [
|
||||
6,
|
||||
9,
|
||||
8,
|
||||
],
|
||||
is_one_way: false,
|
||||
),
|
||||
(
|
||||
vertices: [
|
||||
13,
|
||||
11,
|
||||
10,
|
||||
],
|
||||
is_one_way: false,
|
||||
),
|
||||
(
|
||||
vertices: [
|
||||
13,
|
||||
12,
|
||||
11,
|
||||
],
|
||||
is_one_way: false,
|
||||
),
|
||||
(
|
||||
vertices: [
|
||||
15,
|
||||
14,
|
||||
16,
|
||||
],
|
||||
is_one_way: false,
|
||||
),
|
||||
(
|
||||
vertices: [
|
||||
18,
|
||||
17,
|
||||
19,
|
||||
],
|
||||
is_one_way: false,
|
||||
),
|
||||
(
|
||||
vertices: [
|
||||
22,
|
||||
20,
|
||||
23,
|
||||
],
|
||||
is_one_way: false,
|
||||
),
|
||||
(
|
||||
vertices: [
|
||||
22,
|
||||
21,
|
||||
20,
|
||||
],
|
||||
is_one_way: false,
|
||||
),
|
||||
(
|
||||
vertices: [
|
||||
25,
|
||||
24,
|
||||
26,
|
||||
],
|
||||
is_one_way: false,
|
||||
),
|
||||
(
|
||||
vertices: [
|
||||
27,
|
||||
29,
|
||||
28,
|
||||
],
|
||||
is_one_way: false,
|
||||
),
|
||||
(
|
||||
vertices: [
|
||||
27,
|
||||
30,
|
||||
29,
|
||||
],
|
||||
is_one_way: false,
|
||||
),
|
||||
(
|
||||
vertices: [
|
||||
32,
|
||||
31,
|
||||
33,
|
||||
],
|
||||
is_one_way: false,
|
||||
),
|
||||
(
|
||||
vertices: [
|
||||
36,
|
||||
34,
|
||||
37,
|
||||
],
|
||||
is_one_way: false,
|
||||
),
|
||||
(
|
||||
vertices: [
|
||||
36,
|
||||
35,
|
||||
34,
|
||||
],
|
||||
is_one_way: false,
|
||||
),
|
||||
(
|
||||
vertices: [
|
||||
40,
|
||||
39,
|
||||
38,
|
||||
],
|
||||
is_one_way: false,
|
||||
),
|
||||
(
|
||||
vertices: [
|
||||
42,
|
||||
41,
|
||||
43,
|
||||
],
|
||||
is_one_way: false,
|
||||
),
|
||||
(
|
||||
vertices: [
|
||||
44,
|
||||
46,
|
||||
45,
|
||||
],
|
||||
is_one_way: false,
|
||||
),
|
||||
(
|
||||
vertices: [
|
||||
44,
|
||||
47,
|
||||
46,
|
||||
],
|
||||
is_one_way: false,
|
||||
),
|
||||
(
|
||||
vertices: [
|
||||
49,
|
||||
48,
|
||||
50,
|
||||
],
|
||||
is_one_way: false,
|
||||
),
|
||||
(
|
||||
vertices: [
|
||||
52,
|
||||
51,
|
||||
53,
|
||||
],
|
||||
is_one_way: false,
|
||||
),
|
||||
(
|
||||
vertices: [
|
||||
54,
|
||||
56,
|
||||
55,
|
||||
],
|
||||
is_one_way: false,
|
||||
),
|
||||
(
|
||||
vertices: [
|
||||
54,
|
||||
57,
|
||||
56,
|
||||
],
|
||||
is_one_way: false,
|
||||
),
|
||||
(
|
||||
vertices: [
|
||||
60,
|
||||
58,
|
||||
61,
|
||||
],
|
||||
is_one_way: false,
|
||||
),
|
||||
(
|
||||
vertices: [
|
||||
60,
|
||||
59,
|
||||
58,
|
||||
],
|
||||
is_one_way: false,
|
||||
),
|
||||
],
|
||||
offset: (0.0, 0.0),
|
||||
baked_polygons: None,
|
||||
islands: None,
|
||||
height: [
|
||||
0.10000008,
|
||||
0.10000008,
|
||||
0.10000008,
|
||||
0.10000008,
|
||||
0.10000008,
|
||||
0.10000008,
|
||||
0.10000008,
|
||||
0.10000008,
|
||||
0.10000008,
|
||||
0.10000008,
|
||||
0.10000008,
|
||||
0.10000008,
|
||||
0.10000008,
|
||||
0.10000008,
|
||||
0.10000008,
|
||||
0.10000008,
|
||||
0.10000008,
|
||||
0.10000008,
|
||||
0.10000008,
|
||||
0.10000008,
|
||||
0.10000008,
|
||||
0.10000008,
|
||||
0.10000008,
|
||||
0.10000008,
|
||||
0.10000008,
|
||||
0.10000008,
|
||||
0.10000008,
|
||||
0.10000008,
|
||||
0.10000008,
|
||||
0.10000008,
|
||||
0.10000008,
|
||||
0.10000008,
|
||||
0.10000008,
|
||||
0.10000008,
|
||||
0.10000008,
|
||||
0.10000008,
|
||||
0.10000008,
|
||||
0.10000008,
|
||||
0.10000008,
|
||||
0.10000008,
|
||||
0.10000008,
|
||||
0.10000008,
|
||||
0.10000008,
|
||||
0.10000008,
|
||||
0.10000008,
|
||||
0.10000008,
|
||||
0.10000008,
|
||||
0.10000008,
|
||||
0.10000008,
|
||||
0.10000008,
|
||||
0.10000008,
|
||||
0.10000008,
|
||||
0.10000008,
|
||||
0.10000008,
|
||||
0.10000008,
|
||||
0.10000008,
|
||||
0.10000008,
|
||||
0.10000008,
|
||||
0.10000008,
|
||||
0.10000008,
|
||||
0.10000008,
|
||||
0.10000008,
|
||||
],
|
||||
),
|
||||
],
|
||||
search_delta: 0.1,
|
||||
search_steps: 2,
|
||||
),
|
||||
links: [
|
||||
(
|
||||
actor_id: "navigation-link-center",
|
||||
start: (-1.5, 0.0, 0.0),
|
||||
end: (1.5, 0.0, 0.0),
|
||||
bidirectional: true,
|
||||
cost: 1.2,
|
||||
enabled: true,
|
||||
),
|
||||
],
|
||||
areas: [
|
||||
(
|
||||
actor_id: "navigation-area-slow",
|
||||
id: "slow",
|
||||
center: (-4.0, 0.5, -4.0),
|
||||
half_extents: (1.5, 1.0, 1.5),
|
||||
cost: 1.5,
|
||||
walkable: true,
|
||||
),
|
||||
],
|
||||
)
|
||||
@ -1,8 +1,7 @@
|
||||
(schema_version: 2,
|
||||
resources: {},
|
||||
(schema_version: 4,resources: {
|
||||
},
|
||||
entities: {
|
||||
1: (
|
||||
components: {
|
||||
1: (components: {
|
||||
"bevy_ecs::name::Name": "Example Base Mesh",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (0.0, 0.0, 0.0),
|
||||
@ -13,7 +12,6 @@
|
||||
"shared::components::ActorKind": Empty,
|
||||
"shared::components::EditorVisibility": (visible: true),
|
||||
"shared::components::LevelObject": (),
|
||||
},
|
||||
),
|
||||
}),
|
||||
},
|
||||
)
|
||||
@ -1,8 +1,7 @@
|
||||
(schema_version: 2,
|
||||
resources: {},
|
||||
(schema_version: 4,resources: {
|
||||
},
|
||||
entities: {
|
||||
1: (
|
||||
components: {
|
||||
1: (components: {
|
||||
"bevy_ecs::name::Name": "Example Base Link",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (0.0, 0.0, 0.0),
|
||||
@ -17,7 +16,6 @@
|
||||
source_path: "assets/prefabs/example_base.scn.ron",
|
||||
overrides_ron: None,
|
||||
),
|
||||
},
|
||||
),
|
||||
}),
|
||||
},
|
||||
)
|
||||
@ -1,8 +1,7 @@
|
||||
(schema_version: 2,
|
||||
resources: {},
|
||||
(schema_version: 4,resources: {
|
||||
},
|
||||
entities: {
|
||||
1: (
|
||||
components: {
|
||||
1: (components: {
|
||||
"bevy_ecs::name::Name": "Example Nested Variant Link",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (0.0, 0.0, 0.0),
|
||||
@ -17,7 +16,6 @@
|
||||
source_path: "assets/prefabs/example_nested.scn.ron",
|
||||
overrides_ron: Some("(format_version:2,source_revision:None,transform:None,material:None,child_visibility:{},properties:[],components:[(target:(instance_chain:[\"base-link\"],actor_id:\"base-mesh\"),component_type:\"shared::components::EditorVisibility\",base_component_ron:Some(\"(visible:true)\"),value_component_ron:Some(\"(visible:false)\"))],structural:[])"),
|
||||
),
|
||||
},
|
||||
),
|
||||
}),
|
||||
},
|
||||
)
|
||||
42
assets/shaders/blacksite_surface.wgsl
Normal file
42
assets/shaders/blacksite_surface.wgsl
Normal file
@ -0,0 +1,42 @@
|
||||
#import bevy_pbr::{
|
||||
pbr_fragment::pbr_input_from_standard_material,
|
||||
pbr_functions::alpha_discard,
|
||||
}
|
||||
|
||||
#ifdef PREPASS_PIPELINE
|
||||
#import bevy_pbr::{
|
||||
prepass_io::{VertexOutput, FragmentOutput},
|
||||
pbr_deferred_functions::deferred_output,
|
||||
}
|
||||
#else
|
||||
#import bevy_pbr::{
|
||||
forward_io::{VertexOutput, FragmentOutput},
|
||||
pbr_functions::{apply_pbr_lighting, main_pass_post_lighting_processing},
|
||||
}
|
||||
#endif
|
||||
|
||||
struct SurfaceUniform {
|
||||
shader_id: u32,
|
||||
flags: u32,
|
||||
alpha_cutoff: f32,
|
||||
abi_version: u32,
|
||||
params: array<vec4<f32>, 16>,
|
||||
uv_transforms: array<vec4<f32>, 8>,
|
||||
}
|
||||
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(100) var<uniform> surface_uniform: SurfaceUniform;
|
||||
|
||||
@fragment
|
||||
fn fragment(in: VertexOutput, @builtin(front_facing) is_front: bool) -> FragmentOutput {
|
||||
var pbr_input = pbr_input_from_standard_material(in, is_front);
|
||||
pbr_input.material.base_color = alpha_discard(pbr_input.material, pbr_input.material.base_color);
|
||||
#ifdef PREPASS_PIPELINE
|
||||
return deferred_output(in, pbr_input);
|
||||
#else
|
||||
var out: FragmentOutput;
|
||||
out.color = apply_pbr_lighting(pbr_input);
|
||||
out.color = main_pass_post_lighting_processing(pbr_input, out.color);
|
||||
return out;
|
||||
#endif
|
||||
}
|
||||
|
||||
@ -1,17 +1,72 @@
|
||||
(
|
||||
schema_version: 1,
|
||||
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),
|
||||
(
|
||||
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: [],
|
||||
|
||||
80
assets/shaders/surface_tint.shader.ron
Normal file
80
assets/shaders/surface_tint.shader.ron
Normal file
@ -0,0 +1,80 @@
|
||||
(
|
||||
schema_version: 1,
|
||||
label: "Surface Tint",
|
||||
kind: Custom,
|
||||
wgsl_path: Some("assets/shaders/surface_tint.wgsl"),
|
||||
parameters: [
|
||||
(
|
||||
name: "tint",
|
||||
display_name: "Tint",
|
||||
group: "Surface",
|
||||
property_type: Color,
|
||||
),
|
||||
(
|
||||
name: "roughness",
|
||||
display_name: "Roughness",
|
||||
group: "Surface",
|
||||
property_type: Float(
|
||||
min: Some(0.001),
|
||||
max: Some(1.0),
|
||||
),
|
||||
),
|
||||
(
|
||||
name: "metallic",
|
||||
display_name: "Metallic",
|
||||
group: "Surface",
|
||||
property_type: Float(
|
||||
min: Some(0.0),
|
||||
max: Some(1.0),
|
||||
),
|
||||
),
|
||||
(
|
||||
name: "emissive",
|
||||
display_name: "Emissive",
|
||||
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: [
|
||||
(
|
||||
name: "tint",
|
||||
value: Color((
|
||||
r: 0.12,
|
||||
g: 0.42,
|
||||
b: 0.95,
|
||||
a: 1.0,
|
||||
)),
|
||||
),
|
||||
(
|
||||
name: "roughness",
|
||||
value: Float(0.28),
|
||||
),
|
||||
(
|
||||
name: "metallic",
|
||||
value: Float(0.15),
|
||||
),
|
||||
(
|
||||
name: "emissive",
|
||||
value: Color((
|
||||
r: 0.02,
|
||||
g: 0.08,
|
||||
b: 0.25,
|
||||
a: 1.0,
|
||||
)),
|
||||
),
|
||||
(
|
||||
name: "emissive_intensity",
|
||||
value: Float(3.0),
|
||||
),
|
||||
],
|
||||
default_textures: [],
|
||||
)
|
||||
14
assets/shaders/surface_tint.wgsl
Normal file
14
assets/shaders/surface_tint.wgsl
Normal file
@ -0,0 +1,14 @@
|
||||
fn evaluate(
|
||||
input: SurfaceInput,
|
||||
params: SurfaceParams,
|
||||
samples: SurfaceSamples,
|
||||
) -> Surface {
|
||||
var surface = surface_default();
|
||||
surface.base_color = params.lanes[0];
|
||||
surface.perceptual_roughness = params.lanes[1].x;
|
||||
surface.metallic = params.lanes[2].x;
|
||||
surface.emissive = params.lanes[3].rgb * params.lanes[4].x;
|
||||
surface.reflectance = 0.5;
|
||||
surface.occlusion = 1.0;
|
||||
return surface;
|
||||
}
|
||||
@ -1,12 +1,36 @@
|
||||
(
|
||||
schema_version: 1,
|
||||
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))),
|
||||
(
|
||||
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: [],
|
||||
|
||||
13
crates/blacksite_surface/Cargo.toml
Normal file
13
crates/blacksite_surface/Cargo.toml
Normal file
@ -0,0 +1,13 @@
|
||||
[package]
|
||||
name = "blacksite_surface"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "Blacksite raster and ray-tracing Surface ABI material runtime"
|
||||
|
||||
[dependencies]
|
||||
bevy.workspace = true
|
||||
naga = { version = "29", features = ["wgsl-in"] }
|
||||
ron = "0.8"
|
||||
shared.workspace = true
|
||||
uuid = "1"
|
||||
777
crates/blacksite_surface/src/lib.rs
Normal file
777
crates/blacksite_surface/src/lib.rs
Normal file
@ -0,0 +1,777 @@
|
||||
//! Blacksite Surface ABI v1.
|
||||
//!
|
||||
//! The authoring layer persists named values. This crate validates and packs them into a fixed GPU
|
||||
//! contract and supplies the same surface evaluator source to raster and Solari integrations.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
|
||||
use bevy::mesh::MeshVertexBufferLayoutRef;
|
||||
use bevy::pbr::{
|
||||
ExtendedMaterial, MaterialExtension, MaterialExtensionKey, MaterialExtensionPipeline,
|
||||
};
|
||||
use bevy::prelude::*;
|
||||
use bevy::render::extract_resource::ExtractResource;
|
||||
use bevy::render::render_resource::{
|
||||
AsBindGroup, RenderPipelineDescriptor, ShaderType, SpecializedMeshPipelineError,
|
||||
};
|
||||
use bevy::shader::{Shader, ShaderRef};
|
||||
use shared::{
|
||||
asset_server_path, material_from_desc, HydratedRendererMaterialBinding, MaterialAsset,
|
||||
MaterialInstanceAsset, MaterialParameterValue, MaterialRef, ShaderPropertyType,
|
||||
ShaderSchemaAsset, MATERIAL_INSTANCE_SCHEMA_VERSION,
|
||||
};
|
||||
|
||||
pub const SURFACE_ABI_VERSION: u32 = 1;
|
||||
pub const SURFACE_PARAMETER_LANES: usize = 16;
|
||||
pub const SURFACE_TEXTURE_SLOTS: usize = 8;
|
||||
pub const DEFAULT_SURFACE_SHADER_PATH: &str = "shaders/blacksite_surface.wgsl";
|
||||
|
||||
#[derive(ShaderType, Reflect, Debug, Clone, Copy, PartialEq)]
|
||||
pub struct SurfaceUniform {
|
||||
pub shader_id: u32,
|
||||
pub flags: u32,
|
||||
pub alpha_cutoff: f32,
|
||||
pub abi_version: u32,
|
||||
pub params: [Vec4; SURFACE_PARAMETER_LANES],
|
||||
pub uv_transforms: [Vec4; SURFACE_TEXTURE_SLOTS],
|
||||
}
|
||||
|
||||
impl Default for SurfaceUniform {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
shader_id: 0,
|
||||
flags: 0,
|
||||
alpha_cutoff: 0.5,
|
||||
abi_version: SURFACE_ABI_VERSION,
|
||||
params: [Vec4::ZERO; SURFACE_PARAMETER_LANES],
|
||||
uv_transforms: [Vec4::new(1.0, 1.0, 0.0, 0.0); SURFACE_TEXTURE_SLOTS],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub struct SurfaceExtensionKey {
|
||||
shader: AssetId<Shader>,
|
||||
}
|
||||
|
||||
impl From<&SurfaceExtension> for SurfaceExtensionKey {
|
||||
fn from(extension: &SurfaceExtension) -> Self {
|
||||
Self {
|
||||
shader: extension.shader.id(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Asset, AsBindGroup, Reflect, Debug, Clone)]
|
||||
#[bind_group_data(SurfaceExtensionKey)]
|
||||
pub struct SurfaceExtension {
|
||||
#[uniform(100)]
|
||||
pub uniform: SurfaceUniform,
|
||||
#[texture(101)]
|
||||
#[sampler(102)]
|
||||
pub texture0: Option<Handle<Image>>,
|
||||
#[texture(103)]
|
||||
#[sampler(104)]
|
||||
pub texture1: Option<Handle<Image>>,
|
||||
#[texture(105)]
|
||||
#[sampler(106)]
|
||||
pub texture2: Option<Handle<Image>>,
|
||||
#[texture(107)]
|
||||
#[sampler(108)]
|
||||
pub texture3: Option<Handle<Image>>,
|
||||
#[texture(109)]
|
||||
#[sampler(110)]
|
||||
pub texture4: Option<Handle<Image>>,
|
||||
#[texture(111)]
|
||||
#[sampler(112)]
|
||||
pub texture5: Option<Handle<Image>>,
|
||||
#[texture(113)]
|
||||
#[sampler(114)]
|
||||
pub texture6: Option<Handle<Image>>,
|
||||
#[texture(115)]
|
||||
#[sampler(116)]
|
||||
pub texture7: Option<Handle<Image>>,
|
||||
#[reflect(ignore)]
|
||||
pub shader: Handle<Shader>,
|
||||
}
|
||||
|
||||
impl Default for SurfaceExtension {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
uniform: SurfaceUniform::default(),
|
||||
texture0: None,
|
||||
texture1: None,
|
||||
texture2: None,
|
||||
texture3: None,
|
||||
texture4: None,
|
||||
texture5: None,
|
||||
texture6: None,
|
||||
texture7: None,
|
||||
shader: Handle::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl SurfaceExtension {
|
||||
pub fn set_texture(&mut self, index: usize, handle: Option<Handle<Image>>) {
|
||||
match index {
|
||||
0 => self.texture0 = handle,
|
||||
1 => self.texture1 = handle,
|
||||
2 => self.texture2 = handle,
|
||||
3 => self.texture3 = handle,
|
||||
4 => self.texture4 = handle,
|
||||
5 => self.texture5 = handle,
|
||||
6 => self.texture6 = handle,
|
||||
7 => self.texture7 = handle,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl MaterialExtension for SurfaceExtension {
|
||||
fn fragment_shader() -> ShaderRef {
|
||||
DEFAULT_SURFACE_SHADER_PATH.into()
|
||||
}
|
||||
|
||||
fn deferred_fragment_shader() -> ShaderRef {
|
||||
DEFAULT_SURFACE_SHADER_PATH.into()
|
||||
}
|
||||
|
||||
fn prepass_fragment_shader() -> ShaderRef {
|
||||
DEFAULT_SURFACE_SHADER_PATH.into()
|
||||
}
|
||||
|
||||
fn specialize(
|
||||
_pipeline: &MaterialExtensionPipeline,
|
||||
descriptor: &mut RenderPipelineDescriptor,
|
||||
_layout: &MeshVertexBufferLayoutRef,
|
||||
key: MaterialExtensionKey<Self>,
|
||||
) -> Result<(), SpecializedMeshPipelineError> {
|
||||
if let (AssetId::Uuid { uuid }, Some(fragment)) =
|
||||
(key.bind_group_data.shader, descriptor.fragment.as_mut())
|
||||
{
|
||||
fragment.shader = Handle::Uuid(uuid, Default::default());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub type SurfaceMaterial = ExtendedMaterial<StandardMaterial, SurfaceExtension>;
|
||||
|
||||
#[derive(Resource, Default)]
|
||||
pub struct SurfaceMaterialCache {
|
||||
handles: HashMap<MaterialRef, Handle<SurfaceMaterial>>,
|
||||
standard_handles: HashMap<MaterialRef, Handle<StandardMaterial>>,
|
||||
revisions: HashMap<MaterialRef, u64>,
|
||||
standard_only: HashMap<MaterialRef, u64>,
|
||||
dependencies: HashMap<MaterialRef, Vec<String>>,
|
||||
failed_revisions: HashMap<MaterialRef, u64>,
|
||||
}
|
||||
|
||||
enum BuiltRendererMaterial {
|
||||
Standard(StandardMaterial),
|
||||
Surface(SurfaceMaterial),
|
||||
}
|
||||
|
||||
#[derive(Resource, Default, Debug, Clone)]
|
||||
pub struct SurfaceDiagnostics(pub Vec<String>);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SurfaceEvaluatorRecord {
|
||||
pub shader_id: u32,
|
||||
pub evaluator_source: String,
|
||||
pub revision: u64,
|
||||
pub alpha_mode: shared::MaterialAlphaMode,
|
||||
}
|
||||
|
||||
/// Active, validated evaluator generation consumed by both raster and Solari render worlds.
|
||||
#[derive(Resource, ExtractResource, Default, Debug, Clone)]
|
||||
pub struct SurfaceEvaluatorRegistry {
|
||||
pub evaluators: HashMap<u32, SurfaceEvaluatorRecord>,
|
||||
}
|
||||
|
||||
pub struct SurfaceMaterialPlugin;
|
||||
|
||||
impl Plugin for SurfaceMaterialPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_plugins(MaterialPlugin::<SurfaceMaterial>::default())
|
||||
.init_resource::<SurfaceMaterialCache>()
|
||||
.init_resource::<SurfaceDiagnostics>()
|
||||
.init_resource::<SurfaceEvaluatorRegistry>()
|
||||
.add_systems(Update, sync_surface_material_bindings);
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn sync_surface_material_bindings(
|
||||
mut commands: Commands,
|
||||
asset_server: Res<AssetServer>,
|
||||
mut shaders: ResMut<Assets<Shader>>,
|
||||
mut standard_materials: ResMut<Assets<StandardMaterial>>,
|
||||
mut surface_materials: ResMut<Assets<SurfaceMaterial>>,
|
||||
mut cache: ResMut<SurfaceMaterialCache>,
|
||||
mut diagnostics: ResMut<SurfaceDiagnostics>,
|
||||
mut evaluator_registry: ResMut<SurfaceEvaluatorRegistry>,
|
||||
bindings: Query<(
|
||||
Entity,
|
||||
&HydratedRendererMaterialBinding,
|
||||
Option<&MeshMaterial3d<StandardMaterial>>,
|
||||
Option<&MeshMaterial3d<SurfaceMaterial>>,
|
||||
)>,
|
||||
) {
|
||||
for (entity, binding, standard_handle, surface_handle) in &bindings {
|
||||
let Some(reference) = binding.effective_material.as_ref() else {
|
||||
continue;
|
||||
};
|
||||
let Some(path) = reference.0.source_path.as_deref() else {
|
||||
continue;
|
||||
};
|
||||
let dependencies = cache
|
||||
.dependencies
|
||||
.get(reference)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| vec![path.to_string()]);
|
||||
let revision = dependency_revision(&dependencies);
|
||||
if cache.failed_revisions.get(reference) == Some(&revision) {
|
||||
continue;
|
||||
}
|
||||
if cache.standard_only.get(reference) == Some(&revision) {
|
||||
if let Some(handle) = cache.standard_handles.get(reference).cloned() {
|
||||
if standard_handle.is_none_or(|current| current.0 != handle)
|
||||
|| surface_handle.is_some()
|
||||
{
|
||||
commands
|
||||
.entity(entity)
|
||||
.remove::<MeshMaterial3d<SurfaceMaterial>>()
|
||||
.insert(MeshMaterial3d(handle));
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let stale = cache.revisions.get(reference) != Some(&revision);
|
||||
let handle = if !stale {
|
||||
cache.handles.get(reference).cloned()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let handle = match handle {
|
||||
Some(handle) => handle,
|
||||
None => match build_surface_material(
|
||||
reference,
|
||||
path,
|
||||
&asset_server,
|
||||
&mut shaders,
|
||||
&mut standard_materials,
|
||||
standard_handle,
|
||||
&mut evaluator_registry,
|
||||
) {
|
||||
Ok(BuiltRendererMaterial::Surface(material)) => {
|
||||
cache.standard_only.remove(reference);
|
||||
cache.standard_handles.remove(reference);
|
||||
cache.failed_revisions.remove(reference);
|
||||
if let Some(existing) = cache.handles.get(reference).cloned() {
|
||||
if let Some(mut slot) = surface_materials.get_mut(&existing) {
|
||||
*slot = material;
|
||||
}
|
||||
existing
|
||||
} else {
|
||||
let handle = surface_materials.add(material);
|
||||
cache.handles.insert(reference.clone(), handle.clone());
|
||||
handle
|
||||
}
|
||||
}
|
||||
Ok(BuiltRendererMaterial::Standard(material)) => {
|
||||
// Plain Material assets stay on Bevy's StandardMaterial path, while still
|
||||
// sharing one live-updated handle across every renderer slot.
|
||||
let handle = standard_handle
|
||||
.map(|handle| handle.0.clone())
|
||||
.or_else(|| cache.standard_handles.get(reference).cloned())
|
||||
.unwrap_or_else(|| standard_materials.add(material.clone()));
|
||||
if let Some(mut slot) = standard_materials.get_mut(&handle) {
|
||||
*slot = material;
|
||||
}
|
||||
cache.handles.remove(reference);
|
||||
cache.revisions.remove(reference);
|
||||
cache
|
||||
.standard_handles
|
||||
.insert(reference.clone(), handle.clone());
|
||||
let next_dependencies =
|
||||
material_dependency_paths(path).unwrap_or_else(|_| vec![path.to_string()]);
|
||||
let next_revision = dependency_revision(&next_dependencies);
|
||||
cache
|
||||
.dependencies
|
||||
.insert(reference.clone(), next_dependencies);
|
||||
cache.standard_only.insert(reference.clone(), next_revision);
|
||||
cache.failed_revisions.remove(reference);
|
||||
evaluator_registry
|
||||
.evaluators
|
||||
.remove(&stable_shader_id(reference));
|
||||
if standard_handle.is_none_or(|current| current.0 != handle)
|
||||
|| surface_handle.is_some()
|
||||
{
|
||||
commands
|
||||
.entity(entity)
|
||||
.remove::<MeshMaterial3d<SurfaceMaterial>>()
|
||||
.insert(MeshMaterial3d(handle));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
Err(error) => {
|
||||
cache.failed_revisions.insert(reference.clone(), revision);
|
||||
diagnostics.0.push(format!(
|
||||
"material {} could not build Surface ABI: {error}",
|
||||
reference.0.label
|
||||
));
|
||||
continue;
|
||||
}
|
||||
},
|
||||
};
|
||||
let next_dependencies =
|
||||
material_dependency_paths(path).unwrap_or_else(|_| vec![path.to_string()]);
|
||||
let next_revision = dependency_revision(&next_dependencies);
|
||||
cache
|
||||
.dependencies
|
||||
.insert(reference.clone(), next_dependencies);
|
||||
cache.revisions.insert(reference.clone(), next_revision);
|
||||
if surface_handle.is_none_or(|current| current.0 != handle) {
|
||||
commands
|
||||
.entity(entity)
|
||||
.remove::<MeshMaterial3d<StandardMaterial>>()
|
||||
.insert(MeshMaterial3d(handle));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_surface_material(
|
||||
reference: &MaterialRef,
|
||||
path: &str,
|
||||
asset_server: &AssetServer,
|
||||
shaders: &mut Assets<Shader>,
|
||||
standard_materials: &mut Assets<StandardMaterial>,
|
||||
existing_standard: Option<&MeshMaterial3d<StandardMaterial>>,
|
||||
evaluator_registry: &mut SurfaceEvaluatorRegistry,
|
||||
) -> Result<BuiltRendererMaterial, String> {
|
||||
let (mut asset, instance) = match MaterialAsset::load_from_path(path) {
|
||||
Ok(asset) => (asset, None),
|
||||
Err(_) => {
|
||||
let instance = MaterialInstanceAsset::load_from_path(path)?;
|
||||
if instance.schema_version != MATERIAL_INSTANCE_SCHEMA_VERSION {
|
||||
return Err(format!(
|
||||
"unsupported material-instance schema {}",
|
||||
instance.schema_version
|
||||
));
|
||||
}
|
||||
let base_path = instance
|
||||
.base
|
||||
.0
|
||||
.source_path
|
||||
.as_deref()
|
||||
.ok_or_else(|| "material instance base has no loadable path".to_string())?;
|
||||
(MaterialAsset::load_from_path(base_path)?, Some(instance))
|
||||
}
|
||||
};
|
||||
if let Some(instance) = instance.as_ref() {
|
||||
instance.apply_to(&mut asset.material);
|
||||
}
|
||||
let schema_path = asset
|
||||
.shader_ref
|
||||
.as_ref()
|
||||
.and_then(|reference| reference.source_path.as_deref())
|
||||
.or(asset.material.shader.schema_path.as_deref())
|
||||
.or(asset
|
||||
.shader
|
||||
.as_deref()
|
||||
.filter(|path| path.ends_with(".ron")));
|
||||
let schema = schema_path
|
||||
.map(ShaderSchemaAsset::load_from_path)
|
||||
.transpose()?;
|
||||
let evaluator_source = schema
|
||||
.as_ref()
|
||||
.and_then(|schema| schema.wgsl_path.as_deref())
|
||||
.map(fs::read_to_string)
|
||||
.transpose()
|
||||
.map_err(|error| format!("could not read surface WGSL: {error}"))?;
|
||||
|
||||
let mut base = existing_standard
|
||||
.and_then(|handle| standard_materials.get(&handle.0).cloned())
|
||||
.unwrap_or_else(|| material_from_desc(asset_server, &asset.material));
|
||||
base.alpha_mode = match asset.render_state.alpha_mode {
|
||||
shared::MaterialAlphaMode::Opaque => AlphaMode::Opaque,
|
||||
shared::MaterialAlphaMode::Cutout => AlphaMode::Mask(asset.render_state.alpha_cutoff),
|
||||
};
|
||||
base.cull_mode =
|
||||
(!asset.render_state.double_sided).then_some(bevy::render::render_resource::Face::Back);
|
||||
|
||||
let Some(evaluator) = evaluator_source.as_deref() else {
|
||||
return Ok(BuiltRendererMaterial::Standard(base));
|
||||
};
|
||||
validate_surface_evaluator(evaluator)?;
|
||||
let runtime_shader_id = stable_shader_id(reference);
|
||||
evaluator_registry.evaluators.insert(
|
||||
runtime_shader_id,
|
||||
SurfaceEvaluatorRecord {
|
||||
shader_id: runtime_shader_id,
|
||||
evaluator_source: evaluator.to_string(),
|
||||
revision: evaluator_revision(evaluator),
|
||||
alpha_mode: asset.render_state.alpha_mode,
|
||||
},
|
||||
);
|
||||
let virtual_path = format!("generated://surface/{}.wgsl", reference.0.asset_id);
|
||||
let shader_uuid = shader_uuid_for_material(reference);
|
||||
let shader_id = AssetId::<Shader>::Uuid { uuid: shader_uuid };
|
||||
shaders
|
||||
.insert(
|
||||
shader_id,
|
||||
Shader::from_wgsl(compose_surface_shader(evaluator), virtual_path),
|
||||
)
|
||||
.map_err(|error| format!("could not replace generated surface shader: {error}"))?;
|
||||
let shader = Handle::Uuid(shader_uuid, Default::default());
|
||||
|
||||
let mut extension = SurfaceExtension {
|
||||
shader,
|
||||
..default()
|
||||
};
|
||||
extension.uniform.alpha_cutoff = asset.render_state.alpha_cutoff;
|
||||
extension.uniform.shader_id = runtime_shader_id;
|
||||
if let Some(schema) = schema.as_ref() {
|
||||
pack_schema_values(
|
||||
&mut extension,
|
||||
schema,
|
||||
&asset.material,
|
||||
instance.as_ref(),
|
||||
asset_server,
|
||||
)?;
|
||||
}
|
||||
Ok(BuiltRendererMaterial::Surface(ExtendedMaterial {
|
||||
base,
|
||||
extension,
|
||||
}))
|
||||
}
|
||||
|
||||
fn material_dependency_paths(path: &str) -> Result<Vec<String>, String> {
|
||||
let mut dependencies = vec![path.to_string()];
|
||||
let asset = match MaterialAsset::load_from_path(path) {
|
||||
Ok(asset) => asset,
|
||||
Err(_) => {
|
||||
let instance = MaterialInstanceAsset::load_from_path(path)?;
|
||||
let base_path = instance
|
||||
.base
|
||||
.0
|
||||
.source_path
|
||||
.as_deref()
|
||||
.ok_or_else(|| "material instance base has no loadable path".to_string())?;
|
||||
dependencies.push(base_path.to_string());
|
||||
MaterialAsset::load_from_path(base_path)?
|
||||
}
|
||||
};
|
||||
let schema_path = asset
|
||||
.shader_ref
|
||||
.as_ref()
|
||||
.and_then(|reference| reference.source_path.as_deref())
|
||||
.or(asset.material.shader.schema_path.as_deref())
|
||||
.or(asset
|
||||
.shader
|
||||
.as_deref()
|
||||
.filter(|path| path.ends_with(".ron")));
|
||||
if let Some(schema_path) = schema_path {
|
||||
dependencies.push(schema_path.to_string());
|
||||
if let Some(wgsl_path) = ShaderSchemaAsset::load_from_path(schema_path)?.wgsl_path {
|
||||
dependencies.push(wgsl_path);
|
||||
}
|
||||
}
|
||||
dependencies.sort();
|
||||
dependencies.dedup();
|
||||
Ok(dependencies)
|
||||
}
|
||||
|
||||
fn dependency_revision(paths: &[String]) -> u64 {
|
||||
let mut hash = 0xcbf29ce484222325u64;
|
||||
for path in paths {
|
||||
for byte in path.bytes() {
|
||||
hash ^= u64::from(byte);
|
||||
hash = hash.wrapping_mul(0x100000001b3);
|
||||
}
|
||||
match fs::metadata(path) {
|
||||
Ok(metadata) => {
|
||||
for byte in metadata.len().to_le_bytes() {
|
||||
hash ^= u64::from(byte);
|
||||
hash = hash.wrapping_mul(0x100000001b3);
|
||||
}
|
||||
if let Ok(modified) = metadata.modified() {
|
||||
if let Ok(duration) = modified.duration_since(std::time::UNIX_EPOCH) {
|
||||
for byte in duration.as_nanos().to_le_bytes() {
|
||||
hash ^= u64::from(byte);
|
||||
hash = hash.wrapping_mul(0x100000001b3);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
hash ^= u64::MAX;
|
||||
hash = hash.wrapping_mul(0x100000001b3);
|
||||
}
|
||||
}
|
||||
}
|
||||
hash
|
||||
}
|
||||
|
||||
fn stable_shader_id(reference: &MaterialRef) -> u32 {
|
||||
let mut hash = 0x811c9dc5u32;
|
||||
for byte in reference.0.asset_id.bytes() {
|
||||
hash ^= u32::from(byte);
|
||||
hash = hash.wrapping_mul(0x01000193);
|
||||
}
|
||||
hash
|
||||
}
|
||||
|
||||
fn evaluator_revision(source: &str) -> u64 {
|
||||
let mut hash = 0xcbf29ce484222325u64;
|
||||
for byte in source.bytes() {
|
||||
hash ^= u64::from(byte);
|
||||
hash = hash.wrapping_mul(0x100000001b3);
|
||||
}
|
||||
hash
|
||||
}
|
||||
|
||||
fn shader_uuid_for_material(reference: &MaterialRef) -> uuid::Uuid {
|
||||
if let Ok(source) = uuid::Uuid::parse_str(&reference.0.asset_id) {
|
||||
let mixed = source.as_u128() ^ 0x7f8c_4e90_f9ab_4f28_9b44_47c1_b2d5_5ac1u128;
|
||||
return uuid::Uuid::from_u128(mixed);
|
||||
}
|
||||
let mut bytes = [0u8; 16];
|
||||
for (index, byte) in reference.0.asset_id.bytes().enumerate() {
|
||||
bytes[index % 16] = bytes[index % 16].wrapping_mul(31).wrapping_add(byte);
|
||||
}
|
||||
uuid::Uuid::from_bytes(bytes)
|
||||
}
|
||||
|
||||
fn pack_schema_values(
|
||||
extension: &mut SurfaceExtension,
|
||||
schema: &ShaderSchemaAsset,
|
||||
material: &shared::MaterialDesc,
|
||||
instance: Option<&MaterialInstanceAsset>,
|
||||
asset_server: &AssetServer,
|
||||
) -> Result<(), String> {
|
||||
let mut numeric_index = 0usize;
|
||||
let mut texture_index = 0usize;
|
||||
for property in &schema.parameters {
|
||||
if matches!(property.property_type, ShaderPropertyType::Texture) {
|
||||
if texture_index >= SURFACE_TEXTURE_SLOTS {
|
||||
return Err(format!(
|
||||
"surface schema exceeds {SURFACE_TEXTURE_SLOTS} textures"
|
||||
));
|
||||
}
|
||||
let binding = instance
|
||||
.and_then(|instance| {
|
||||
instance
|
||||
.textures
|
||||
.iter()
|
||||
.find(|value| value.name == property.name)
|
||||
})
|
||||
.or_else(|| {
|
||||
material
|
||||
.textures
|
||||
.iter()
|
||||
.find(|value| value.name == property.name)
|
||||
});
|
||||
let handle = binding
|
||||
.and_then(|binding| binding.texture.as_ref())
|
||||
.and_then(|reference| reference.source_path.as_deref())
|
||||
.map(asset_server_path)
|
||||
.map(|path| asset_server.load(path));
|
||||
extension.set_texture(texture_index, handle);
|
||||
texture_index += 1;
|
||||
continue;
|
||||
}
|
||||
if numeric_index >= SURFACE_PARAMETER_LANES {
|
||||
return Err(format!(
|
||||
"surface schema exceeds {SURFACE_PARAMETER_LANES} parameter lanes"
|
||||
));
|
||||
}
|
||||
let value = instance
|
||||
.and_then(|instance| {
|
||||
instance
|
||||
.parameters
|
||||
.iter()
|
||||
.find(|value| value.name == property.name)
|
||||
})
|
||||
.or_else(|| {
|
||||
material
|
||||
.parameters
|
||||
.iter()
|
||||
.find(|value| value.name == property.name)
|
||||
})
|
||||
.or_else(|| {
|
||||
schema
|
||||
.default_values
|
||||
.iter()
|
||||
.find(|value| value.name == property.name)
|
||||
});
|
||||
extension.uniform.params[numeric_index] = value
|
||||
.map(|value| parameter_lane(&value.value, &property.property_type))
|
||||
.transpose()?
|
||||
.unwrap_or(Vec4::ZERO);
|
||||
numeric_index += 1;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parameter_lane(
|
||||
value: &MaterialParameterValue,
|
||||
property_type: &ShaderPropertyType,
|
||||
) -> Result<Vec4, String> {
|
||||
match (value, property_type) {
|
||||
(MaterialParameterValue::Bool(value), ShaderPropertyType::Bool) => {
|
||||
Ok(Vec4::new(u32::from(*value) as f32, 0.0, 0.0, 0.0))
|
||||
}
|
||||
(MaterialParameterValue::Float(value), ShaderPropertyType::Float { .. }) => {
|
||||
Ok(Vec4::new(*value, 0.0, 0.0, 0.0))
|
||||
}
|
||||
(MaterialParameterValue::Vec2(value), ShaderPropertyType::Vec2) => {
|
||||
Ok(Vec4::new(value.x, value.y, 0.0, 0.0))
|
||||
}
|
||||
(MaterialParameterValue::Vec3(value), ShaderPropertyType::Vec3) => Ok(value.extend(0.0)),
|
||||
(MaterialParameterValue::Color(value), ShaderPropertyType::Color) => {
|
||||
Ok(Vec4::new(value.r, value.g, value.b, value.a))
|
||||
}
|
||||
(MaterialParameterValue::Enum(value), ShaderPropertyType::Enum { options }) => {
|
||||
let index = options
|
||||
.iter()
|
||||
.position(|option| option == value)
|
||||
.ok_or_else(|| format!("enum value `{value}` is not declared by the schema"))?;
|
||||
Ok(Vec4::new(index as f32, 0.0, 0.0, 0.0))
|
||||
}
|
||||
_ => Err("material value does not match shader schema type".into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate_surface_evaluator(source: &str) -> Result<(), String> {
|
||||
if !source.contains("fn evaluate(") {
|
||||
return Err("surface WGSL must define `fn evaluate(`".into());
|
||||
}
|
||||
for forbidden in [
|
||||
"@group",
|
||||
"@binding",
|
||||
"@vertex",
|
||||
"@fragment",
|
||||
"@compute",
|
||||
"var<",
|
||||
"rayQuery",
|
||||
"dpdx",
|
||||
"dpdy",
|
||||
"fwidth",
|
||||
"discard",
|
||||
"Barrier",
|
||||
"subgroup",
|
||||
"textureStore",
|
||||
"atomic",
|
||||
] {
|
||||
if source.contains(forbidden) {
|
||||
return Err(format!(
|
||||
"surface WGSL uses forbidden construct `{forbidden}`"
|
||||
));
|
||||
}
|
||||
}
|
||||
let validation_source = format!("{EVALUATOR_VALIDATION_PRELUDE}\n{source}");
|
||||
let module = naga::front::wgsl::parse_str(&validation_source)
|
||||
.map_err(|error| format!("surface WGSL syntax error: {error}"))?;
|
||||
naga::valid::Validator::new(
|
||||
naga::valid::ValidationFlags::all(),
|
||||
naga::valid::Capabilities::empty(),
|
||||
)
|
||||
.validate(&module)
|
||||
.map_err(|error| format!("surface WGSL validation error: {error}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const EVALUATOR_VALIDATION_PRELUDE: &str = r#"
|
||||
struct SurfaceInput {
|
||||
uv0: vec2<f32>,
|
||||
world_position: vec3<f32>,
|
||||
world_normal: vec3<f32>,
|
||||
}
|
||||
struct SurfaceParams { lanes: array<vec4<f32>, 16>, }
|
||||
struct SurfaceSamples { values: array<vec4<f32>, 8>, }
|
||||
struct Surface {
|
||||
base_color: vec4<f32>,
|
||||
normal_ts: vec3<f32>,
|
||||
emissive: vec3<f32>,
|
||||
metallic: f32,
|
||||
perceptual_roughness: f32,
|
||||
reflectance: f32,
|
||||
occlusion: f32,
|
||||
model: u32,
|
||||
}
|
||||
fn surface_default() -> Surface {
|
||||
var surface: Surface;
|
||||
surface.base_color = vec4<f32>(1.0);
|
||||
surface.normal_ts = vec3<f32>(0.0, 0.0, 1.0);
|
||||
surface.emissive = vec3<f32>(0.0);
|
||||
surface.metallic = 0.0;
|
||||
surface.perceptual_roughness = 0.5;
|
||||
surface.reflectance = 0.5;
|
||||
surface.occlusion = 1.0;
|
||||
surface.model = 0u;
|
||||
return surface;
|
||||
}
|
||||
"#;
|
||||
|
||||
pub fn compose_surface_shader(evaluator: &str) -> String {
|
||||
format!(
|
||||
"{}\n{}\n{}",
|
||||
include_str!("surface_header.wgsl"),
|
||||
evaluator,
|
||||
include_str!("surface_footer.wgsl")
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rejects_resource_bindings_and_missing_entry() {
|
||||
assert!(validate_surface_evaluator("fn nope() {}").is_err());
|
||||
assert!(validate_surface_evaluator(
|
||||
"@group(0) @binding(0) var t: texture_2d<f32>; fn evaluate() {}"
|
||||
)
|
||||
.is_err());
|
||||
for compute_incompatible in [
|
||||
"dpdx(input.uv0.x)",
|
||||
"dpdy(input.uv0.y)",
|
||||
"fwidth(input.uv0.x)",
|
||||
"discard",
|
||||
"workgroupBarrier()",
|
||||
"subgroupAdd(1u)",
|
||||
] {
|
||||
let source = format!(
|
||||
"fn evaluate(input: SurfaceInput, params: SurfaceParams, samples: SurfaceSamples) -> Surface {{ let invalid = {compute_incompatible}; return surface_default(); }}"
|
||||
);
|
||||
assert!(
|
||||
validate_surface_evaluator(&source).is_err(),
|
||||
"accepted compute-incompatible evaluator: {compute_incompatible}"
|
||||
);
|
||||
}
|
||||
assert!(validate_surface_evaluator(
|
||||
"fn evaluate(input: SurfaceInput, params: SurfaceParams, samples: SurfaceSamples) -> Surface { return missing_symbol; }"
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_surface_only_module_and_composes_wrapper() {
|
||||
let source = "fn evaluate(input: SurfaceInput, params: SurfaceParams, samples: SurfaceSamples) -> Surface { return surface_default(); }";
|
||||
validate_surface_evaluator(source).unwrap();
|
||||
let composed = compose_surface_shader(source);
|
||||
assert!(composed.contains(source));
|
||||
assert!(composed.contains("@fragment"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abi_limits_are_stable() {
|
||||
assert_eq!(SURFACE_PARAMETER_LANES, 16);
|
||||
assert_eq!(SURFACE_TEXTURE_SLOTS, 8);
|
||||
assert_eq!(std::mem::size_of::<SurfaceUniform>(), 400);
|
||||
}
|
||||
}
|
||||
41
crates/blacksite_surface/src/surface_footer.wgsl
Normal file
41
crates/blacksite_surface/src/surface_footer.wgsl
Normal file
@ -0,0 +1,41 @@
|
||||
@fragment
|
||||
fn fragment(in: VertexOutput, @builtin(front_facing) is_front: bool) -> FragmentOutput {
|
||||
var pbr_input = pbr_input_from_standard_material(in, is_front);
|
||||
#ifdef VERTEX_UVS_A
|
||||
let uv0 = in.uv;
|
||||
#else
|
||||
let uv0 = vec2<f32>(0.0);
|
||||
#endif
|
||||
let input = SurfaceInput(uv0, pbr_input.world_position.xyz, pbr_input.N);
|
||||
let params = SurfaceParams(surface_uniform.params);
|
||||
let surface = evaluate(input, params, surface_samples(uv0));
|
||||
pbr_input.material.base_color = surface.base_color;
|
||||
pbr_input.material.emissive = vec4<f32>(surface.emissive, 1.0);
|
||||
pbr_input.material.metallic = clamp(surface.metallic, 0.0, 1.0);
|
||||
pbr_input.material.perceptual_roughness = clamp(surface.perceptual_roughness, 0.001, 1.0);
|
||||
pbr_input.material.reflectance = vec3<f32>(clamp(surface.reflectance, 0.0, 1.0));
|
||||
pbr_input.diffuse_occlusion = vec3<f32>(clamp(surface.occlusion, 0.0, 1.0));
|
||||
#ifdef VERTEX_TANGENTS
|
||||
let surface_tbn = calculate_tbn_mikktspace(pbr_input.world_normal, in.world_tangent);
|
||||
let surface_nt = normalize(surface.normal_ts);
|
||||
pbr_input.N = normalize(
|
||||
surface_nt.x * surface_tbn[0]
|
||||
+ surface_nt.y * surface_tbn[1]
|
||||
+ surface_nt.z * surface_tbn[2]
|
||||
);
|
||||
#endif
|
||||
if surface.model == BLACKSITE_SURFACE_UNLIT {
|
||||
pbr_input.material.flags |= STANDARD_MATERIAL_FLAGS_UNLIT_BIT;
|
||||
} else {
|
||||
pbr_input.material.flags &= ~STANDARD_MATERIAL_FLAGS_UNLIT_BIT;
|
||||
}
|
||||
pbr_input.material.base_color = alpha_discard(pbr_input.material, pbr_input.material.base_color);
|
||||
#ifdef PREPASS_PIPELINE
|
||||
return deferred_output(in, pbr_input);
|
||||
#else
|
||||
var out: FragmentOutput;
|
||||
out.color = apply_pbr_lighting(pbr_input);
|
||||
out.color = main_pass_post_lighting_processing(pbr_input, out.color);
|
||||
return out;
|
||||
#endif
|
||||
}
|
||||
103
crates/blacksite_surface/src/surface_header.wgsl
Normal file
103
crates/blacksite_surface/src/surface_header.wgsl
Normal file
@ -0,0 +1,103 @@
|
||||
#import bevy_pbr::{
|
||||
pbr_fragment::pbr_input_from_standard_material,
|
||||
pbr_functions::{alpha_discard, calculate_tbn_mikktspace},
|
||||
}
|
||||
|
||||
#ifdef PREPASS_PIPELINE
|
||||
#import bevy_pbr::{
|
||||
prepass_io::{VertexOutput, FragmentOutput},
|
||||
pbr_deferred_functions::deferred_output,
|
||||
}
|
||||
#else
|
||||
#import bevy_pbr::{
|
||||
forward_io::{VertexOutput, FragmentOutput},
|
||||
pbr_functions::{apply_pbr_lighting, main_pass_post_lighting_processing},
|
||||
}
|
||||
#endif
|
||||
|
||||
const BLACKSITE_SURFACE_ABI_VERSION: u32 = 1u;
|
||||
const BLACKSITE_SURFACE_UNLIT: u32 = 1u;
|
||||
const STANDARD_MATERIAL_FLAGS_UNLIT_BIT: u32 = 1u << 5u;
|
||||
|
||||
struct SurfaceUniform {
|
||||
shader_id: u32,
|
||||
flags: u32,
|
||||
alpha_cutoff: f32,
|
||||
abi_version: u32,
|
||||
params: array<vec4<f32>, 16>,
|
||||
uv_transforms: array<vec4<f32>, 8>,
|
||||
}
|
||||
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(100) var<uniform> surface_uniform: SurfaceUniform;
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(101) var surface_texture0: texture_2d<f32>;
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(102) var surface_sampler0: sampler;
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(103) var surface_texture1: texture_2d<f32>;
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(104) var surface_sampler1: sampler;
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(105) var surface_texture2: texture_2d<f32>;
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(106) var surface_sampler2: sampler;
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(107) var surface_texture3: texture_2d<f32>;
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(108) var surface_sampler3: sampler;
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(109) var surface_texture4: texture_2d<f32>;
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(110) var surface_sampler4: sampler;
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(111) var surface_texture5: texture_2d<f32>;
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(112) var surface_sampler5: sampler;
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(113) var surface_texture6: texture_2d<f32>;
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(114) var surface_sampler6: sampler;
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(115) var surface_texture7: texture_2d<f32>;
|
||||
@group(#{MATERIAL_BIND_GROUP}) @binding(116) var surface_sampler7: sampler;
|
||||
|
||||
struct SurfaceInput {
|
||||
uv0: vec2<f32>,
|
||||
world_position: vec3<f32>,
|
||||
world_normal: vec3<f32>,
|
||||
}
|
||||
|
||||
struct SurfaceParams {
|
||||
lanes: array<vec4<f32>, 16>,
|
||||
}
|
||||
|
||||
struct SurfaceSamples {
|
||||
values: array<vec4<f32>, 8>,
|
||||
}
|
||||
|
||||
struct Surface {
|
||||
base_color: vec4<f32>,
|
||||
normal_ts: vec3<f32>,
|
||||
emissive: vec3<f32>,
|
||||
metallic: f32,
|
||||
perceptual_roughness: f32,
|
||||
reflectance: f32,
|
||||
occlusion: f32,
|
||||
model: u32,
|
||||
}
|
||||
|
||||
fn surface_default() -> Surface {
|
||||
var surface: Surface;
|
||||
surface.base_color = vec4<f32>(1.0);
|
||||
surface.normal_ts = vec3<f32>(0.0, 0.0, 1.0);
|
||||
surface.emissive = vec3<f32>(0.0);
|
||||
surface.metallic = 0.0;
|
||||
surface.perceptual_roughness = 0.5;
|
||||
surface.reflectance = 0.5;
|
||||
surface.occlusion = 1.0;
|
||||
surface.model = 0u;
|
||||
return surface;
|
||||
}
|
||||
|
||||
fn surface_uv(index: u32, uv: vec2<f32>) -> vec2<f32> {
|
||||
let transform = surface_uniform.uv_transforms[index];
|
||||
return uv * transform.xy + transform.zw;
|
||||
}
|
||||
|
||||
fn surface_samples(uv: vec2<f32>) -> SurfaceSamples {
|
||||
var samples: SurfaceSamples;
|
||||
samples.values[0] = textureSample(surface_texture0, surface_sampler0, surface_uv(0u, uv));
|
||||
samples.values[1] = textureSample(surface_texture1, surface_sampler1, surface_uv(1u, uv));
|
||||
samples.values[2] = textureSample(surface_texture2, surface_sampler2, surface_uv(2u, uv));
|
||||
samples.values[3] = textureSample(surface_texture3, surface_sampler3, surface_uv(3u, uv));
|
||||
samples.values[4] = textureSample(surface_texture4, surface_sampler4, surface_uv(4u, uv));
|
||||
samples.values[5] = textureSample(surface_texture5, surface_sampler5, surface_uv(5u, uv));
|
||||
samples.values[6] = textureSample(surface_texture6, surface_sampler6, surface_uv(6u, uv));
|
||||
samples.values[7] = textureSample(surface_texture7, surface_sampler7, surface_uv(7u, uv));
|
||||
return samples;
|
||||
}
|
||||
@ -33,6 +33,7 @@ egui_phosphor_icons.workspace = true
|
||||
bevy_ufbx.workspace = true
|
||||
ufbx = "0.9"
|
||||
game.workspace = true
|
||||
polyanya.workspace = true
|
||||
game_hot.workspace = true
|
||||
hot-lib-reloader = { version = "0.8.2", optional = true }
|
||||
notify = { version = "6.1", optional = true }
|
||||
|
||||
@ -210,6 +210,13 @@ fn build_gltf_manifest(
|
||||
});
|
||||
}
|
||||
|
||||
let default_animation_clip_id = record.import_settings.default_animation_clip_id.clone();
|
||||
validate_default_animation_clip(
|
||||
default_animation_clip_id.as_deref(),
|
||||
&clips,
|
||||
&mut diagnostics,
|
||||
);
|
||||
|
||||
let runtime_supported = animation_roots.len() <= 1;
|
||||
if !runtime_supported {
|
||||
let roots = animation_roots
|
||||
@ -232,6 +239,7 @@ fn build_gltf_manifest(
|
||||
schema_version: ANIMATION_MANIFEST_SCHEMA_VERSION,
|
||||
asset_id: record.id.as_string(),
|
||||
label: record.label.clone(),
|
||||
default_animation_clip_id,
|
||||
source: AnimationManifestSource {
|
||||
path: record.path.clone(),
|
||||
format,
|
||||
@ -313,15 +321,22 @@ fn build_fbx_manifest(
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let (runtime_supported, diagnostics) = fbx_runtime_support(
|
||||
let (runtime_supported, mut diagnostics) = fbx_runtime_support(
|
||||
scene.anim_stacks.as_ref().len(),
|
||||
scene.skin_deformers.as_ref().len(),
|
||||
);
|
||||
let default_animation_clip_id = record.import_settings.default_animation_clip_id.clone();
|
||||
validate_default_animation_clip(
|
||||
default_animation_clip_id.as_deref(),
|
||||
&clips,
|
||||
&mut diagnostics,
|
||||
);
|
||||
|
||||
Ok(AnimationManifest {
|
||||
schema_version: ANIMATION_MANIFEST_SCHEMA_VERSION,
|
||||
asset_id: record.id.as_string(),
|
||||
label: record.label.clone(),
|
||||
default_animation_clip_id,
|
||||
source: AnimationManifestSource {
|
||||
path: record.path.clone(),
|
||||
format,
|
||||
@ -335,6 +350,27 @@ fn build_fbx_manifest(
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_default_animation_clip(
|
||||
default_clip_id: Option<&str>,
|
||||
clips: &[AnimationClipRecord],
|
||||
diagnostics: &mut Vec<AnimationImportDiagnostic>,
|
||||
) {
|
||||
let Some(default_clip_id) = default_clip_id.filter(|id| !id.trim().is_empty()) else {
|
||||
return;
|
||||
};
|
||||
if clips.iter().any(|clip| clip.id == default_clip_id) {
|
||||
return;
|
||||
}
|
||||
diagnostics.push(AnimationImportDiagnostic {
|
||||
severity: AnimationDiagnosticSeverity::Error,
|
||||
code: "animation.default_clip_missing".into(),
|
||||
message: format!(
|
||||
"configured default animation clip `{default_clip_id}` no longer exists in the imported source"
|
||||
),
|
||||
repair: "Choose an existing Default animation in Model Import Settings, or select Imported rest pose, then reimport.".into(),
|
||||
});
|
||||
}
|
||||
|
||||
fn fbx_runtime_support(
|
||||
animation_count: usize,
|
||||
skin_count: usize,
|
||||
@ -696,7 +732,8 @@ mod tests {
|
||||
let second = build_animation_manifest(&record).unwrap();
|
||||
|
||||
assert_eq!(first, second);
|
||||
assert_eq!(first.schema_version, 2);
|
||||
assert_eq!(first.schema_version, ANIMATION_MANIFEST_SCHEMA_VERSION);
|
||||
assert!(first.default_animation_clip_id.is_none());
|
||||
assert!(first.runtime_supported);
|
||||
assert_eq!(first.source.dependencies.len(), 1);
|
||||
assert_eq!(first.skeletons.len(), 1);
|
||||
@ -717,6 +754,32 @@ mod tests {
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_default_clip_is_stable_and_stale_ids_are_diagnostic() {
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("blacksite-animation-default-{}", Uuid::new_v4()));
|
||||
let path = write_animated_gltf(&root);
|
||||
let mut record = fixture_record(&path);
|
||||
record.import_settings.default_animation_clip_id =
|
||||
Some("animation:clip:0:idle_loop".into());
|
||||
|
||||
let manifest = build_animation_manifest(&record).unwrap();
|
||||
assert_eq!(
|
||||
manifest.default_animation_clip_id.as_deref(),
|
||||
Some("animation:clip:0:idle_loop")
|
||||
);
|
||||
assert!(manifest.diagnostics.is_empty());
|
||||
|
||||
record.import_settings.default_animation_clip_id = Some("animation:clip:99:removed".into());
|
||||
let stale = build_animation_manifest(&record).unwrap();
|
||||
assert!(stale.diagnostics.iter().any(|diagnostic| {
|
||||
diagnostic.code == "animation.default_clip_missing"
|
||||
&& diagnostic.severity == AnimationDiagnosticSeverity::Error
|
||||
}));
|
||||
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exact_signature_changes_with_joint_path_or_bind_pose() {
|
||||
let identity = identity_matrix_bytes();
|
||||
|
||||
@ -27,8 +27,12 @@ impl Default for AssetId {
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum ModelPlacementMode {
|
||||
/// Place renderable content through the appropriate normalized renderer. Imports containing
|
||||
/// skins or animation route to `SkinnedMeshRenderer`; unrigged, non-animated content routes to
|
||||
/// `StaticMeshRenderer`.
|
||||
#[default]
|
||||
StaticAsset,
|
||||
/// Instantiate the complete source scene as a generic imported model.
|
||||
SceneInstance,
|
||||
}
|
||||
|
||||
@ -61,6 +65,10 @@ pub struct ImportSettings {
|
||||
pub static_mesh_manifest_path: Option<String>,
|
||||
#[serde(default)]
|
||||
pub animation_manifest_path: Option<String>,
|
||||
/// Stable animation clip sub-asset ID used as this model's edit-mode rest presentation.
|
||||
/// `None` preserves the imported node pose and never guesses a clip.
|
||||
#[serde(default)]
|
||||
pub default_animation_clip_id: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for ImportSettings {
|
||||
@ -74,6 +82,7 @@ impl Default for ImportSettings {
|
||||
material_policy: MaterialImportPolicy::default(),
|
||||
static_mesh_manifest_path: None,
|
||||
animation_manifest_path: None,
|
||||
default_animation_clip_id: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -336,6 +345,7 @@ mod tests {
|
||||
schema_version: ANIMATION_MANIFEST_SCHEMA_VERSION,
|
||||
asset_id: id.as_string(),
|
||||
label: "Moved Model".into(),
|
||||
default_animation_clip_id: None,
|
||||
source: AnimationManifestSource {
|
||||
path: old_path.clone(),
|
||||
format: "glb".into(),
|
||||
|
||||
@ -524,6 +524,7 @@ fn empty_snapshot_for_asset(asset: &EditorAsset, translation: Vec3) -> EditorEnt
|
||||
primitive: None,
|
||||
brush: None,
|
||||
static_mesh_renderer: None,
|
||||
skinned_mesh_renderer: None,
|
||||
material: None,
|
||||
material_override: None,
|
||||
rigid_body: None,
|
||||
@ -542,8 +543,18 @@ fn empty_snapshot_for_asset(asset: &EditorAsset, translation: Vec3) -> EditorEnt
|
||||
post_process_volume: None,
|
||||
team_spawn: None,
|
||||
objective: None,
|
||||
|
||||
navigation_bounds: None,
|
||||
|
||||
navigation_obstacle: None,
|
||||
|
||||
navigation_area: None,
|
||||
|
||||
navigation_link: None,
|
||||
hierarchy_sibling_index: 0,
|
||||
editor_visibility: EditorVisibility::default(),
|
||||
inspector_order: None,
|
||||
component_states: None,
|
||||
children: Vec::new(),
|
||||
}
|
||||
}
|
||||
@ -672,7 +683,22 @@ pub fn spawn_asset_at(world: &mut World, asset: &EditorAsset, translation: Vec3)
|
||||
record.import_settings.placement_mode,
|
||||
ModelPlacementMode::StaticAsset
|
||||
) {
|
||||
if let Some(renderer) = static_mesh_renderer_for_asset(world, path) {
|
||||
if let Some((renderer, requires_skinned_renderer)) =
|
||||
static_mesh_renderer_for_asset(world, path)
|
||||
{
|
||||
if requires_skinned_renderer {
|
||||
apply_skinned_mesh_placement_mode_with_materials(
|
||||
&mut snapshot,
|
||||
renderer.materials.clone(),
|
||||
);
|
||||
match default_animation_controller_for_record(&record) {
|
||||
Ok(controller) => snapshot.animation_controller = controller,
|
||||
Err(error) => warn!(
|
||||
"Default animation was not applied while placing {}: {error}",
|
||||
record.path
|
||||
),
|
||||
}
|
||||
} else {
|
||||
let collider = record
|
||||
.import_settings
|
||||
.generate_collider
|
||||
@ -683,6 +709,7 @@ pub fn spawn_asset_at(world: &mut World, asset: &EditorAsset, translation: Vec3)
|
||||
collider,
|
||||
record.import_settings.hierarchy_mode,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
warn!(
|
||||
"Static mesh placement fell back to SceneRoot for {} because no renderer manifest was available",
|
||||
@ -760,6 +787,11 @@ pub fn spawn_subasset_at(
|
||||
.parts
|
||||
.iter()
|
||||
.find(|part| part_effective_id_for_selection(part) == *sub_asset_id)?;
|
||||
let part_requires_skinned_renderer = manifest.metadata.animation_count > 0
|
||||
|| part.skinned
|
||||
|| (manifest.schema_version < 2
|
||||
&& crate::assets::gltf_skinned_primitive_labels(parent_path)
|
||||
.contains(&part.mesh_label));
|
||||
|
||||
let mesh_ref = shared::EditorAssetRef::new(
|
||||
manifest.asset_id.clone(),
|
||||
@ -784,6 +816,10 @@ pub fn spawn_subasset_at(
|
||||
id: shared::ComponentInstanceId::new("slot:0"),
|
||||
name: label.clone(),
|
||||
mesh: mesh_ref.clone(),
|
||||
material_slot_id: shared::ComponentInstanceId::new(format!(
|
||||
"slot:{}",
|
||||
part_effective_id_for_selection(part)
|
||||
)),
|
||||
material,
|
||||
local_transform: part.local_transform,
|
||||
visible: true,
|
||||
@ -800,6 +836,7 @@ pub fn spawn_subasset_at(
|
||||
primitive: None,
|
||||
brush: None,
|
||||
static_mesh_renderer: Some(shared::StaticMeshRenderer::single(slot)),
|
||||
skinned_mesh_renderer: None,
|
||||
material: None,
|
||||
material_override: None,
|
||||
rigid_body: None,
|
||||
@ -818,11 +855,41 @@ pub fn spawn_subasset_at(
|
||||
post_process_volume: None,
|
||||
team_spawn: None,
|
||||
objective: None,
|
||||
|
||||
navigation_bounds: None,
|
||||
|
||||
navigation_obstacle: None,
|
||||
|
||||
navigation_area: None,
|
||||
|
||||
navigation_link: None,
|
||||
hierarchy_sibling_index: 0,
|
||||
editor_visibility: EditorVisibility::default(),
|
||||
inspector_order: None,
|
||||
component_states: None,
|
||||
children: Vec::new(),
|
||||
};
|
||||
snapshot.transform.scale *= record.import_settings.scale;
|
||||
if part_requires_skinned_renderer {
|
||||
snapshot.actor_kind = ActorKind::SkinnedMesh;
|
||||
snapshot.static_mesh_renderer = None;
|
||||
snapshot.skinned_mesh_renderer = Some(shared::SkinnedMeshRenderer {
|
||||
materials: crate::assets::static_mesh::renderer_materials_from_manifest(
|
||||
&manifest,
|
||||
&record.import_settings,
|
||||
),
|
||||
..shared::SkinnedMeshRenderer::new(parent_path.clone())
|
||||
.with_asset_id(record.id.as_string())
|
||||
});
|
||||
match default_animation_controller_for_record(&record) {
|
||||
Ok(controller) => snapshot.animation_controller = controller,
|
||||
Err(error) => warn!(
|
||||
"Default animation was not applied while placing {}: {error}",
|
||||
record.path
|
||||
),
|
||||
}
|
||||
return Some(spawn_with_history(world, snapshot));
|
||||
}
|
||||
if record.import_settings.generate_collider {
|
||||
snapshot.rigid_body = Some(RigidBodyDesc::default());
|
||||
snapshot.collider = Some(ColliderDesc::static_mesh(vec![mesh_ref]));
|
||||
@ -840,6 +907,7 @@ pub struct AnimationClipAuthoringData {
|
||||
pub skeleton: shared::EditorAssetRef,
|
||||
pub skeleton_signature: shared::AnimationSkeletonSignature,
|
||||
pub state: shared::AnimationStateDesc,
|
||||
pub renderer_materials: shared::RendererMaterialSet,
|
||||
}
|
||||
|
||||
pub fn animation_clip_authoring_data(
|
||||
@ -905,6 +973,18 @@ pub fn animation_clip_authoring_data(
|
||||
let clip_ref = shared::EditorAssetRef::new(asset_id, clip.id.clone(), clip.label.clone())
|
||||
.with_source_path(parent_path);
|
||||
let state_id = animation_state_id(&clip.label, clip.source_index);
|
||||
let renderer_materials = record
|
||||
.import_settings
|
||||
.static_mesh_manifest_path
|
||||
.as_deref()
|
||||
.and_then(|path| load_static_mesh_manifest(path).ok())
|
||||
.map(|render_manifest| {
|
||||
crate::assets::static_mesh::renderer_materials_from_manifest(
|
||||
&render_manifest,
|
||||
&record.import_settings,
|
||||
)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
Ok(AnimationClipAuthoringData {
|
||||
model_asset_id: record.id.as_string(),
|
||||
model_path: parent_path.clone(),
|
||||
@ -920,6 +1000,7 @@ pub fn animation_clip_authoring_data(
|
||||
speed: 1.0,
|
||||
range: shared::AnimationPlaybackRange::default(),
|
||||
},
|
||||
renderer_materials,
|
||||
})
|
||||
}
|
||||
|
||||
@ -957,7 +1038,7 @@ pub fn animation_skeleton_signature_for_ref(
|
||||
|
||||
pub fn compatible_animation_skeleton_for_model(
|
||||
world: &World,
|
||||
model: &ModelRef,
|
||||
model: &shared::SkinnedMeshRenderer,
|
||||
signature: &shared::AnimationSkeletonSignature,
|
||||
) -> Result<shared::EditorAssetRef, String> {
|
||||
let record = world
|
||||
@ -1017,6 +1098,82 @@ fn animation_state_id(label: &str, source_index: usize) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn default_animation_controller_for_record(
|
||||
record: &crate::asset_db::AssetRecord,
|
||||
) -> Result<Option<shared::AnimationControllerDesc>, String> {
|
||||
let Some(manifest_path) = record.import_settings.animation_manifest_path.as_deref() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let manifest = load_animation_manifest(manifest_path)?;
|
||||
if manifest.default_animation_clip_id != record.import_settings.default_animation_clip_id {
|
||||
return Err(
|
||||
"model import settings and generated animation manifest disagree; reimport the model"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
let Some(default_clip_id) = manifest
|
||||
.default_animation_clip_id
|
||||
.as_deref()
|
||||
.filter(|id| !id.trim().is_empty())
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
if !manifest.runtime_supported {
|
||||
return Err(format!(
|
||||
"configured clip `{default_clip_id}` belongs to a model unsupported by the animation runtime"
|
||||
));
|
||||
}
|
||||
let clip = manifest
|
||||
.clips
|
||||
.iter()
|
||||
.find(|clip| clip.id == default_clip_id)
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"configured clip `{default_clip_id}` is missing from {manifest_path}; choose an existing clip or Imported rest pose"
|
||||
)
|
||||
})?;
|
||||
let signature = clip.target_skeleton_signature.as_ref().ok_or_else(|| {
|
||||
format!(
|
||||
"configured clip `{}` has no uniquely resolved target skeleton",
|
||||
clip.label
|
||||
)
|
||||
})?;
|
||||
let skeleton = manifest
|
||||
.skeletons
|
||||
.iter()
|
||||
.find(|skeleton| &skeleton.signature == signature)
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"configured clip `{}` targets skeleton `{}` but no exact match exists",
|
||||
clip.label, signature.0
|
||||
)
|
||||
})?;
|
||||
let state_id = animation_state_id(&clip.label, clip.source_index);
|
||||
let source_path = manifest.source.path.clone();
|
||||
let asset_id = manifest.asset_id.clone();
|
||||
Ok(Some(shared::AnimationControllerDesc {
|
||||
skeleton: Some(
|
||||
shared::EditorAssetRef::new(
|
||||
asset_id.clone(),
|
||||
skeleton.id.clone(),
|
||||
skeleton.label.clone(),
|
||||
)
|
||||
.with_source_path(&source_path),
|
||||
),
|
||||
states: vec![shared::AnimationStateDesc {
|
||||
id: state_id.clone(),
|
||||
label: clip.label.clone(),
|
||||
clip: shared::EditorAssetRef::new(asset_id, clip.id.clone(), clip.label.clone())
|
||||
.with_source_path(source_path),
|
||||
looping: true,
|
||||
speed: 1.0,
|
||||
range: shared::AnimationPlaybackRange::default(),
|
||||
}],
|
||||
default_state: state_id,
|
||||
..default()
|
||||
}))
|
||||
}
|
||||
|
||||
fn spawn_animation_clip_at(
|
||||
world: &mut World,
|
||||
selection: &AssetSelection,
|
||||
@ -1035,7 +1192,7 @@ fn spawn_animation_clip_at(
|
||||
let default_state = authored.state.id.clone();
|
||||
let snapshot = EditorEntitySnapshot {
|
||||
actor_id: None,
|
||||
actor_kind: ActorKind::ImportedModel,
|
||||
actor_kind: ActorKind::SkinnedMesh,
|
||||
actor_name: None,
|
||||
name: Some(authored.model_label),
|
||||
transform: Transform {
|
||||
@ -1046,6 +1203,11 @@ fn spawn_animation_clip_at(
|
||||
primitive: None,
|
||||
brush: None,
|
||||
static_mesh_renderer: None,
|
||||
skinned_mesh_renderer: Some(shared::SkinnedMeshRenderer {
|
||||
materials: authored.renderer_materials,
|
||||
..shared::SkinnedMeshRenderer::new(authored.model_path)
|
||||
.with_asset_id(authored.model_asset_id)
|
||||
}),
|
||||
material: None,
|
||||
material_override: None,
|
||||
rigid_body: None,
|
||||
@ -1061,7 +1223,7 @@ fn spawn_animation_clip_at(
|
||||
audio_source: None,
|
||||
audio_listener: None,
|
||||
player_spawn: false,
|
||||
model: Some(ModelRef::new(authored.model_path).with_asset_id(authored.model_asset_id)),
|
||||
model: None,
|
||||
prefab: None,
|
||||
prefab_instance: None,
|
||||
weapon_spawn: None,
|
||||
@ -1069,8 +1231,18 @@ fn spawn_animation_clip_at(
|
||||
post_process_volume: None,
|
||||
team_spawn: None,
|
||||
objective: None,
|
||||
|
||||
navigation_bounds: None,
|
||||
|
||||
navigation_obstacle: None,
|
||||
|
||||
navigation_area: None,
|
||||
|
||||
navigation_link: None,
|
||||
hierarchy_sibling_index: 0,
|
||||
editor_visibility: EditorVisibility::default(),
|
||||
inspector_order: None,
|
||||
component_states: None,
|
||||
children: Vec::new(),
|
||||
};
|
||||
Some(spawn_with_history(world, snapshot))
|
||||
@ -1118,6 +1290,7 @@ fn apply_static_mesh_placement_mode(
|
||||
primitive: None,
|
||||
brush: None,
|
||||
static_mesh_renderer: Some(shared::StaticMeshRenderer::single(entry)),
|
||||
skinned_mesh_renderer: None,
|
||||
material: None,
|
||||
material_override: None,
|
||||
rigid_body: collider.as_ref().map(|_| RigidBodyDesc::default()),
|
||||
@ -1136,8 +1309,18 @@ fn apply_static_mesh_placement_mode(
|
||||
post_process_volume: None,
|
||||
team_spawn: None,
|
||||
objective: None,
|
||||
|
||||
navigation_bounds: None,
|
||||
|
||||
navigation_obstacle: None,
|
||||
|
||||
navigation_area: None,
|
||||
|
||||
navigation_link: None,
|
||||
hierarchy_sibling_index: index as i32,
|
||||
editor_visibility: EditorVisibility::default(),
|
||||
inspector_order: None,
|
||||
component_states: None,
|
||||
children: Vec::new(),
|
||||
}
|
||||
})
|
||||
@ -1146,6 +1329,35 @@ fn apply_static_mesh_placement_mode(
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn apply_skinned_mesh_placement_mode(snapshot: &mut EditorEntitySnapshot) {
|
||||
apply_skinned_mesh_placement_mode_with_materials(
|
||||
snapshot,
|
||||
shared::RendererMaterialSet::default(),
|
||||
);
|
||||
}
|
||||
|
||||
fn apply_skinned_mesh_placement_mode_with_materials(
|
||||
snapshot: &mut EditorEntitySnapshot,
|
||||
materials: shared::RendererMaterialSet,
|
||||
) {
|
||||
let Some(model) = snapshot.model.take() else {
|
||||
return;
|
||||
};
|
||||
snapshot.actor_kind = ActorKind::SkinnedMesh;
|
||||
snapshot.static_mesh_renderer = None;
|
||||
snapshot.skinned_mesh_renderer = Some(shared::SkinnedMeshRenderer {
|
||||
asset_id: model.asset_id,
|
||||
path: model.path,
|
||||
scene_index: model.scene_index,
|
||||
materials,
|
||||
});
|
||||
// Triangle-mesh colliders are a static-geometry concern. Animated collision must be authored
|
||||
// explicitly (capsules, hitboxes, or another gameplay-specific representation).
|
||||
snapshot.rigid_body = None;
|
||||
snapshot.collider = None;
|
||||
}
|
||||
|
||||
fn static_mesh_collider_for_renderer(renderer: &shared::StaticMeshRenderer) -> ColliderDesc {
|
||||
ColliderDesc::static_mesh(
|
||||
renderer
|
||||
@ -1177,14 +1389,26 @@ fn part_effective_material_id_for_selection(
|
||||
fn static_mesh_renderer_for_asset(
|
||||
world: &mut World,
|
||||
path: &str,
|
||||
) -> Option<shared::StaticMeshRenderer> {
|
||||
) -> Option<(shared::StaticMeshRenderer, bool)> {
|
||||
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 super::refresh_model_artifacts(record) {
|
||||
Ok(manifest) => {
|
||||
renderer = Some(renderer_from_manifest(&manifest, &record.import_settings));
|
||||
let requires_skinned_renderer = manifest.metadata.animation_count > 0
|
||||
|| manifest.metadata.skin_count > 0
|
||||
|| manifest.parts.iter().any(|part| part.skinned);
|
||||
let mut authored_renderer =
|
||||
renderer_from_manifest(&manifest, &record.import_settings);
|
||||
if requires_skinned_renderer {
|
||||
authored_renderer.materials =
|
||||
crate::assets::static_mesh::renderer_materials_from_manifest(
|
||||
&manifest,
|
||||
&record.import_settings,
|
||||
);
|
||||
}
|
||||
renderer = Some((authored_renderer, requires_skinned_renderer));
|
||||
registry.index_dirty = true;
|
||||
}
|
||||
Err(error) => {
|
||||
@ -1207,7 +1431,17 @@ fn static_mesh_renderer_for_asset(
|
||||
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))
|
||||
let requires_skinned_renderer = manifest.metadata.animation_count > 0
|
||||
|| manifest.metadata.skin_count > 0
|
||||
|| manifest.parts.iter().any(|part| part.skinned);
|
||||
let mut renderer = renderer_from_manifest(&manifest, &record.import_settings);
|
||||
if requires_skinned_renderer {
|
||||
renderer.materials = crate::assets::static_mesh::renderer_materials_from_manifest(
|
||||
&manifest,
|
||||
&record.import_settings,
|
||||
);
|
||||
}
|
||||
Some((renderer, requires_skinned_renderer))
|
||||
}
|
||||
|
||||
pub fn import_external_assets(paths: &[PathBuf]) -> Result<usize, String> {
|
||||
@ -1348,4 +1582,106 @@ mod tests {
|
||||
);
|
||||
assert!(world.resource::<crate::history::EditorHistory>().can_undo());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skinned_model_placement_replaces_static_and_generic_model_renderers() {
|
||||
let asset = EditorAsset {
|
||||
label: "Hero".into(),
|
||||
path: Some("assets/models/hero.glb".into()),
|
||||
folder_path: "assets/models".into(),
|
||||
kind: EditorAssetKind::Model,
|
||||
};
|
||||
let mut snapshot = snapshot_for_asset(&asset, Vec3::ZERO).unwrap();
|
||||
snapshot.model.as_mut().unwrap().asset_id = "hero-id".into();
|
||||
snapshot.static_mesh_renderer = Some(shared::StaticMeshRenderer::default());
|
||||
snapshot.rigid_body = Some(RigidBodyDesc::default());
|
||||
snapshot.collider = Some(ColliderDesc::static_cuboid(Vec3::ONE));
|
||||
|
||||
apply_skinned_mesh_placement_mode(&mut snapshot);
|
||||
|
||||
assert_eq!(snapshot.actor_kind, ActorKind::SkinnedMesh);
|
||||
assert!(snapshot.model.is_none());
|
||||
assert!(snapshot.static_mesh_renderer.is_none());
|
||||
assert!(snapshot.rigid_body.is_none());
|
||||
assert!(snapshot.collider.is_none());
|
||||
assert_eq!(
|
||||
snapshot.skinned_mesh_renderer,
|
||||
Some(
|
||||
shared::SkinnedMeshRenderer::new("assets/models/hero.glb").with_asset_id("hero-id")
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_default_clip_builds_one_explicit_controller_state() {
|
||||
let key = Uuid::new_v4();
|
||||
let manifest_path =
|
||||
std::env::temp_dir().join(format!("blacksite-default-animation-{key}.animation.ron"));
|
||||
let asset_id = key.to_string();
|
||||
let signature = shared::AnimationSkeletonSignature::new("rig-signature");
|
||||
let clip_id = shared::animation_clip_sub_asset_id(8, "Standing");
|
||||
let manifest = shared::AnimationManifest {
|
||||
schema_version: shared::ANIMATION_MANIFEST_SCHEMA_VERSION,
|
||||
asset_id: asset_id.clone(),
|
||||
label: "Robot".into(),
|
||||
default_animation_clip_id: Some(clip_id.clone()),
|
||||
source: shared::AnimationManifestSource {
|
||||
path: "assets/models/robot.glb".into(),
|
||||
format: "glb".into(),
|
||||
fingerprint: shared::AnimationSourceFingerprint {
|
||||
byte_len: 1,
|
||||
modified_unix_secs: 0,
|
||||
content_hash: "fixture".into(),
|
||||
},
|
||||
dependencies: Vec::new(),
|
||||
},
|
||||
runtime_supported: true,
|
||||
skeletons: vec![shared::AnimationSkeletonRecord {
|
||||
id: shared::animation_skeleton_sub_asset_id(0, "Rig"),
|
||||
label: "Rig".into(),
|
||||
source_index: 0,
|
||||
signature: signature.clone(),
|
||||
joint_paths: vec!["Root".into()],
|
||||
}],
|
||||
clips: vec![shared::AnimationClipRecord {
|
||||
id: clip_id.clone(),
|
||||
label: "Standing".into(),
|
||||
source_index: 8,
|
||||
duration_seconds: 0.4,
|
||||
target_skeleton_signature: Some(signature),
|
||||
events: Vec::new(),
|
||||
}],
|
||||
diagnostics: Vec::new(),
|
||||
};
|
||||
std::fs::write(
|
||||
&manifest_path,
|
||||
ron::ser::to_string_pretty(&manifest, ron::ser::PrettyConfig::default()).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
let record = AssetRecord {
|
||||
id: AssetId(key),
|
||||
path: "assets/models/robot.glb".into(),
|
||||
label: "Robot".into(),
|
||||
kind_tag: "Model".into(),
|
||||
import_settings: crate::asset_db::ImportSettings {
|
||||
animation_manifest_path: Some(manifest_path.to_string_lossy().into_owned()),
|
||||
default_animation_clip_id: Some(clip_id.clone()),
|
||||
..Default::default()
|
||||
},
|
||||
dependencies: Vec::new(),
|
||||
};
|
||||
|
||||
let controller = default_animation_controller_for_record(&record)
|
||||
.unwrap()
|
||||
.expect("explicit default should produce a controller");
|
||||
assert_eq!(controller.default_state, "standing");
|
||||
assert_eq!(controller.states.len(), 1);
|
||||
assert_eq!(controller.states[0].clip.sub_asset_id, clip_id);
|
||||
assert_eq!(
|
||||
controller.skeleton.unwrap().sub_asset_id,
|
||||
shared::animation_skeleton_sub_asset_id(0, "Rig")
|
||||
);
|
||||
|
||||
std::fs::remove_file(manifest_path).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,10 +2,13 @@
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy_egui::egui;
|
||||
use shared::{LevelObject, MaterialAsset, MaterialDesc};
|
||||
use shared::{
|
||||
ComponentInstanceId, LevelObject, MaterialAsset, MaterialDesc, MaterialRef,
|
||||
RendererMaterialSlot,
|
||||
};
|
||||
|
||||
use crate::assets::{EditorAsset, EditorAssetKind, EditorAssets};
|
||||
use crate::history::set_material_group_with_history;
|
||||
use crate::history::{reflected_component_transaction, set_material_group_with_history};
|
||||
use crate::ui::inspector::property_row;
|
||||
|
||||
pub fn load_material_asset(catalog_path: &str) -> Result<MaterialAsset, String> {
|
||||
@ -28,16 +31,103 @@ pub fn apply_material_asset_to_selection(
|
||||
.path
|
||||
.clone()
|
||||
.ok_or_else(|| format!("Material has no source path: {}", asset.label))?;
|
||||
let desc = material_desc_from_asset(&path)?;
|
||||
let changes = crate::ui::helpers::selected_level_entities(world, selected)
|
||||
.into_iter()
|
||||
.filter(|entity| world.get::<LevelObject>(*entity).is_some())
|
||||
.map(|entity| (entity, desc.clone()))
|
||||
.collect::<Vec<_>>();
|
||||
set_material_group_with_history(world, changes);
|
||||
world.resource_mut::<crate::scene_io::SceneIo>().status =
|
||||
format!("Applied material {}", asset.label);
|
||||
let record = world
|
||||
.get_resource::<crate::asset_db::AssetRegistry>()
|
||||
.and_then(|registry| crate::asset_db::find_asset_by_path(registry, &path))
|
||||
.ok_or_else(|| format!("Material is missing from the asset registry: {path}"))?;
|
||||
let sub_asset_id = if shared::MaterialInstanceAsset::load_from_path(&path).is_ok() {
|
||||
"material:instance"
|
||||
} else {
|
||||
"material:source"
|
||||
};
|
||||
let material_ref = MaterialRef::new(
|
||||
shared::EditorAssetRef::new(record.id.as_string(), sub_asset_id, asset.label.clone())
|
||||
.with_source_path(path.clone()),
|
||||
);
|
||||
let desc = material_desc_from_asset(&path).ok();
|
||||
let entities = crate::ui::helpers::selected_level_entities(world, selected);
|
||||
let mut renderer_changes = 0usize;
|
||||
let mut legacy_changes = Vec::new();
|
||||
for entity in entities {
|
||||
if world.get::<LevelObject>(entity).is_none() {
|
||||
continue;
|
||||
}
|
||||
if let Some(mut renderer) = world.get::<shared::StaticMeshRenderer>(entity).cloned() {
|
||||
ensure_static_material_slots(&mut renderer);
|
||||
for slot in &mut renderer.materials.slots {
|
||||
slot.material = Some(material_ref.clone());
|
||||
}
|
||||
reflected_component_transaction(
|
||||
world,
|
||||
entity,
|
||||
"Assign Material Slots",
|
||||
shared::AUTHORING_COMPONENT_STATIC_MESH_RENDERER,
|
||||
shared::COMPONENT_STATIC_MESH_RENDERER,
|
||||
move |world, entity| {
|
||||
world.entity_mut(entity).insert(renderer);
|
||||
Ok(())
|
||||
},
|
||||
)?;
|
||||
renderer_changes += 1;
|
||||
continue;
|
||||
}
|
||||
if let Some(mut renderer) = world.get::<shared::SkinnedMeshRenderer>(entity).cloned() {
|
||||
if renderer.materials.slots.is_empty() {
|
||||
return Err(format!(
|
||||
"Skinned renderer `{}` has no imported material slots; reimport its model",
|
||||
renderer.path
|
||||
));
|
||||
}
|
||||
for slot in &mut renderer.materials.slots {
|
||||
slot.material = Some(material_ref.clone());
|
||||
}
|
||||
reflected_component_transaction(
|
||||
world,
|
||||
entity,
|
||||
"Assign Material Slots",
|
||||
shared::AUTHORING_COMPONENT_SKINNED_MESH_RENDERER,
|
||||
shared::COMPONENT_SKINNED_MESH_RENDERER,
|
||||
move |world, entity| {
|
||||
world.entity_mut(entity).insert(renderer);
|
||||
Ok(())
|
||||
},
|
||||
)?;
|
||||
renderer_changes += 1;
|
||||
continue;
|
||||
}
|
||||
if let Some(desc) = desc.as_ref() {
|
||||
legacy_changes.push((entity, desc.clone()));
|
||||
}
|
||||
}
|
||||
if !legacy_changes.is_empty() {
|
||||
set_material_group_with_history(world, legacy_changes);
|
||||
}
|
||||
if renderer_changes == 0 && desc.is_none() {
|
||||
return Err("No selected renderable actor accepts this material".into());
|
||||
}
|
||||
world.resource_mut::<crate::scene_io::SceneIo>().status =
|
||||
format!("Applied shared material {}", asset.label);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_static_material_slots(renderer: &mut shared::StaticMeshRenderer) {
|
||||
for part in &mut renderer.slots {
|
||||
if part.material_slot_id.0.trim().is_empty() {
|
||||
part.material_slot_id = if part.id.0.trim().is_empty() {
|
||||
ComponentInstanceId::new("slot:legacy")
|
||||
} else {
|
||||
ComponentInstanceId::new(format!("slot:{}", part.id.0))
|
||||
};
|
||||
}
|
||||
if renderer.materials.slot(&part.material_slot_id).is_none() {
|
||||
renderer.materials.slots.push(RendererMaterialSlot {
|
||||
id: part.material_slot_id.clone(),
|
||||
name: part.name.clone(),
|
||||
source_material: part.material.clone().map(MaterialRef::new),
|
||||
material: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn material_asset_picker_ui(
|
||||
|
||||
@ -119,10 +119,10 @@ pub fn assign_animation_clip_operator(
|
||||
selection: AssetSelection,
|
||||
entity: Entity,
|
||||
) -> bool {
|
||||
let availability = if world.get::<shared::ModelRef>(entity).is_some() {
|
||||
let availability = if world.get::<shared::SkinnedMeshRenderer>(entity).is_some() {
|
||||
OperatorAvailability::Ready
|
||||
} else {
|
||||
OperatorAvailability::Disabled("Select an authored imported-model actor".to_string())
|
||||
OperatorAvailability::Disabled("Select an authored skinned-mesh actor".to_string())
|
||||
};
|
||||
let label = format!("Assign Animation Clip {}", selection.display_label());
|
||||
run_immediate_operator(
|
||||
@ -133,9 +133,9 @@ pub fn assign_animation_clip_operator(
|
||||
move |world| {
|
||||
let authored = animation_clip_authoring_data(world, &selection)?;
|
||||
let model = world
|
||||
.get::<shared::ModelRef>(entity)
|
||||
.get::<shared::SkinnedMeshRenderer>(entity)
|
||||
.cloned()
|
||||
.ok_or_else(|| "Selected actor has no ModelRef".to_string())?;
|
||||
.ok_or_else(|| "Selected actor has no SkinnedMeshRenderer".to_string())?;
|
||||
let mut controller = world
|
||||
.get::<shared::AnimationControllerDesc>(entity)
|
||||
.cloned()
|
||||
@ -197,7 +197,7 @@ mod tests {
|
||||
animation_clip_sub_asset_id, animation_skeleton_sub_asset_id, ActorKind,
|
||||
AnimationClipRecord, AnimationControllerDesc, AnimationManifest, AnimationManifestSource,
|
||||
AnimationSkeletonRecord, AnimationSkeletonSignature, AnimationSourceFingerprint,
|
||||
AudioSourceDesc, LevelObject, MaterialDesc, ModelRef, PrimitiveShape,
|
||||
AudioSourceDesc, LevelObject, MaterialDesc, PrimitiveShape, SkinnedMeshRenderer,
|
||||
ANIMATION_MANIFEST_SCHEMA_VERSION,
|
||||
};
|
||||
|
||||
@ -222,6 +222,7 @@ mod tests {
|
||||
schema_version: ANIMATION_MANIFEST_SCHEMA_VERSION,
|
||||
asset_id: asset_id.as_string(),
|
||||
label: "Operator Fixture".into(),
|
||||
default_animation_clip_id: None,
|
||||
source: AnimationManifestSource {
|
||||
path: model_path.clone(),
|
||||
format: "glb".into(),
|
||||
@ -301,6 +302,43 @@ mod tests {
|
||||
assert_undo_redo_round_trip(&mut world, 0, 1, authored_count);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn animation_clip_placement_creates_only_the_skinned_renderer_path() {
|
||||
let mut world = World::new();
|
||||
world.init_resource::<ActiveOperator>();
|
||||
world.init_resource::<EditorHistory>();
|
||||
world.init_resource::<SceneIo>();
|
||||
world.init_resource::<SelectedEntity>();
|
||||
let (manifest_path, record, selection) = animation_fixture();
|
||||
world.insert_resource(AssetRegistry {
|
||||
records: vec![record],
|
||||
index_dirty: false,
|
||||
});
|
||||
let harness = OperatorInvariantHarness::capture(&mut world);
|
||||
|
||||
assert!(place_subasset_operator(
|
||||
&mut world,
|
||||
selection,
|
||||
Vec3::new(1.0, 2.0, 3.0),
|
||||
));
|
||||
|
||||
let entity = world
|
||||
.query_filtered::<Entity, With<LevelObject>>()
|
||||
.single(&world)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
world.get::<ActorKind>(entity),
|
||||
Some(&ActorKind::SkinnedMesh)
|
||||
);
|
||||
assert!(world.get::<SkinnedMeshRenderer>(entity).is_some());
|
||||
assert!(world.get::<AnimationControllerDesc>(entity).is_some());
|
||||
assert!(world.get::<shared::ModelRef>(entity).is_none());
|
||||
assert!(world.get::<shared::StaticMeshRenderer>(entity).is_none());
|
||||
harness.assert_committed(&mut world, 1, 1);
|
||||
assert_undo_redo_round_trip(&mut world, 0, 1, authored_count);
|
||||
let _ = std::fs::remove_file(manifest_path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_clip_assignment_is_one_undoable_operator() {
|
||||
let mut world = World::new();
|
||||
@ -363,8 +401,8 @@ mod tests {
|
||||
let entity = world
|
||||
.spawn((
|
||||
LevelObject,
|
||||
ActorKind::ImportedModel,
|
||||
ModelRef::new(model_path),
|
||||
ActorKind::SkinnedMesh,
|
||||
SkinnedMeshRenderer::new(model_path),
|
||||
))
|
||||
.id();
|
||||
let harness = OperatorInvariantHarness::capture(&mut world);
|
||||
|
||||
@ -14,9 +14,12 @@ use serde::{Deserialize, Serialize};
|
||||
use crate::asset_db::{
|
||||
AssetRecord, ImportSettings, MaterialImportPolicy, ModelHierarchyMode, ModelPlacementMode,
|
||||
};
|
||||
use shared::{ComponentInstanceId, EditorAssetRef, StaticMeshRenderer, StaticMeshRendererEntry};
|
||||
use shared::{
|
||||
ComponentInstanceId, EditorAssetRef, MaterialRef, RendererMaterialSet, RendererMaterialSlot,
|
||||
StaticMeshRenderer, StaticMeshRendererEntry,
|
||||
};
|
||||
|
||||
pub const STATIC_MESH_MANIFEST_SCHEMA: u32 = 1;
|
||||
pub const STATIC_MESH_MANIFEST_SCHEMA: u32 = 3;
|
||||
pub const STATIC_MESH_ARTIFACT_DIR: &str = "assets/meshes/generated";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
@ -80,6 +83,10 @@ pub struct StaticMeshPart {
|
||||
pub source_node: Option<String>,
|
||||
pub source_mesh: Option<String>,
|
||||
pub source_material: Option<String>,
|
||||
/// Whether the source primitive is bound to a skin and therefore must never become a static
|
||||
/// renderer slot.
|
||||
#[serde(default)]
|
||||
pub skinned: bool,
|
||||
}
|
||||
|
||||
pub fn static_mesh_manifest_path(asset_id: &str) -> String {
|
||||
@ -94,6 +101,17 @@ pub fn material_id_from_label(label: &str) -> String {
|
||||
format!("material:{}", stable_sub_asset_slug(label))
|
||||
}
|
||||
|
||||
fn gltf_draw_id(node_index: Option<usize>, mesh_index: usize, primitive_index: usize) -> String {
|
||||
let node = node_index
|
||||
.map(|index| index.to_string())
|
||||
.unwrap_or_else(|| "unbound".into());
|
||||
format!("draw:scene0:node{node}:mesh{mesh_index}:primitive{primitive_index}")
|
||||
}
|
||||
|
||||
fn fbx_draw_id(node_index: usize, material_index: usize) -> String {
|
||||
format!("draw:scene0:node{node_index}:material{material_index}")
|
||||
}
|
||||
|
||||
fn stable_sub_asset_slug(label: &str) -> String {
|
||||
let mut slug = String::new();
|
||||
for ch in label.chars() {
|
||||
@ -151,10 +169,10 @@ pub fn renderer_from_manifest(
|
||||
MaterialImportPolicy::SourceMaterials
|
||||
);
|
||||
|
||||
StaticMeshRenderer {
|
||||
slots: manifest
|
||||
let parts: Vec<_> = manifest
|
||||
.parts
|
||||
.iter()
|
||||
.filter(|part| !part.skinned && manifest.metadata.animation_count == 0)
|
||||
.map(|part| StaticMeshRendererEntry {
|
||||
id: ComponentInstanceId::new(part_effective_id(part)),
|
||||
name: part.name.clone(),
|
||||
@ -163,6 +181,7 @@ pub fn renderer_from_manifest(
|
||||
part_effective_id(part),
|
||||
part.name.clone(),
|
||||
),
|
||||
material_slot_id: ComponentInstanceId::new(material_slot_id(part)),
|
||||
material: use_source_materials
|
||||
.then(|| {
|
||||
part_effective_material_id(part).map(|id| {
|
||||
@ -179,10 +198,77 @@ pub fn renderer_from_manifest(
|
||||
cast_shadows: true,
|
||||
receive_shadows: true,
|
||||
})
|
||||
.collect();
|
||||
let materials = RendererMaterialSet {
|
||||
slots: manifest
|
||||
.parts
|
||||
.iter()
|
||||
.filter(|part| !part.skinned && manifest.metadata.animation_count == 0)
|
||||
.map(|part| RendererMaterialSlot {
|
||||
id: ComponentInstanceId::new(material_slot_id(part)),
|
||||
name: part.material_slot_name.clone(),
|
||||
source_material: use_source_materials
|
||||
.then(|| {
|
||||
part_effective_material_id(part).map(|id| {
|
||||
MaterialRef::new(EditorAssetRef::new(
|
||||
manifest.asset_id.clone(),
|
||||
id,
|
||||
part.material_slot_name.clone(),
|
||||
))
|
||||
})
|
||||
})
|
||||
.flatten(),
|
||||
material: None,
|
||||
})
|
||||
.collect(),
|
||||
orphaned_assignments: Vec::new(),
|
||||
};
|
||||
|
||||
StaticMeshRenderer {
|
||||
slots: parts,
|
||||
materials,
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the shared material slots for a full imported hierarchy. Unlike static renderer
|
||||
/// construction this intentionally includes skin-bound and rigid animated draw bindings.
|
||||
pub fn renderer_materials_from_manifest(
|
||||
manifest: &StaticMeshManifest,
|
||||
settings: &ImportSettings,
|
||||
) -> RendererMaterialSet {
|
||||
let use_source_materials = matches!(
|
||||
settings.material_policy,
|
||||
MaterialImportPolicy::SourceMaterials
|
||||
);
|
||||
RendererMaterialSet {
|
||||
slots: manifest
|
||||
.parts
|
||||
.iter()
|
||||
.map(|part| RendererMaterialSlot {
|
||||
id: ComponentInstanceId::new(material_slot_id(part)),
|
||||
name: part.material_slot_name.clone(),
|
||||
source_material: use_source_materials
|
||||
.then(|| {
|
||||
part_effective_material_id(part).map(|id| {
|
||||
MaterialRef::new(EditorAssetRef::new(
|
||||
manifest.asset_id.clone(),
|
||||
id,
|
||||
part.material_slot_name.clone(),
|
||||
))
|
||||
})
|
||||
})
|
||||
.flatten(),
|
||||
material: None,
|
||||
})
|
||||
.collect(),
|
||||
orphaned_assignments: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn material_slot_id(part: &StaticMeshPart) -> String {
|
||||
format!("slot:{}", part_effective_id(part))
|
||||
}
|
||||
|
||||
fn part_effective_id(part: &StaticMeshPart) -> String {
|
||||
if part.id.trim().is_empty() {
|
||||
part_id_from_label(&part.mesh_label)
|
||||
@ -255,23 +341,23 @@ fn build_gltf_manifest(
|
||||
.or_else(|| gltf.document.scenes().next())
|
||||
{
|
||||
for node in scene.nodes() {
|
||||
collect_gltf_node_parts(node, Mat4::IDENTITY, &mut parts);
|
||||
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);
|
||||
collect_gltf_mesh_parts(None, None, None, mesh, Mat4::IDENTITY, false, &mut parts);
|
||||
}
|
||||
}
|
||||
|
||||
if gltf.document.animations().count() > 0 {
|
||||
warnings.push(
|
||||
"Animations are recorded as metadata; use Scene Instance placement for playback."
|
||||
"Animations are recorded as metadata; animated placement uses SkinnedMeshRenderer."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
if gltf.document.skins().count() > 0 {
|
||||
warnings.push(
|
||||
"Skins are recorded as metadata; skinned playback stays on the Scene Instance path."
|
||||
"Skinned primitives are excluded from StaticMeshRenderer and use SkinnedMeshRenderer."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
@ -306,29 +392,44 @@ fn build_gltf_manifest(
|
||||
fn collect_gltf_node_parts(
|
||||
node: gltf::Node<'_>,
|
||||
parent_transform: Mat4,
|
||||
parent_path: &str,
|
||||
parts: &mut Vec<StaticMeshPart>,
|
||||
) {
|
||||
let local = Mat4::from_cols_array_2d(&node.transform().matrix());
|
||||
let world_transform = parent_transform * local;
|
||||
let segment = node
|
||||
.name()
|
||||
.map(str::to_string)
|
||||
.unwrap_or_else(|| format!("Node{}", node.index()));
|
||||
let node_path = if parent_path.is_empty() {
|
||||
segment
|
||||
} else {
|
||||
format!("{parent_path}/{segment}")
|
||||
};
|
||||
let skinned = node.skin().is_some();
|
||||
if let Some(mesh) = node.mesh() {
|
||||
collect_gltf_mesh_parts(
|
||||
node.name().map(str::to_string),
|
||||
Some(node.index()),
|
||||
Some(node_path.clone()),
|
||||
mesh,
|
||||
world_transform,
|
||||
skinned,
|
||||
parts,
|
||||
);
|
||||
}
|
||||
for child in node.children() {
|
||||
collect_gltf_node_parts(child, world_transform, parts);
|
||||
collect_gltf_node_parts(child, world_transform, &node_path, parts);
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_gltf_mesh_parts(
|
||||
node_name: Option<String>,
|
||||
node_index: Option<usize>,
|
||||
node_path: Option<String>,
|
||||
mesh: gltf::Mesh<'_>,
|
||||
transform: Mat4,
|
||||
skinned: bool,
|
||||
parts: &mut Vec<StaticMeshPart>,
|
||||
) {
|
||||
let mesh_index = mesh.index();
|
||||
@ -359,9 +460,11 @@ fn collect_gltf_mesh_parts(
|
||||
.clone()
|
||||
.or_else(|| mesh_name.clone())
|
||||
.unwrap_or_else(|| format!("Mesh {mesh_index}"));
|
||||
let source_node = node_index.map(|index| format!("Node{index}"));
|
||||
let source_node = node_path
|
||||
.clone()
|
||||
.or_else(|| node_index.map(|index| format!("Node{index}")));
|
||||
parts.push(StaticMeshPart {
|
||||
id: part_id_from_label(&mesh_label),
|
||||
id: gltf_draw_id(node_index, mesh_index, primitive_index),
|
||||
name: format!("{name} / Primitive {primitive_index}"),
|
||||
material_id: material_label
|
||||
.as_ref()
|
||||
@ -373,6 +476,7 @@ fn collect_gltf_mesh_parts(
|
||||
source_node,
|
||||
source_mesh: Some(format!("Mesh{mesh_index}")),
|
||||
source_material: Some(material_name),
|
||||
skinned,
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -405,6 +509,7 @@ fn build_fbx_manifest(
|
||||
if mesh.num_vertices == 0 || mesh.faces.as_ref().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let skinned = !mesh.skin_deformers.as_ref().is_empty();
|
||||
|
||||
let mut groups: Vec<(usize, Vec<u32>)> =
|
||||
group_faces_by_material(mesh).into_iter().collect();
|
||||
@ -428,7 +533,7 @@ fn build_fbx_manifest(
|
||||
};
|
||||
let mesh_label = FbxAssetLabel::Mesh(node_index * 1000 + material_index).to_string();
|
||||
parts.push(StaticMeshPart {
|
||||
id: part_id_from_label(&mesh_label),
|
||||
id: fbx_draw_id(node_index, material_index),
|
||||
name: format!("{node_name} / Material {material_index}"),
|
||||
material_id: material_label
|
||||
.as_ref()
|
||||
@ -440,19 +545,20 @@ fn build_fbx_manifest(
|
||||
source_node: Some(format!("Node{node_index}")),
|
||||
source_mesh: Some(format!("Mesh{node_index}")),
|
||||
source_material: Some(material_name),
|
||||
skinned,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if !scene.anim_stacks.as_ref().is_empty() {
|
||||
warnings.push(
|
||||
"Animations are recorded as metadata; use Scene Instance placement for playback."
|
||||
"Animations are recorded as metadata; animated placement uses SkinnedMeshRenderer."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
if !scene.skin_deformers.as_ref().is_empty() {
|
||||
warnings.push(
|
||||
"Skins are recorded as metadata; skinned playback stays on the Scene Instance path."
|
||||
"Skinned primitives are excluded from StaticMeshRenderer and use SkinnedMeshRenderer."
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
@ -554,6 +660,7 @@ fn resolve_dependency(source_path: &str, uri: &str) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::asset_db::AssetId;
|
||||
|
||||
fn test_manifest() -> StaticMeshManifest {
|
||||
StaticMeshManifest {
|
||||
@ -597,6 +704,7 @@ mod tests {
|
||||
source_node: Some("Node0".into()),
|
||||
source_mesh: Some("Mesh0".into()),
|
||||
source_material: Some("Wood".into()),
|
||||
skinned: false,
|
||||
}],
|
||||
warnings: Vec::new(),
|
||||
}
|
||||
@ -645,4 +753,48 @@ mod tests {
|
||||
|
||||
assert!(renderer.slots[0].material.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renderer_from_manifest_excludes_skinned_primitives() {
|
||||
let mut manifest = test_manifest();
|
||||
manifest.parts[0].skinned = true;
|
||||
|
||||
let renderer = renderer_from_manifest(&manifest, &ImportSettings::default());
|
||||
|
||||
assert!(renderer.slots.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renderer_from_manifest_excludes_node_animated_geometry() {
|
||||
let mut manifest = test_manifest();
|
||||
manifest.metadata.animation_count = 1;
|
||||
|
||||
let renderer = renderer_from_manifest(&manifest, &ImportSettings::default());
|
||||
|
||||
assert!(renderer.slots.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn committed_rigged_fixture_never_builds_static_renderer_slots() {
|
||||
let path = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../../assets/models/robot_expressive.glb")
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
let record = AssetRecord {
|
||||
id: AssetId::new(),
|
||||
path,
|
||||
label: "Robot Expressive".into(),
|
||||
kind_tag: "Model".into(),
|
||||
import_settings: ImportSettings::default(),
|
||||
dependencies: Vec::new(),
|
||||
};
|
||||
|
||||
let manifest = build_static_mesh_manifest(&record).unwrap();
|
||||
let renderer = renderer_from_manifest(&manifest, &record.import_settings);
|
||||
|
||||
assert!(manifest.metadata.skin_count > 0);
|
||||
assert!(manifest.metadata.animation_count > 0);
|
||||
assert!(manifest.parts.iter().any(|part| part.skinned));
|
||||
assert!(renderer.slots.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@ -21,6 +21,10 @@ use crate::infra::EditorOnly;
|
||||
|
||||
pub(crate) const THUMB_SIZE: u32 = 128;
|
||||
pub(crate) const THUMBNAIL_LAYER: usize = 31;
|
||||
const THUMBNAIL_VERTICAL_FOV: f32 = 40.0_f32.to_radians();
|
||||
const THUMBNAIL_HALF_VERTICAL_FOV: f32 = THUMBNAIL_VERTICAL_FOV * 0.5;
|
||||
const SCENE_FRAME_PADDING: f32 = 1.35;
|
||||
const MATERIAL_SPHERE_FRAME_PADDING: f32 = 1.28;
|
||||
const MESH_WARMUP_FRAMES: u8 = 12;
|
||||
const POST_ATTACH_FRAMES: u8 = 8;
|
||||
const RENDER_FRAMES: u8 = 3;
|
||||
@ -61,6 +65,25 @@ struct ActiveModelThumbnail {
|
||||
framed: bool,
|
||||
frames_remaining: u8,
|
||||
wait_frames: u32,
|
||||
framing: ThumbnailFraming,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum ThumbnailFraming {
|
||||
SceneBounds,
|
||||
MaterialSphere,
|
||||
}
|
||||
|
||||
impl ThumbnailFraming {
|
||||
fn for_source(source: &ThumbnailJobSource) -> Self {
|
||||
match source {
|
||||
ThumbnailJobSource::SourceMaterial { .. }
|
||||
| ThumbnailJobSource::MaterialAsset { .. } => Self::MaterialSphere,
|
||||
ThumbnailJobSource::Model { .. } | ThumbnailJobSource::MeshSubAsset { .. } => {
|
||||
Self::SceneBounds
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ThumbnailStudioPlugin;
|
||||
@ -143,7 +166,7 @@ fn setup_thumbnail_studio(mut commands: Commands, mut images: ResMut<Assets<Imag
|
||||
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(),
|
||||
fov: THUMBNAIL_VERTICAL_FOV,
|
||||
..default()
|
||||
}),
|
||||
))
|
||||
@ -229,6 +252,7 @@ fn process_thumbnail_studio(
|
||||
&mesh3d,
|
||||
&mesh_storage,
|
||||
&mut camera_transforms,
|
||||
active.framing,
|
||||
) {
|
||||
active.framed = true;
|
||||
active.post_attach_frames = 0;
|
||||
@ -322,6 +346,7 @@ fn process_thumbnail_studio(
|
||||
.id();
|
||||
|
||||
let label = job.source.label().to_string();
|
||||
let framing = ThumbnailFraming::for_source(&job.source);
|
||||
let content_spawned = spawn_thumbnail_job_content(
|
||||
&mut commands,
|
||||
&mut mesh_storage,
|
||||
@ -350,6 +375,7 @@ fn process_thumbnail_studio(
|
||||
framed: false,
|
||||
frames_remaining: 0,
|
||||
wait_frames: 0,
|
||||
framing,
|
||||
},
|
||||
"no renderable meshes",
|
||||
true,
|
||||
@ -369,6 +395,7 @@ fn process_thumbnail_studio(
|
||||
framed: false,
|
||||
frames_remaining: 0,
|
||||
wait_frames: 0,
|
||||
framing,
|
||||
});
|
||||
}
|
||||
|
||||
@ -588,6 +615,7 @@ fn frame_thumbnail_scene(
|
||||
meshes: &Query<&Mesh3d>,
|
||||
mesh_assets: &Assets<Mesh>,
|
||||
camera_transforms: &mut Query<&mut Transform, With<ThumbnailStudioCamera>>,
|
||||
framing: ThumbnailFraming,
|
||||
) -> bool {
|
||||
let mut entities = Vec::new();
|
||||
collect_descendants(root, children, &mut entities);
|
||||
@ -633,8 +661,7 @@ fn frame_thumbnail_scene(
|
||||
}
|
||||
|
||||
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 distance = thumbnail_camera_distance(min, max, framing);
|
||||
let eye = center + Vec3::new(1.0, 0.75, 1.0).normalize() * distance;
|
||||
|
||||
if let Ok(mut transform) = camera_transforms.single_mut() {
|
||||
@ -644,6 +671,20 @@ fn frame_thumbnail_scene(
|
||||
true
|
||||
}
|
||||
|
||||
fn thumbnail_camera_distance(min: Vec3, max: Vec3, framing: ThumbnailFraming) -> f32 {
|
||||
let bounds_size = max - min;
|
||||
let (radius, padding) = match framing {
|
||||
ThumbnailFraming::SceneBounds => {
|
||||
(bounds_size.length().max(0.25) * 0.5, SCENE_FRAME_PADDING)
|
||||
}
|
||||
ThumbnailFraming::MaterialSphere => (
|
||||
bounds_size.max_element().max(0.5) * 0.5,
|
||||
MATERIAL_SPHERE_FRAME_PADDING,
|
||||
),
|
||||
};
|
||||
radius / THUMBNAIL_HALF_VERTICAL_FOV.tan() * padding
|
||||
}
|
||||
|
||||
fn collect_descendants(entity: Entity, children: &Query<&Children>, out: &mut Vec<Entity>) {
|
||||
out.push(entity);
|
||||
let Ok(child_list) = children.get(entity) else {
|
||||
@ -673,3 +714,24 @@ fn aabb_corners(aabb: &Aabb3d) -> [Vec3; 8] {
|
||||
pub fn model_file_exists(catalog_path: &str) -> bool {
|
||||
Path::new(catalog_path).is_file()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn material_sphere_framing_fills_more_of_the_thumbnail_with_padding() {
|
||||
let sphere_radius = 0.65;
|
||||
let min = Vec3::splat(-sphere_radius);
|
||||
let max = Vec3::splat(sphere_radius);
|
||||
|
||||
let scene_distance = thumbnail_camera_distance(min, max, ThumbnailFraming::SceneBounds);
|
||||
let material_distance =
|
||||
thumbnail_camera_distance(min, max, ThumbnailFraming::MaterialSphere);
|
||||
let projected_radius_fraction =
|
||||
sphere_radius / (material_distance * THUMBNAIL_HALF_VERTICAL_FOV.tan());
|
||||
|
||||
assert!(material_distance < scene_distance * 0.6);
|
||||
assert!((0.75..0.82).contains(&projected_radius_fraction));
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,9 +3,10 @@ use std::path::PathBuf;
|
||||
use bevy::prelude::*;
|
||||
use shared::{
|
||||
ActorId, ActorKind, ActorName, AnimationControllerDesc, AudioListenerDesc, AudioSourceDesc,
|
||||
BrushDesc, ColliderDesc, EditorVisibility, InspectorOrder, LightDesc, MaterialDesc,
|
||||
MaterialOverride, ModelRef, ObjectiveMarker, PhysicsBody, PostProcessVolumeDesc,
|
||||
PrefabInstance, PrefabRef, Primitive, RigidBodyDesc, SceneComposition, StaticMeshRenderer,
|
||||
AuthoringComponentStates, BrushDesc, ColliderDesc, EditorVisibility, InspectorOrder, LightDesc,
|
||||
MaterialDesc, MaterialOverride, ModelRef, NavigationArea, NavigationBounds, NavigationLink,
|
||||
NavigationObstacle, ObjectiveMarker, PhysicsBody, PostProcessVolumeDesc, PrefabInstance,
|
||||
PrefabRef, Primitive, RigidBodyDesc, SceneComposition, SkinnedMeshRenderer, StaticMeshRenderer,
|
||||
TeamSpawn, TriggerVolume, WeaponSpawn,
|
||||
};
|
||||
#[derive(Debug, Clone)]
|
||||
@ -18,6 +19,7 @@ pub struct EditorEntitySnapshot {
|
||||
pub primitive: Option<Primitive>,
|
||||
pub brush: Option<BrushDesc>,
|
||||
pub static_mesh_renderer: Option<StaticMeshRenderer>,
|
||||
pub skinned_mesh_renderer: Option<SkinnedMeshRenderer>,
|
||||
pub material: Option<MaterialDesc>,
|
||||
pub material_override: Option<MaterialOverride>,
|
||||
pub rigid_body: Option<RigidBodyDesc>,
|
||||
@ -36,11 +38,52 @@ pub struct EditorEntitySnapshot {
|
||||
pub post_process_volume: Option<PostProcessVolumeDesc>,
|
||||
pub team_spawn: Option<TeamSpawn>,
|
||||
pub objective: Option<ObjectiveMarker>,
|
||||
pub navigation_bounds: Option<NavigationBounds>,
|
||||
pub navigation_obstacle: Option<NavigationObstacle>,
|
||||
pub navigation_area: Option<NavigationArea>,
|
||||
pub navigation_link: Option<NavigationLink>,
|
||||
pub hierarchy_sibling_index: i32,
|
||||
pub editor_visibility: EditorVisibility,
|
||||
pub inspector_order: Option<InspectorOrder>,
|
||||
pub component_states: Option<AuthoringComponentStates>,
|
||||
pub children: Vec<EditorEntitySnapshot>,
|
||||
}
|
||||
|
||||
/// One reflected component value used by registry-driven editor transactions.
|
||||
/// `component_id` is stable; `type_path` is retained only to resolve Bevy's
|
||||
/// reflection adapter for this build.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ReflectedComponentValue {
|
||||
pub component_id: String,
|
||||
pub type_path: String,
|
||||
pub ron: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ComponentDelta {
|
||||
pub component_id: String,
|
||||
pub type_path: String,
|
||||
pub before: Option<ReflectedComponentValue>,
|
||||
pub after: Option<ReflectedComponentValue>,
|
||||
}
|
||||
|
||||
/// An atomic add/remove/reset/paste operation. Requirements, dependent removals,
|
||||
/// and the derived ActorKind hint can be represented as additional deltas.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ComponentTransaction {
|
||||
pub entity: Entity,
|
||||
pub label: &'static str,
|
||||
pub deltas: Vec<ComponentDelta>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
pub struct NavigationComponentState {
|
||||
pub bounds: Option<NavigationBounds>,
|
||||
pub obstacle: Option<NavigationObstacle>,
|
||||
pub area: Option<NavigationArea>,
|
||||
pub link: Option<NavigationLink>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SiblingChange {
|
||||
pub entity: Entity,
|
||||
@ -174,6 +217,11 @@ pub enum EditorCommand {
|
||||
old: Option<PostProcessVolumeDesc>,
|
||||
new: PostProcessVolumeDesc,
|
||||
},
|
||||
SetNavigation {
|
||||
entity: Entity,
|
||||
old: NavigationComponentState,
|
||||
new: NavigationComponentState,
|
||||
},
|
||||
SetTransformGroup {
|
||||
entities: Vec<Entity>,
|
||||
olds: Vec<Transform>,
|
||||
@ -239,6 +287,7 @@ pub enum EditorCommand {
|
||||
entity: Entity,
|
||||
snapshot: EditorEntitySnapshot,
|
||||
},
|
||||
ComponentTransaction(ComponentTransaction),
|
||||
}
|
||||
|
||||
impl EditorCommand {
|
||||
@ -266,6 +315,7 @@ impl EditorCommand {
|
||||
EditorCommand::ApplyBrushCsg { .. } => "Brush CSG",
|
||||
EditorCommand::SetStaticMeshRenderer { .. } => "Set Static Mesh Renderer",
|
||||
EditorCommand::SetPostProcessVolume { .. } => "Set Post Process Volume",
|
||||
EditorCommand::SetNavigation { .. } => "Set Navigation",
|
||||
EditorCommand::SetTransformGroup { .. } => "Move Selection",
|
||||
EditorCommand::Reparent { .. } => "Reparent",
|
||||
EditorCommand::SetActorKind { .. } => "Set Actor Kind",
|
||||
@ -283,6 +333,7 @@ impl EditorCommand {
|
||||
} => "Unpack Prefab Layer",
|
||||
EditorCommand::AddComponent { .. } => "Add Component",
|
||||
EditorCommand::RemoveComponent { .. } => "Remove Component",
|
||||
EditorCommand::ComponentTransaction(transaction) => transaction.label,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,14 +1,20 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use bevy::ecs::reflect::ReflectComponent;
|
||||
use bevy::prelude::*;
|
||||
use bevy::reflect::serde::{TypedReflectDeserializer, TypedReflectSerializer};
|
||||
use bevy::reflect::std_traits::ReflectDefault;
|
||||
use bevy::world_serialization::WorldInstance;
|
||||
use serde::de::DeserializeSeed;
|
||||
use shared::{
|
||||
infer_actor_kind, ActorId, ActorKind, ActorName, AnimationControllerDesc, AudioListenerDesc,
|
||||
AudioSourceDesc, BrushDesc, ColliderDesc, EditorVisibility, HierarchySiblingIndex,
|
||||
HydratedPrefabMember, HydratedPrefabReady, InspectorOrder, LevelObject, LightDesc,
|
||||
MaterialDesc, MaterialOverride, ModelRef, ObjectiveMarker, PhysicsBody, PlayerSpawn,
|
||||
AudioSourceDesc, AuthoringComponentStates, BrushDesc, ColliderDesc, EditorVisibility,
|
||||
HierarchySiblingIndex, HydratedPrefabMember, HydratedPrefabReady, InspectorOrder, LevelObject,
|
||||
LightDesc, MaterialDesc, MaterialOverride, ModelRef, NavigationArea, NavigationBounds,
|
||||
NavigationLink, NavigationObstacle, ObjectiveMarker, PhysicsBody, PlayerSpawn,
|
||||
PostProcessVolumeDesc, PrefabHydrationBlocked, PrefabInstance, PrefabRef, Primitive,
|
||||
RigidBodyDesc, SceneComposition, StaticMeshRenderer, TeamSpawn, TriggerVolume, WeaponSpawn,
|
||||
RigidBodyDesc, SceneComposition, SkinnedMeshRenderer, StaticMeshRenderer, TeamSpawn,
|
||||
TriggerVolume, WeaponSpawn,
|
||||
};
|
||||
|
||||
use crate::scene_io::{SceneIo, SceneIoRequest};
|
||||
@ -22,7 +28,10 @@ use crate::ui::selection_ops::is_mutable_level_object;
|
||||
use crate::ui::{egui_captures_keyboard_from_world, UiState};
|
||||
|
||||
mod commands;
|
||||
pub use commands::{EditorCommand, EditorEntitySnapshot, PrefabEditState, SiblingChange};
|
||||
pub use commands::{
|
||||
ComponentDelta, ComponentTransaction, EditorCommand, EditorEntitySnapshot,
|
||||
NavigationComponentState, PrefabEditState, ReflectedComponentValue, SiblingChange,
|
||||
};
|
||||
|
||||
#[derive(Resource, Debug, Default)]
|
||||
pub struct EditorHistory {
|
||||
@ -138,6 +147,7 @@ pub fn snapshot_entity(world: &World, entity: Entity) -> Option<EditorEntitySnap
|
||||
primitive: entity_ref.get::<Primitive>().cloned(),
|
||||
brush: entity_ref.get::<BrushDesc>().cloned(),
|
||||
static_mesh_renderer: entity_ref.get::<StaticMeshRenderer>().cloned(),
|
||||
skinned_mesh_renderer: entity_ref.get::<SkinnedMeshRenderer>().cloned(),
|
||||
material: entity_ref.get::<MaterialDesc>().cloned(),
|
||||
material_override: entity_ref.get::<MaterialOverride>().cloned(),
|
||||
rigid_body: entity_ref.get::<RigidBodyDesc>().cloned(),
|
||||
@ -156,6 +166,10 @@ pub fn snapshot_entity(world: &World, entity: Entity) -> Option<EditorEntitySnap
|
||||
post_process_volume: entity_ref.get::<PostProcessVolumeDesc>().cloned(),
|
||||
team_spawn: entity_ref.get::<TeamSpawn>().cloned(),
|
||||
objective: entity_ref.get::<ObjectiveMarker>().cloned(),
|
||||
navigation_bounds: entity_ref.get::<NavigationBounds>().cloned(),
|
||||
navigation_obstacle: entity_ref.get::<NavigationObstacle>().cloned(),
|
||||
navigation_area: entity_ref.get::<NavigationArea>().cloned(),
|
||||
navigation_link: entity_ref.get::<NavigationLink>().cloned(),
|
||||
hierarchy_sibling_index: entity_ref
|
||||
.get::<HierarchySiblingIndex>()
|
||||
.map(|index| index.0)
|
||||
@ -164,6 +178,8 @@ pub fn snapshot_entity(world: &World, entity: Entity) -> Option<EditorEntitySnap
|
||||
.get::<EditorVisibility>()
|
||||
.copied()
|
||||
.unwrap_or_default(),
|
||||
inspector_order: entity_ref.get::<InspectorOrder>().cloned(),
|
||||
component_states: entity_ref.get::<AuthoringComponentStates>().cloned(),
|
||||
children,
|
||||
})
|
||||
}
|
||||
@ -204,6 +220,9 @@ fn spawn_snapshot_with_parent(
|
||||
if let Some(renderer) = &snapshot.static_mesh_renderer {
|
||||
entity_mut.insert(renderer.clone());
|
||||
}
|
||||
if let Some(renderer) = &snapshot.skinned_mesh_renderer {
|
||||
entity_mut.insert(renderer.clone());
|
||||
}
|
||||
if let Some(material) = &snapshot.material {
|
||||
entity_mut.insert(material.clone());
|
||||
}
|
||||
@ -258,8 +277,26 @@ fn spawn_snapshot_with_parent(
|
||||
if let Some(objective) = &snapshot.objective {
|
||||
entity_mut.insert(objective.clone());
|
||||
}
|
||||
if let Some(bounds) = &snapshot.navigation_bounds {
|
||||
entity_mut.insert(bounds.clone());
|
||||
}
|
||||
if let Some(obstacle) = &snapshot.navigation_obstacle {
|
||||
entity_mut.insert(obstacle.clone());
|
||||
}
|
||||
if let Some(area) = &snapshot.navigation_area {
|
||||
entity_mut.insert(area.clone());
|
||||
}
|
||||
if let Some(link) = &snapshot.navigation_link {
|
||||
entity_mut.insert(link.clone());
|
||||
}
|
||||
entity_mut.insert(HierarchySiblingIndex(snapshot.hierarchy_sibling_index));
|
||||
entity_mut.insert(snapshot.editor_visibility);
|
||||
if let Some(order) = &snapshot.inspector_order {
|
||||
entity_mut.insert(order.clone());
|
||||
}
|
||||
if let Some(states) = &snapshot.component_states {
|
||||
entity_mut.insert(states.clone());
|
||||
}
|
||||
if let Some(parent) = parent {
|
||||
entity_mut.insert(ChildOf(parent));
|
||||
}
|
||||
@ -561,6 +598,31 @@ pub fn set_post_process_volume_with_history(
|
||||
);
|
||||
}
|
||||
|
||||
pub fn navigation_component_state(world: &World, entity: Entity) -> NavigationComponentState {
|
||||
NavigationComponentState {
|
||||
bounds: world.get::<NavigationBounds>(entity).cloned(),
|
||||
obstacle: world.get::<NavigationObstacle>(entity).cloned(),
|
||||
area: world.get::<NavigationArea>(entity).cloned(),
|
||||
link: world.get::<NavigationLink>(entity).cloned(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_navigation_with_history(
|
||||
world: &mut World,
|
||||
entity: Entity,
|
||||
new: NavigationComponentState,
|
||||
) {
|
||||
if !is_mutable_level_object(world, entity) {
|
||||
return;
|
||||
}
|
||||
let old = navigation_component_state(world, entity);
|
||||
if old == new {
|
||||
return;
|
||||
}
|
||||
apply_navigation_state(world, entity, &new);
|
||||
push_history(world, EditorCommand::SetNavigation { entity, old, new });
|
||||
}
|
||||
|
||||
pub fn set_physics_with_history(world: &mut World, entity: Entity, new: PhysicsBody) {
|
||||
if !is_mutable_level_object(world, entity) {
|
||||
return;
|
||||
@ -818,6 +880,7 @@ pub fn group_selection_with_history(world: &mut World, entities: &[Entity]) {
|
||||
primitive: None,
|
||||
brush: None,
|
||||
static_mesh_renderer: None,
|
||||
skinned_mesh_renderer: None,
|
||||
material: None,
|
||||
material_override: None,
|
||||
rigid_body: None,
|
||||
@ -836,8 +899,18 @@ pub fn group_selection_with_history(world: &mut World, entities: &[Entity]) {
|
||||
post_process_volume: None,
|
||||
team_spawn: None,
|
||||
objective: None,
|
||||
|
||||
navigation_bounds: None,
|
||||
|
||||
navigation_obstacle: None,
|
||||
|
||||
navigation_area: None,
|
||||
|
||||
navigation_link: None,
|
||||
hierarchy_sibling_index: next_sibling_index(world, None),
|
||||
editor_visibility: EditorVisibility::default(),
|
||||
inspector_order: None,
|
||||
component_states: None,
|
||||
children: Vec::new(),
|
||||
};
|
||||
let group = spawn_snapshot(world, &snapshot);
|
||||
@ -868,6 +941,7 @@ pub fn create_empty_child_with_history(world: &mut World, parent: Entity) {
|
||||
primitive: None,
|
||||
brush: None,
|
||||
static_mesh_renderer: None,
|
||||
skinned_mesh_renderer: None,
|
||||
material: None,
|
||||
material_override: None,
|
||||
rigid_body: None,
|
||||
@ -886,8 +960,18 @@ pub fn create_empty_child_with_history(world: &mut World, parent: Entity) {
|
||||
post_process_volume: None,
|
||||
team_spawn: None,
|
||||
objective: None,
|
||||
|
||||
navigation_bounds: None,
|
||||
|
||||
navigation_obstacle: None,
|
||||
|
||||
navigation_area: None,
|
||||
|
||||
navigation_link: None,
|
||||
hierarchy_sibling_index: next_sibling_index(world, Some(parent)),
|
||||
editor_visibility: EditorVisibility::default(),
|
||||
inspector_order: None,
|
||||
component_states: None,
|
||||
children: Vec::new(),
|
||||
};
|
||||
let entity = spawn_snapshot_with_parent(world, &snapshot, Some(parent));
|
||||
@ -1235,6 +1319,215 @@ pub fn apply_actor_kind(world: &mut World, entity: Entity, kind: ActorKind) {
|
||||
}
|
||||
}
|
||||
|
||||
pub const ACTOR_KIND_COMPONENT_ID: &str = "core.actor_kind";
|
||||
pub const ACTOR_KIND_TYPE_PATH: &str = "shared::components::ActorKind";
|
||||
|
||||
/// Captures any reflected component without adding a typed history branch.
|
||||
pub fn capture_reflected_component(
|
||||
world: &World,
|
||||
entity: Entity,
|
||||
component_id: &str,
|
||||
type_path: &str,
|
||||
) -> Result<Option<ReflectedComponentValue>, String> {
|
||||
let entity_ref = world
|
||||
.get_entity(entity)
|
||||
.map_err(|_| format!("entity {entity:?} no longer exists"))?;
|
||||
let app_registry = world
|
||||
.get_resource::<AppTypeRegistry>()
|
||||
.ok_or_else(|| "AppTypeRegistry is unavailable".to_string())?;
|
||||
let type_registry = app_registry.read();
|
||||
let registration = type_registry
|
||||
.get_with_type_path(type_path)
|
||||
.ok_or_else(|| format!("component type `{type_path}` is not registered"))?;
|
||||
let reflect_component = registration
|
||||
.data::<ReflectComponent>()
|
||||
.ok_or_else(|| format!("component `{type_path}` has no ReflectComponent adapter"))?;
|
||||
let Some(value) = reflect_component.reflect(entity_ref) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let ron = ron::ser::to_string(&TypedReflectSerializer::new(value, &type_registry))
|
||||
.map_err(|error| format!("could not serialize `{type_path}`: {error}"))?;
|
||||
Ok(Some(ReflectedComponentValue {
|
||||
component_id: component_id.to_string(),
|
||||
type_path: type_path.to_string(),
|
||||
ron,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Applies or removes one reflected component value.
|
||||
pub fn apply_reflected_component(
|
||||
world: &mut World,
|
||||
entity: Entity,
|
||||
type_path: &str,
|
||||
value: Option<&ReflectedComponentValue>,
|
||||
) -> Result<(), String> {
|
||||
if world.get_entity(entity).is_err() {
|
||||
return Err(format!("entity {entity:?} no longer exists"));
|
||||
}
|
||||
world.resource_scope(|world, app_registry: Mut<AppTypeRegistry>| {
|
||||
let type_registry = app_registry.read();
|
||||
let registration = type_registry
|
||||
.get_with_type_path(type_path)
|
||||
.ok_or_else(|| format!("component type `{type_path}` is not registered"))?;
|
||||
let reflect_component = registration
|
||||
.data::<ReflectComponent>()
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("component `{type_path}` has no ReflectComponent adapter"))?;
|
||||
match value {
|
||||
Some(value) => {
|
||||
if value.type_path != type_path {
|
||||
return Err(format!(
|
||||
"clipboard type `{}` does not match `{type_path}`",
|
||||
value.type_path
|
||||
));
|
||||
}
|
||||
let mut deserializer = ron::de::Deserializer::from_str(&value.ron)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let reflected = TypedReflectDeserializer::new(registration, &type_registry)
|
||||
.deserialize(&mut deserializer)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let exists = reflect_component.reflect(world.entity(entity)).is_some();
|
||||
if exists {
|
||||
reflect_component.apply(world.entity_mut(entity), reflected.as_ref());
|
||||
} else {
|
||||
reflect_component.insert(
|
||||
&mut world.entity_mut(entity),
|
||||
reflected.as_ref(),
|
||||
&type_registry,
|
||||
);
|
||||
}
|
||||
}
|
||||
None => reflect_component.remove(&mut world.entity_mut(entity)),
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Inserts the reflected `Default` for a registered authoring component.
|
||||
pub fn apply_reflected_default(
|
||||
world: &mut World,
|
||||
entity: Entity,
|
||||
type_path: &str,
|
||||
) -> Result<(), String> {
|
||||
world.resource_scope(|world, app_registry: Mut<AppTypeRegistry>| {
|
||||
let type_registry = app_registry.read();
|
||||
let registration = type_registry
|
||||
.get_with_type_path(type_path)
|
||||
.ok_or_else(|| format!("component type `{type_path}` is not registered"))?;
|
||||
let reflect_component = registration
|
||||
.data::<ReflectComponent>()
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("component `{type_path}` has no ReflectComponent adapter"))?;
|
||||
let default = registration
|
||||
.data::<ReflectDefault>()
|
||||
.ok_or_else(|| format!("component `{type_path}` has no reflected Default"))?
|
||||
.default();
|
||||
let exists = reflect_component.reflect(world.entity(entity)).is_some();
|
||||
if exists {
|
||||
reflect_component.apply(world.entity_mut(entity), default.as_ref());
|
||||
} else {
|
||||
reflect_component.insert(
|
||||
&mut world.entity_mut(entity),
|
||||
default.as_ref(),
|
||||
&type_registry,
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn sync_actor_kind_hint(world: &mut World, entity: Entity) {
|
||||
let kind = world
|
||||
.get_entity(entity)
|
||||
.ok()
|
||||
.and_then(infer_actor_kind)
|
||||
.unwrap_or(ActorKind::Empty);
|
||||
if let Ok(mut entity_mut) = world.get_entity_mut(entity) {
|
||||
entity_mut.insert(kind);
|
||||
}
|
||||
}
|
||||
|
||||
/// Executes one atomic reflected component edit and records all changed values,
|
||||
/// including the derived ActorKind presentation hint, as a single history item.
|
||||
pub fn reflected_component_transaction(
|
||||
world: &mut World,
|
||||
entity: Entity,
|
||||
label: &'static str,
|
||||
component_id: &str,
|
||||
type_path: &str,
|
||||
edit: impl FnOnce(&mut World, Entity) -> Result<(), String>,
|
||||
) -> Result<(), String> {
|
||||
if !is_mutable_level_object(world, entity) {
|
||||
return Err("actor is not editable".to_string());
|
||||
}
|
||||
let tracked = [
|
||||
(component_id, type_path),
|
||||
(ACTOR_KIND_COMPONENT_ID, ACTOR_KIND_TYPE_PATH),
|
||||
];
|
||||
let before = tracked
|
||||
.iter()
|
||||
.map(|(id, path)| capture_reflected_component(world, entity, id, path))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
if let Err(error) = edit(world, entity) {
|
||||
for ((_, path), value) in tracked.iter().zip(before.iter()) {
|
||||
let _ = apply_reflected_component(world, entity, path, value.as_ref());
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
sync_actor_kind_hint(world, entity);
|
||||
let after = tracked
|
||||
.iter()
|
||||
.map(|(id, path)| capture_reflected_component(world, entity, id, path))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let deltas = tracked
|
||||
.iter()
|
||||
.zip(before)
|
||||
.zip(after)
|
||||
.filter_map(|(((id, path), before), after)| {
|
||||
(before != after).then(|| ComponentDelta {
|
||||
component_id: (*id).to_string(),
|
||||
type_path: (*path).to_string(),
|
||||
before,
|
||||
after,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if !deltas.is_empty() {
|
||||
push_history(
|
||||
world,
|
||||
EditorCommand::ComponentTransaction(ComponentTransaction {
|
||||
entity,
|
||||
label,
|
||||
deltas,
|
||||
}),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_component_transaction(
|
||||
world: &mut World,
|
||||
transaction: &ComponentTransaction,
|
||||
use_after: bool,
|
||||
) {
|
||||
for delta in &transaction.deltas {
|
||||
let value = if use_after {
|
||||
delta.after.as_ref()
|
||||
} else {
|
||||
delta.before.as_ref()
|
||||
};
|
||||
if let Err(error) =
|
||||
apply_reflected_component(world, transaction.entity, &delta.type_path, value)
|
||||
{
|
||||
world.resource_mut::<EditorHistory>().status = format!(
|
||||
"{} partially failed for {}: {error}",
|
||||
transaction.label, delta.component_id
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_scene_composition_with_history(world: &mut World, new: SceneComposition) {
|
||||
let old = world.resource::<SceneComposition>().clone();
|
||||
if old == new {
|
||||
@ -1372,6 +1665,9 @@ fn undo_command(world: &mut World, command: &mut EditorCommand) {
|
||||
EditorCommand::SetPostProcessVolume { entity, old, .. } => {
|
||||
apply_post_process_volume(world, *entity, old);
|
||||
}
|
||||
EditorCommand::SetNavigation { entity, old, .. } => {
|
||||
apply_navigation_state(world, *entity, old);
|
||||
}
|
||||
EditorCommand::SetPhysics { entity, old, .. } => {
|
||||
apply_physics(world, *entity, old);
|
||||
}
|
||||
@ -1485,6 +1781,9 @@ fn undo_command(world: &mut World, command: &mut EditorCommand) {
|
||||
EditorCommand::RemoveComponent { entity, snapshot } => {
|
||||
apply_authored_components(world, *entity, snapshot);
|
||||
}
|
||||
EditorCommand::ComponentTransaction(transaction) => {
|
||||
apply_component_transaction(world, transaction, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1573,6 +1872,9 @@ fn redo_command(world: &mut World, command: &mut EditorCommand) {
|
||||
entity_mut.insert(new.clone());
|
||||
}
|
||||
}
|
||||
EditorCommand::SetNavigation { entity, new, .. } => {
|
||||
apply_navigation_state(world, *entity, new);
|
||||
}
|
||||
EditorCommand::SetPhysics { entity, new, .. } => {
|
||||
if let Ok(mut entity_mut) = world.get_entity_mut(*entity) {
|
||||
entity_mut.insert(new.clone());
|
||||
@ -1706,6 +2008,9 @@ fn redo_command(world: &mut World, command: &mut EditorCommand) {
|
||||
EditorCommand::RemoveComponent { entity, snapshot } => {
|
||||
remove_authored_components(world, *entity, snapshot);
|
||||
}
|
||||
EditorCommand::ComponentTransaction(transaction) => {
|
||||
apply_component_transaction(world, transaction, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1862,6 +2167,45 @@ fn apply_post_process_volume(
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_navigation_state(world: &mut World, entity: Entity, state: &NavigationComponentState) {
|
||||
let has_navigation = state.bounds.is_some()
|
||||
|| state.obstacle.is_some()
|
||||
|| state.area.is_some()
|
||||
|| state.link.is_some();
|
||||
if let Ok(mut entity_mut) = world.get_entity_mut(entity) {
|
||||
entity_mut
|
||||
.remove::<NavigationBounds>()
|
||||
.remove::<NavigationObstacle>()
|
||||
.remove::<NavigationArea>()
|
||||
.remove::<NavigationLink>();
|
||||
if let Some(value) = &state.bounds {
|
||||
entity_mut.insert(value.clone());
|
||||
}
|
||||
if let Some(value) = &state.obstacle {
|
||||
entity_mut.insert(value.clone());
|
||||
}
|
||||
if let Some(value) = &state.area {
|
||||
entity_mut.insert(value.clone());
|
||||
}
|
||||
if let Some(value) = &state.link {
|
||||
entity_mut.insert(value.clone());
|
||||
}
|
||||
if has_navigation {
|
||||
entity_mut.insert(ActorKind::Navigation);
|
||||
}
|
||||
}
|
||||
if !has_navigation {
|
||||
let fallback = world
|
||||
.get_entity(entity)
|
||||
.ok()
|
||||
.and_then(infer_actor_kind)
|
||||
.unwrap_or(ActorKind::Empty);
|
||||
if let Ok(mut entity_mut) = world.get_entity_mut(entity) {
|
||||
entity_mut.insert(fallback);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_physics(world: &mut World, entity: Entity, physics: &Option<PhysicsBody>) {
|
||||
if let Ok(mut entity_mut) = world.get_entity_mut(entity) {
|
||||
match physics {
|
||||
@ -1986,6 +2330,9 @@ fn apply_authored_components(world: &mut World, entity: Entity, snapshot: &Edito
|
||||
if let Some(v) = &snapshot.static_mesh_renderer {
|
||||
entity_mut.insert(v.clone());
|
||||
}
|
||||
if let Some(v) = &snapshot.skinned_mesh_renderer {
|
||||
entity_mut.insert(v.clone());
|
||||
}
|
||||
if let Some(v) = &snapshot.material {
|
||||
entity_mut.insert(v.clone());
|
||||
}
|
||||
@ -2040,6 +2387,24 @@ fn apply_authored_components(world: &mut World, entity: Entity, snapshot: &Edito
|
||||
if let Some(v) = &snapshot.objective {
|
||||
entity_mut.insert(v.clone());
|
||||
}
|
||||
if let Some(v) = &snapshot.navigation_bounds {
|
||||
entity_mut.insert(v.clone());
|
||||
}
|
||||
if let Some(v) = &snapshot.navigation_obstacle {
|
||||
entity_mut.insert(v.clone());
|
||||
}
|
||||
if let Some(v) = &snapshot.navigation_area {
|
||||
entity_mut.insert(v.clone());
|
||||
}
|
||||
if let Some(v) = &snapshot.navigation_link {
|
||||
entity_mut.insert(v.clone());
|
||||
}
|
||||
if let Some(v) = &snapshot.inspector_order {
|
||||
entity_mut.insert(v.clone());
|
||||
}
|
||||
if let Some(v) = &snapshot.component_states {
|
||||
entity_mut.insert(v.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -2061,6 +2426,9 @@ fn remove_authored_components(world: &mut World, entity: Entity, snapshot: &Edit
|
||||
if snapshot.static_mesh_renderer.is_some() {
|
||||
entity_mut.remove::<StaticMeshRenderer>();
|
||||
}
|
||||
if snapshot.skinned_mesh_renderer.is_some() {
|
||||
entity_mut.remove::<SkinnedMeshRenderer>();
|
||||
}
|
||||
if snapshot.material.is_some() {
|
||||
entity_mut.remove::<MaterialDesc>();
|
||||
}
|
||||
@ -2115,6 +2483,24 @@ fn remove_authored_components(world: &mut World, entity: Entity, snapshot: &Edit
|
||||
if snapshot.objective.is_some() {
|
||||
entity_mut.remove::<ObjectiveMarker>();
|
||||
}
|
||||
if snapshot.navigation_bounds.is_some() {
|
||||
entity_mut.remove::<NavigationBounds>();
|
||||
}
|
||||
if snapshot.navigation_obstacle.is_some() {
|
||||
entity_mut.remove::<NavigationObstacle>();
|
||||
}
|
||||
if snapshot.navigation_area.is_some() {
|
||||
entity_mut.remove::<NavigationArea>();
|
||||
}
|
||||
if snapshot.navigation_link.is_some() {
|
||||
entity_mut.remove::<NavigationLink>();
|
||||
}
|
||||
if snapshot.inspector_order.is_some() {
|
||||
entity_mut.remove::<InspectorOrder>();
|
||||
}
|
||||
if snapshot.component_states.is_some() {
|
||||
entity_mut.remove::<AuthoringComponentStates>();
|
||||
}
|
||||
}
|
||||
if removed_dedicated_audio_kind {
|
||||
let fallback = world
|
||||
@ -2383,6 +2769,88 @@ mod tests {
|
||||
use crate::operators::test_harness::{assert_undo_redo_round_trip, OperatorInvariantHarness};
|
||||
use crate::operators::{ActiveOperator, OperatorPhase, OperatorStatus};
|
||||
|
||||
#[derive(Component, Reflect, Default, Debug, Clone, PartialEq)]
|
||||
#[reflect(Component, Default)]
|
||||
struct StaticExtensionFixture {
|
||||
value: u32,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reflected_extension_component_transaction_round_trips_atomically() {
|
||||
let mut app = App::new();
|
||||
app.register_type::<ActorKind>()
|
||||
.register_type::<StaticExtensionFixture>();
|
||||
let world = app.world_mut();
|
||||
world.init_resource::<EditorHistory>();
|
||||
world.init_resource::<SceneIo>();
|
||||
let entity = world
|
||||
.spawn((LevelObject, ActorKind::Light, Transform::default()))
|
||||
.id();
|
||||
let type_path = std::any::type_name::<StaticExtensionFixture>();
|
||||
|
||||
reflected_component_transaction(
|
||||
world,
|
||||
entity,
|
||||
"Add Fixture",
|
||||
"game.static_extension_fixture",
|
||||
type_path,
|
||||
|world, entity| {
|
||||
world
|
||||
.entity_mut(entity)
|
||||
.insert(StaticExtensionFixture { value: 42 });
|
||||
Ok(())
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
world.get::<StaticExtensionFixture>(entity).unwrap().value,
|
||||
42
|
||||
);
|
||||
assert_eq!(world.get::<ActorKind>(entity), Some(&ActorKind::Empty));
|
||||
assert_eq!(world.resource::<EditorHistory>().undo_depth(), 1);
|
||||
|
||||
apply_command_undo(world);
|
||||
assert!(world.get::<StaticExtensionFixture>(entity).is_none());
|
||||
assert_eq!(world.get::<ActorKind>(entity), Some(&ActorKind::Light));
|
||||
|
||||
apply_command_redo(world);
|
||||
assert_eq!(
|
||||
world.get::<StaticExtensionFixture>(entity).unwrap().value,
|
||||
42
|
||||
);
|
||||
assert_eq!(world.get::<ActorKind>(entity), Some(&ActorKind::Empty));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshots_preserve_component_order_and_independent_active_state() {
|
||||
let mut world = World::new();
|
||||
let mut order = InspectorOrder::default();
|
||||
order.component_ids = vec![shared::AUTHORING_COMPONENT_LIGHT.to_string()];
|
||||
let mut states = AuthoringComponentStates::default();
|
||||
states.set_component_active(shared::COMPONENT_LIGHT_DESC, false);
|
||||
let entity = world
|
||||
.spawn((
|
||||
LevelObject,
|
||||
ActorKind::Light,
|
||||
Transform::default(),
|
||||
LightDesc::default(),
|
||||
order.clone(),
|
||||
states.clone(),
|
||||
))
|
||||
.id();
|
||||
|
||||
let snapshot = snapshot_entity(&world, entity).unwrap();
|
||||
world.entity_mut(entity).despawn();
|
||||
let restored = spawn_snapshot(&mut world, &snapshot);
|
||||
|
||||
assert_eq!(world.get::<InspectorOrder>(restored), Some(&order));
|
||||
assert_eq!(
|
||||
world.get::<AuthoringComponentStates>(restored),
|
||||
Some(&states)
|
||||
);
|
||||
}
|
||||
|
||||
fn animation_controller(state_id: &str, crossfade_seconds: f32) -> AnimationControllerDesc {
|
||||
AnimationControllerDesc {
|
||||
skeleton: Some(
|
||||
@ -2420,7 +2888,7 @@ mod tests {
|
||||
let entity = world
|
||||
.spawn((
|
||||
LevelObject,
|
||||
ActorKind::ImportedModel,
|
||||
ActorKind::SkinnedMesh,
|
||||
Transform::IDENTITY,
|
||||
old.clone(),
|
||||
))
|
||||
@ -2439,6 +2907,36 @@ mod tests {
|
||||
assert_eq!(world.get::<AnimationControllerDesc>(entity), Some(&new));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigation_component_history_round_trips_and_preserves_kind() {
|
||||
let mut world = World::new();
|
||||
world.init_resource::<EditorHistory>();
|
||||
world.init_resource::<SceneIo>();
|
||||
let entity = world
|
||||
.spawn((LevelObject, ActorKind::Empty, Transform::IDENTITY))
|
||||
.id();
|
||||
let bounds = NavigationBounds::default();
|
||||
|
||||
set_navigation_with_history(
|
||||
&mut world,
|
||||
entity,
|
||||
NavigationComponentState {
|
||||
bounds: Some(bounds.clone()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
assert_eq!(world.get::<NavigationBounds>(entity), Some(&bounds));
|
||||
assert_eq!(world.get::<ActorKind>(entity), Some(&ActorKind::Navigation));
|
||||
|
||||
apply_command_undo(&mut world);
|
||||
assert!(world.get::<NavigationBounds>(entity).is_none());
|
||||
assert_eq!(world.get::<ActorKind>(entity), Some(&ActorKind::Empty));
|
||||
|
||||
apply_command_redo(&mut world);
|
||||
assert_eq!(world.get::<NavigationBounds>(entity), Some(&bounds));
|
||||
assert_eq!(world.get::<ActorKind>(entity), Some(&ActorKind::Navigation));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn undoing_a_new_animation_controller_removes_the_component() {
|
||||
let mut world = World::new();
|
||||
@ -2446,7 +2944,7 @@ mod tests {
|
||||
world.init_resource::<SceneIo>();
|
||||
let controller = animation_controller("idle", 0.2);
|
||||
let entity = world
|
||||
.spawn((LevelObject, ActorKind::ImportedModel, Transform::IDENTITY))
|
||||
.spawn((LevelObject, ActorKind::SkinnedMesh, Transform::IDENTITY))
|
||||
.id();
|
||||
|
||||
set_animation_controller_with_history(&mut world, entity, controller.clone());
|
||||
@ -2467,9 +2965,10 @@ mod tests {
|
||||
let source = world
|
||||
.spawn((
|
||||
LevelObject,
|
||||
ActorKind::ImportedModel,
|
||||
ActorKind::SkinnedMesh,
|
||||
Name::new("Animated Robot"),
|
||||
Transform::from_xyz(1.0, 2.0, 3.0),
|
||||
SkinnedMeshRenderer::new("assets/models/robot.glb").with_asset_id("robot-asset"),
|
||||
controller.clone(),
|
||||
))
|
||||
.id();
|
||||
@ -2486,6 +2985,10 @@ mod tests {
|
||||
world.get::<Name>(respawned).map(Name::as_str),
|
||||
Some("Animated Robot")
|
||||
);
|
||||
assert_eq!(
|
||||
world.get::<SkinnedMeshRenderer>(respawned),
|
||||
Some(&SkinnedMeshRenderer::new("assets/models/robot.glb").with_asset_id("robot-asset"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@ -6,7 +6,10 @@ use bevy::window::WindowRef;
|
||||
use game::player::PlayerCamera;
|
||||
use protocol::PlayerInputIntent;
|
||||
use settings::SimTuning;
|
||||
use shared::{inspector_component_active, InspectorOrder, PlayerSpawn, COMPONENT_PLAYER_SPAWN};
|
||||
use shared::{
|
||||
authoring_component_active, AuthoringComponentStates, InspectorOrder, PlayerSpawn,
|
||||
COMPONENT_PLAYER_SPAWN,
|
||||
};
|
||||
use sim::{Crouching, Grounded, JumpState, Player, PlayerVelocity};
|
||||
|
||||
use crate::render_target::ViewportRenderTarget;
|
||||
@ -66,10 +69,16 @@ fn bootstrap_player_on_play(world: &mut World) {
|
||||
.unwrap_or_else(|| SimTuning::from_physics(&settings::PhysicsSettings::default()));
|
||||
|
||||
let spawn = world
|
||||
.query_filtered::<(&Transform, Option<&InspectorOrder>), With<PlayerSpawn>>()
|
||||
.query_filtered::<(
|
||||
&Transform,
|
||||
Option<&AuthoringComponentStates>,
|
||||
Option<&InspectorOrder>,
|
||||
), With<PlayerSpawn>>()
|
||||
.iter(world)
|
||||
.filter(|(_, order)| inspector_component_active(*order, COMPONENT_PLAYER_SPAWN))
|
||||
.map(|(transform, _)| transform)
|
||||
.filter(|(_, states, legacy_order)| {
|
||||
authoring_component_active(*states, *legacy_order, COMPONENT_PLAYER_SPAWN)
|
||||
})
|
||||
.map(|(transform, _, _)| transform)
|
||||
.next()
|
||||
.copied()
|
||||
.unwrap_or_else(default_player_spawn);
|
||||
|
||||
@ -377,7 +377,11 @@ fn apply_project_settings_changes(
|
||||
settings: Res<ProjectSettings>,
|
||||
tuning: ResMut<settings::SimTuning>,
|
||||
scene_suns: Query<
|
||||
(&shared::LightDesc, Option<&shared::InspectorOrder>),
|
||||
(
|
||||
&shared::LightDesc,
|
||||
Option<&shared::AuthoringComponentStates>,
|
||||
Option<&shared::InspectorOrder>,
|
||||
),
|
||||
With<shared::LevelObject>,
|
||||
>,
|
||||
project_suns: Query<(&mut DirectionalLight, &mut Visibility), With<shared::ProjectSun>>,
|
||||
|
||||
@ -6,17 +6,19 @@ use bevy::ecs::system::SystemState;
|
||||
use bevy::prelude::*;
|
||||
use bevy::window::PrimaryWindow;
|
||||
use bevy::world_serialization::serde::WorldDeserializer;
|
||||
use bevy::world_serialization::{DynamicWorld, DynamicWorldBuilder, WorldInstance};
|
||||
use bevy::world_serialization::{DynamicWorld, DynamicWorldBuilder, WorldFilter, WorldInstance};
|
||||
use scene::{document::SceneDocument, strip_schema_version, validate_level_text};
|
||||
use serde::de::DeserializeSeed;
|
||||
use shared::{
|
||||
infer_actor_kind, validate_actor, ActorId, ActorKind, ActorName, ActorValidationError,
|
||||
AnimationControllerDesc, AudioListenerDesc, AudioSourceDesc, BrushDesc, ColliderDesc,
|
||||
EditorVisibility, HierarchySiblingIndex, HydratedPrefabMember, HydratedPrefabReady,
|
||||
InspectorOrder, LevelObject, LightDesc, MaterialDesc, MaterialOverride, ModelRef,
|
||||
ObjectiveMarker, PhysicsBody, PlayerSpawn, PostProcessVolumeDesc, PrefabHydrationBlocked,
|
||||
PrefabInstance, PrefabRef, Primitive, RigidBodyDesc, SceneComposition, StaticMeshRenderer,
|
||||
TeamSpawn, TriggerVolume, WeaponSpawn,
|
||||
AuthoringComponentStates, EditorVisibility, HierarchySiblingIndex, HydratedPrefabMember,
|
||||
HydratedPrefabReady, InspectorOrder, LevelObject, LightDesc, PrefabHydrationBlocked,
|
||||
PrefabInstance, PrefabRef, SceneComposition,
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
use shared::{
|
||||
AnimationControllerDesc, AudioSourceDesc, BrushDesc, ColliderDesc, SkinnedMeshRenderer,
|
||||
};
|
||||
|
||||
use crate::assets::{import_external_assets, EditorAssets, IMPORTABLE_ASSET_EXTENSIONS};
|
||||
@ -1006,6 +1008,27 @@ fn format_actor_validation(err: ActorValidationError) -> String {
|
||||
ActorValidationError::ImportedModelHasStaticMeshRenderer => {
|
||||
"Save failed: ImportedModel actor cannot have StaticMeshRenderer".into()
|
||||
}
|
||||
ActorValidationError::SkinnedMeshMissingRenderer => {
|
||||
"Save failed: SkinnedMesh actor requires SkinnedMeshRenderer".into()
|
||||
}
|
||||
ActorValidationError::SkinnedMeshInvalidRenderer => {
|
||||
"Save failed: SkinnedMeshRenderer requires a model source path".into()
|
||||
}
|
||||
ActorValidationError::SkinnedMeshHasPrimitive => {
|
||||
"Save failed: SkinnedMesh actor cannot have Primitive".into()
|
||||
}
|
||||
ActorValidationError::SkinnedMeshHasStaticMeshRenderer => {
|
||||
"Save failed: SkinnedMesh actor cannot have StaticMeshRenderer".into()
|
||||
}
|
||||
ActorValidationError::SkinnedMeshHasModelRef => {
|
||||
"Save failed: SkinnedMesh actor cannot have ModelRef".into()
|
||||
}
|
||||
ActorValidationError::SkinnedMeshRendererActorKindMismatch => {
|
||||
"Save failed: SkinnedMeshRenderer requires ActorKind::SkinnedMesh".into()
|
||||
}
|
||||
ActorValidationError::ConflictingGeometrySources => {
|
||||
"Save failed: actor has more than one primary geometry source component".into()
|
||||
}
|
||||
ActorValidationError::LightMissingLightDesc => {
|
||||
"Save failed: Light actor requires LightDesc".into()
|
||||
}
|
||||
@ -1048,8 +1071,9 @@ fn format_actor_validation(err: ActorValidationError) -> String {
|
||||
ActorValidationError::AudioListenerInvalidEarGap => {
|
||||
"Save failed: audio listener ear gap must be positive and finite".into()
|
||||
}
|
||||
ActorValidationError::AnimationControllerMissingModelRef => {
|
||||
"Save failed: animation controller requires ModelRef on the same actor".into()
|
||||
ActorValidationError::AnimationControllerMissingSkinnedMeshRenderer => {
|
||||
"Save failed: animation controller requires SkinnedMeshRenderer on the same actor"
|
||||
.into()
|
||||
}
|
||||
ActorValidationError::AnimationControllerMissingSkeleton => {
|
||||
"Save failed: animation controller requires an assigned skeleton".into()
|
||||
@ -1093,6 +1117,9 @@ fn format_actor_validation(err: ActorValidationError) -> String {
|
||||
ActorValidationError::PostProcessVolumeInvalidOverrideScalar => {
|
||||
"Save failed: post-process volume override contains invalid scalar".into()
|
||||
}
|
||||
ActorValidationError::InvalidNavigation(message) => {
|
||||
format!("Save failed: invalid navigation authoring: {message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1179,39 +1206,26 @@ fn serialize_entities_inner_with_resources(
|
||||
let result = (|| {
|
||||
let ron = {
|
||||
let registry = world.resource::<AppTypeRegistry>().read();
|
||||
let mut component_filter = WorldFilter::deny_all()
|
||||
.allow::<Name>()
|
||||
.allow::<Transform>()
|
||||
.allow::<ChildOf>()
|
||||
.allow::<LevelObject>()
|
||||
.allow::<ActorId>()
|
||||
.allow::<ActorName>()
|
||||
.allow::<HierarchySiblingIndex>()
|
||||
.allow::<EditorVisibility>();
|
||||
let component_registry = world
|
||||
.get_resource::<crate::ui::component_registry::EditorComponentRegistry>()
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
for descriptor in &component_registry.descriptors {
|
||||
if let Some(registration) = registry.get_with_type_path(descriptor.type_name) {
|
||||
component_filter = component_filter.allow_by_id(registration.type_id());
|
||||
}
|
||||
}
|
||||
let builder = DynamicWorldBuilder::from_world(world, ®istry)
|
||||
.deny_all()
|
||||
.allow_component::<Name>()
|
||||
.allow_component::<Transform>()
|
||||
.allow_component::<ChildOf>()
|
||||
.allow_component::<LevelObject>()
|
||||
.allow_component::<ActorId>()
|
||||
.allow_component::<ActorName>()
|
||||
.allow_component::<ActorKind>()
|
||||
.allow_component::<InspectorOrder>()
|
||||
.allow_component::<Primitive>()
|
||||
.allow_component::<BrushDesc>()
|
||||
.allow_component::<StaticMeshRenderer>()
|
||||
.allow_component::<MaterialDesc>()
|
||||
.allow_component::<MaterialOverride>()
|
||||
.allow_component::<RigidBodyDesc>()
|
||||
.allow_component::<ColliderDesc>()
|
||||
.allow_component::<PhysicsBody>()
|
||||
.allow_component::<LightDesc>()
|
||||
.allow_component::<AnimationControllerDesc>()
|
||||
.allow_component::<AudioSourceDesc>()
|
||||
.allow_component::<AudioListenerDesc>()
|
||||
.allow_component::<PlayerSpawn>()
|
||||
.allow_component::<ModelRef>()
|
||||
.allow_component::<PrefabRef>()
|
||||
.allow_component::<PrefabInstance>()
|
||||
.allow_component::<WeaponSpawn>()
|
||||
.allow_component::<TriggerVolume>()
|
||||
.allow_component::<PostProcessVolumeDesc>()
|
||||
.allow_component::<TeamSpawn>()
|
||||
.allow_component::<ObjectiveMarker>()
|
||||
.allow_component::<HierarchySiblingIndex>()
|
||||
.allow_component::<EditorVisibility>()
|
||||
.with_component_filter(component_filter)
|
||||
.extract_entities(entities.into_iter());
|
||||
let scene = if include_scene_resources {
|
||||
builder
|
||||
@ -1781,7 +1795,14 @@ fn finalize_scene_load(world: &mut World) {
|
||||
|
||||
let mut state: SystemState<(
|
||||
Res<settings::ProjectSettings>,
|
||||
Query<(&LightDesc, Option<&InspectorOrder>), With<LevelObject>>,
|
||||
Query<
|
||||
(
|
||||
&LightDesc,
|
||||
Option<&AuthoringComponentStates>,
|
||||
Option<&InspectorOrder>,
|
||||
),
|
||||
With<LevelObject>,
|
||||
>,
|
||||
Query<(&mut DirectionalLight, &mut Visibility), With<shared::ProjectSun>>,
|
||||
)> = SystemState::new(world);
|
||||
|
||||
@ -1801,10 +1822,8 @@ fn backfill_missing_actor_kinds(world: &mut World) {
|
||||
let Ok(entity_ref) = world.get_entity(entity) else {
|
||||
continue;
|
||||
};
|
||||
if entity_ref.get::<ActorKind>().is_some() {
|
||||
continue;
|
||||
}
|
||||
if let Some(kind) = infer_actor_kind(entity_ref) {
|
||||
let current = entity_ref.get::<ActorKind>().copied();
|
||||
if let Some(kind) = infer_actor_kind(entity_ref).filter(|kind| current != Some(*kind)) {
|
||||
world.entity_mut(entity).insert(kind);
|
||||
}
|
||||
}
|
||||
@ -1856,6 +1875,12 @@ mod tests {
|
||||
|
||||
struct NoAssetLoads;
|
||||
|
||||
#[derive(Component, Reflect, Default)]
|
||||
#[reflect(Component, Default)]
|
||||
struct RegisteredSceneExtension {
|
||||
value: u32,
|
||||
}
|
||||
|
||||
impl LoadFromPath for NoAssetLoads {
|
||||
fn load_from_path_erased(
|
||||
&mut self,
|
||||
@ -1866,6 +1891,43 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_registered_extension_is_included_in_scene_persistence() {
|
||||
let mut app = App::new();
|
||||
app.register_type::<Name>()
|
||||
.register_type::<Transform>()
|
||||
.register_type::<LevelObject>()
|
||||
.register_type::<ActorKind>()
|
||||
.register_type::<SceneComposition>()
|
||||
.register_type::<RegisteredSceneExtension>();
|
||||
let mut component_registry =
|
||||
crate::ui::component_registry::EditorComponentRegistry::default();
|
||||
let mut descriptor = component_registry.descriptors[0].clone();
|
||||
descriptor.id = "game.registered_scene_extension";
|
||||
descriptor.type_name = std::any::type_name::<RegisteredSceneExtension>();
|
||||
descriptor.display_name = "Registered Scene Extension";
|
||||
descriptor.recommended = &[];
|
||||
descriptor.conflicts_with = &[];
|
||||
component_registry.register(descriptor).unwrap();
|
||||
app.insert_resource(component_registry);
|
||||
app.insert_resource(SceneComposition::default());
|
||||
let entity = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
Name::new("Extension"),
|
||||
Transform::default(),
|
||||
LevelObject,
|
||||
ActorKind::Empty,
|
||||
RegisteredSceneExtension { value: 73 },
|
||||
))
|
||||
.id();
|
||||
|
||||
let text = serialize_entities_inner(app.world_mut(), vec![entity]).unwrap();
|
||||
|
||||
assert!(text.contains(std::any::type_name::<RegisteredSceneExtension>()));
|
||||
assert!(text.contains("value: 73") || text.contains("value:73"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restored_recovery_remains_available_until_save_or_discard() {
|
||||
let authored = PathBuf::from("assets/levels/authored.scn.ron");
|
||||
@ -2152,8 +2214,8 @@ mod tests {
|
||||
Transform::IDENTITY,
|
||||
LevelObject,
|
||||
ActorId::new("animated-model"),
|
||||
ActorKind::ImportedModel,
|
||||
ModelRef::new(source_path),
|
||||
ActorKind::SkinnedMesh,
|
||||
SkinnedMeshRenderer::new(source_path),
|
||||
controller.clone(),
|
||||
AnimationPlayer::default(),
|
||||
AnimationGraphHandle::default(),
|
||||
@ -2171,6 +2233,8 @@ mod tests {
|
||||
let decoded: AnimationControllerDesc = ron::from_str(&component.ron).unwrap();
|
||||
|
||||
assert_eq!(decoded, controller);
|
||||
assert!(text.contains("shared::animation::SkinnedMeshRenderer"));
|
||||
assert!(!text.contains("shared::components::ModelRef"));
|
||||
assert!(!text.contains("AnimationPlayer"));
|
||||
assert!(!text.contains("AnimationGraphHandle"));
|
||||
assert!(!text.contains("AnimationTransitions"));
|
||||
|
||||
@ -5,7 +5,7 @@ mod transform;
|
||||
use bevy::prelude::*;
|
||||
use bevy_egui::egui;
|
||||
use egui_phosphor_icons::icons;
|
||||
use shared::{ActorId, ActorKind, ActorName, HydratedPrefabMember, LevelObject};
|
||||
use shared::{infer_actor_kind, ActorId, ActorKind, ActorName, HydratedPrefabMember, LevelObject};
|
||||
|
||||
use crate::ext::extensibility::ActorInspectorSectionRegistry;
|
||||
use crate::ui::inspector;
|
||||
@ -29,9 +29,8 @@ pub fn draw_actor_inspector(world: &mut World, ui: &mut egui::Ui, entity: Entity
|
||||
return;
|
||||
}
|
||||
|
||||
let kind = entity_ref
|
||||
.get::<ActorKind>()
|
||||
.copied()
|
||||
let kind = infer_actor_kind(entity_ref)
|
||||
.or_else(|| entity_ref.get::<ActorKind>().copied())
|
||||
.unwrap_or(ActorKind::Empty);
|
||||
|
||||
egui::Frame::new()
|
||||
@ -111,6 +110,7 @@ pub fn draw_actor_inspector(world: &mut World, ui: &mut egui::Ui, entity: Entity
|
||||
|
||||
match kind {
|
||||
ActorKind::StaticMesh
|
||||
| ActorKind::SkinnedMesh
|
||||
| ActorKind::Brush
|
||||
| ActorKind::ImportedModel
|
||||
| ActorKind::Light
|
||||
@ -123,7 +123,8 @@ pub fn draw_actor_inspector(world: &mut World, ui: &mut egui::Ui, entity: Entity
|
||||
| ActorKind::TeamSpawn
|
||||
| ActorKind::Objective
|
||||
| ActorKind::AudioSource
|
||||
| ActorKind::AudioListener => {
|
||||
| ActorKind::AudioListener
|
||||
| ActorKind::Navigation => {
|
||||
inspector::authoring_inspector_ui(world, ui, entity);
|
||||
}
|
||||
}
|
||||
@ -139,7 +140,10 @@ pub fn draw_actor_inspector(world: &mut World, ui: &mut egui::Ui, entity: Entity
|
||||
|
||||
fn actor_icon(kind: ActorKind) -> egui_phosphor_icons::Icon {
|
||||
match kind {
|
||||
ActorKind::StaticMesh | ActorKind::Brush | ActorKind::ImportedModel => icons::CUBE,
|
||||
ActorKind::StaticMesh
|
||||
| ActorKind::SkinnedMesh
|
||||
| ActorKind::Brush
|
||||
| ActorKind::ImportedModel => icons::CUBE,
|
||||
ActorKind::Light => icons::LIGHTBULB,
|
||||
ActorKind::PrefabAnchor => icons::PACKAGE,
|
||||
ActorKind::PlayerSpawn => icons::PERSON_SIMPLE_RUN,
|
||||
@ -149,6 +153,7 @@ fn actor_icon(kind: ActorKind) -> egui_phosphor_icons::Icon {
|
||||
ActorKind::TeamSpawn | ActorKind::Objective => icons::FLAG,
|
||||
ActorKind::AudioSource => icons::SPEAKER_HIGH,
|
||||
ActorKind::AudioListener => icons::HEADPHONES,
|
||||
ActorKind::Navigation => icons::PATH,
|
||||
ActorKind::Empty => icons::CIRCLE,
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,7 +7,7 @@ use bevy_egui::egui;
|
||||
use egui_phosphor_icons::icons;
|
||||
use shared::{
|
||||
AnimationClipRecord, AnimationControllerDesc, AnimationManifest, AnimationSkeletonRecord,
|
||||
AnimationStateDesc, EditorAssetRef, ModelRef, COMPONENT_ANIMATION_CONTROLLER_DESC,
|
||||
AnimationStateDesc, EditorAssetRef, SkinnedMeshRenderer, COMPONENT_ANIMATION_CONTROLLER_DESC,
|
||||
};
|
||||
|
||||
use crate::assets::animation::load_animation_manifest;
|
||||
@ -656,8 +656,8 @@ fn manifest_summary(ui: &mut egui::Ui, manifest: &Result<AnimationManifest, Stri
|
||||
|
||||
fn manifest_for_actor(world: &World, entity: Entity) -> Result<AnimationManifest, String> {
|
||||
let model = world
|
||||
.get::<ModelRef>(entity)
|
||||
.ok_or_else(|| "actor has no ModelRef".to_string())?;
|
||||
.get::<SkinnedMeshRenderer>(entity)
|
||||
.ok_or_else(|| "actor has no SkinnedMeshRenderer".to_string())?;
|
||||
let registry = world
|
||||
.get_resource::<AssetRegistry>()
|
||||
.ok_or_else(|| "asset registry is unavailable".to_string())?;
|
||||
@ -878,6 +878,7 @@ mod tests {
|
||||
schema_version: 1,
|
||||
asset_id: "model-id".into(),
|
||||
label: "Robot".into(),
|
||||
default_animation_clip_id: None,
|
||||
source: AnimationManifestSource {
|
||||
path: "assets/models/robot.glb".into(),
|
||||
format: "glb".into(),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -3,7 +3,7 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use bevy::prelude::*;
|
||||
use shared::MaterialAsset;
|
||||
use shared::{MaterialAsset, MaterialInstanceAsset};
|
||||
|
||||
use crate::asset_db::ImportSettings;
|
||||
use crate::assets::{AssetSelection, ASSETS_ROOT, BUILTINS_FOLDER};
|
||||
@ -48,6 +48,7 @@ pub struct AssetBrowserUiState {
|
||||
pub(crate) pending_delete: Option<AssetDeleteRequest>,
|
||||
pub(crate) import_draft: Option<ImportSettingsDraft>,
|
||||
pub(crate) material_draft: Option<MaterialAssetDraft>,
|
||||
pub(crate) material_instance_draft: Option<MaterialInstanceAssetDraft>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@ -71,6 +72,13 @@ pub(crate) struct MaterialAssetDraft {
|
||||
pub(crate) error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct MaterialInstanceAssetDraft {
|
||||
pub(crate) path: String,
|
||||
pub(crate) asset: MaterialInstanceAsset,
|
||||
pub(crate) error: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for AssetBrowserUiState {
|
||||
fn default() -> Self {
|
||||
let mut expanded_folders = HashSet::new();
|
||||
@ -89,6 +97,7 @@ impl Default for AssetBrowserUiState {
|
||||
pending_delete: None,
|
||||
import_draft: None,
|
||||
material_draft: None,
|
||||
material_instance_draft: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,6 +1,12 @@
|
||||
//! Editor-visible component registry for actor inspector palettes.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use bevy::ecs::reflect::ReflectComponent;
|
||||
use bevy::prelude::*;
|
||||
use bevy::reflect::std_traits::ReflectDefault;
|
||||
use bevy::reflect::GetTypeRegistration;
|
||||
use bevy_egui::egui;
|
||||
use egui_phosphor_icons::icons;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@ -9,6 +15,7 @@ pub enum EditorComponentCategory {
|
||||
Rendering,
|
||||
Animation,
|
||||
Audio,
|
||||
Navigation,
|
||||
Physics,
|
||||
Gameplay,
|
||||
Volumes,
|
||||
@ -17,6 +24,8 @@ pub enum EditorComponentCategory {
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EditorComponentDescriptor {
|
||||
/// Stable editor/protocol identity. This must not be a Rust type path.
|
||||
pub id: &'static str,
|
||||
pub type_name: &'static str,
|
||||
pub display_name: &'static str,
|
||||
pub category: EditorComponentCategory,
|
||||
@ -35,6 +44,266 @@ pub struct EditorComponentDescriptor {
|
||||
#[derive(Resource, Debug, Clone)]
|
||||
pub struct EditorComponentRegistry {
|
||||
pub descriptors: Vec<EditorComponentDescriptor>,
|
||||
inspectors: HashMap<&'static str, ComponentInspectorFn>,
|
||||
}
|
||||
|
||||
pub type ComponentInspectorFn = fn(&mut World, &mut egui::Ui, Entity);
|
||||
|
||||
impl EditorComponentRegistry {
|
||||
/// Registers a statically linked component extension.
|
||||
///
|
||||
/// Stable ids and reflected type paths are both unique. Returning an error
|
||||
/// here prevents a game/editor extension from silently shadowing a built-in.
|
||||
pub fn register(&mut self, descriptor: EditorComponentDescriptor) -> Result<(), String> {
|
||||
if self
|
||||
.descriptors
|
||||
.iter()
|
||||
.any(|existing| existing.id == descriptor.id)
|
||||
{
|
||||
return Err(format!(
|
||||
"duplicate authoring component id `{}`",
|
||||
descriptor.id
|
||||
));
|
||||
}
|
||||
if self
|
||||
.descriptors
|
||||
.iter()
|
||||
.any(|existing| existing.type_name == descriptor.type_name)
|
||||
{
|
||||
return Err(format!(
|
||||
"component type `{}` is already registered",
|
||||
descriptor.type_name
|
||||
));
|
||||
}
|
||||
self.descriptors.push(descriptor);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn register_with_inspector(
|
||||
&mut self,
|
||||
descriptor: EditorComponentDescriptor,
|
||||
inspector: ComponentInspectorFn,
|
||||
) -> Result<(), String> {
|
||||
let type_name = descriptor.type_name;
|
||||
self.register(descriptor)?;
|
||||
self.inspectors.insert(type_name, inspector);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn inspector(&self, type_name: &str) -> Option<ComponentInspectorFn> {
|
||||
self.inspectors.get(type_name).copied()
|
||||
}
|
||||
|
||||
/// Hard lifecycle requirements keyed by stable component IDs. Keeping this
|
||||
/// lookup in the registry removes requirement policy from inspector code.
|
||||
pub fn required_component_ids(
|
||||
&self,
|
||||
descriptor: &EditorComponentDescriptor,
|
||||
) -> &'static [&'static str] {
|
||||
match descriptor.id {
|
||||
shared::AUTHORING_COMPONENT_ANIMATION_CONTROLLER => {
|
||||
&[shared::AUTHORING_COMPONENT_SKINNED_MESH_RENDERER]
|
||||
}
|
||||
_ => &[],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn present_dependents(
|
||||
&self,
|
||||
world: &World,
|
||||
entity: Entity,
|
||||
required_id: &str,
|
||||
) -> Vec<&EditorComponentDescriptor> {
|
||||
self.descriptors
|
||||
.iter()
|
||||
.filter(|descriptor| {
|
||||
self.required_component_ids(descriptor)
|
||||
.contains(&required_id)
|
||||
&& self.component_present(world, entity, descriptor.type_name)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn by_id(&self, id: &str) -> Option<&EditorComponentDescriptor> {
|
||||
self.descriptors
|
||||
.iter()
|
||||
.find(|descriptor| descriptor.id == id)
|
||||
}
|
||||
|
||||
pub fn by_type_name(&self, type_name: &str) -> Option<&EditorComponentDescriptor> {
|
||||
self.descriptors
|
||||
.iter()
|
||||
.find(|descriptor| descriptor.type_name == type_name)
|
||||
}
|
||||
|
||||
pub fn stable_id_for_type<'a>(&self, type_name: &'a str) -> &'a str {
|
||||
self.by_type_name(type_name)
|
||||
.map(|descriptor| descriptor.id)
|
||||
.unwrap_or(type_name)
|
||||
}
|
||||
|
||||
pub fn ensure_component_order(&self, order: &mut shared::InspectorOrder, present: &[&str]) {
|
||||
for type_name in present {
|
||||
let component_id = self.stable_id_for_type(type_name);
|
||||
if !order
|
||||
.component_ids
|
||||
.iter()
|
||||
.any(|existing| existing == component_id)
|
||||
{
|
||||
order.component_ids.push(component_id.to_string());
|
||||
}
|
||||
}
|
||||
order.component_type_names.clear();
|
||||
}
|
||||
|
||||
pub fn ordered_components<'a>(
|
||||
&self,
|
||||
order: &shared::InspectorOrder,
|
||||
present: &[&'a str],
|
||||
) -> Vec<&'a str> {
|
||||
let mut ordered = Vec::new();
|
||||
for component_id in &order.component_ids {
|
||||
if let Some(type_name) = present
|
||||
.iter()
|
||||
.find(|type_name| self.stable_id_for_type(type_name) == component_id)
|
||||
{
|
||||
ordered.push(*type_name);
|
||||
}
|
||||
}
|
||||
for legacy_type_name in &order.component_type_names {
|
||||
if let Some(type_name) = present
|
||||
.iter()
|
||||
.find(|type_name| **type_name == legacy_type_name)
|
||||
{
|
||||
if !ordered.contains(type_name) {
|
||||
ordered.push(*type_name);
|
||||
}
|
||||
}
|
||||
}
|
||||
for type_name in present {
|
||||
if !ordered.contains(type_name) {
|
||||
ordered.push(*type_name);
|
||||
}
|
||||
}
|
||||
ordered
|
||||
}
|
||||
|
||||
pub fn move_component(
|
||||
&self,
|
||||
order: &mut shared::InspectorOrder,
|
||||
type_name: &str,
|
||||
offset: isize,
|
||||
present: &[&str],
|
||||
) -> bool {
|
||||
self.ensure_component_order(order, present);
|
||||
let component_id = self.stable_id_for_type(type_name);
|
||||
let Some(index) = order
|
||||
.component_ids
|
||||
.iter()
|
||||
.position(|existing| existing == component_id)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let target =
|
||||
(index as isize + offset).clamp(0, order.component_ids.len() as isize - 1) as usize;
|
||||
if index == target {
|
||||
return false;
|
||||
}
|
||||
let item = order.component_ids.remove(index);
|
||||
order.component_ids.insert(target, item);
|
||||
true
|
||||
}
|
||||
|
||||
pub fn component_present(&self, world: &World, entity: Entity, type_name: &str) -> bool {
|
||||
let Ok(entity_ref) = world.get_entity(entity) else {
|
||||
return false;
|
||||
};
|
||||
let Some(app_registry) = world.get_resource::<AppTypeRegistry>() else {
|
||||
return false;
|
||||
};
|
||||
let type_registry = app_registry.read();
|
||||
type_registry
|
||||
.get_with_type_path(type_name)
|
||||
.and_then(|registration| registration.data::<ReflectComponent>())
|
||||
.is_some_and(|component| component.contains(entity_ref))
|
||||
}
|
||||
|
||||
/// Validates registry metadata against Bevy reflection before the first
|
||||
/// inspector frame. This is also the contract test used by extensions.
|
||||
pub fn validate(&self, world: &World) -> Result<(), Vec<String>> {
|
||||
let Some(app_registry) = world.get_resource::<AppTypeRegistry>() else {
|
||||
return Err(vec!["AppTypeRegistry is unavailable".to_string()]);
|
||||
};
|
||||
let type_registry = app_registry.read();
|
||||
let mut errors = Vec::new();
|
||||
let mut ids = HashSet::new();
|
||||
let mut type_names = HashSet::new();
|
||||
for descriptor in &self.descriptors {
|
||||
if descriptor.id.trim().is_empty() {
|
||||
errors.push(format!(
|
||||
"{} has an empty stable id",
|
||||
descriptor.display_name
|
||||
));
|
||||
} else if !ids.insert(descriptor.id) {
|
||||
errors.push(format!("duplicate stable id `{}`", descriptor.id));
|
||||
}
|
||||
if !type_names.insert(descriptor.type_name) {
|
||||
errors.push(format!(
|
||||
"duplicate reflected type `{}`",
|
||||
descriptor.type_name
|
||||
));
|
||||
}
|
||||
match type_registry.get_with_type_path(descriptor.type_name) {
|
||||
Some(registration) if registration.data::<ReflectComponent>().is_none() => errors
|
||||
.push(format!(
|
||||
"`{}` is registered without ReflectComponent",
|
||||
descriptor.type_name
|
||||
)),
|
||||
None => errors.push(format!(
|
||||
"reflected component `{}` is not registered",
|
||||
descriptor.type_name
|
||||
)),
|
||||
Some(registration)
|
||||
if descriptor.addable && registration.data::<ReflectDefault>().is_none() =>
|
||||
{
|
||||
errors.push(format!(
|
||||
"addable component `{}` has no reflected Default",
|
||||
descriptor.type_name
|
||||
));
|
||||
}
|
||||
Some(_) => {}
|
||||
}
|
||||
for relation in descriptor
|
||||
.recommended
|
||||
.iter()
|
||||
.chain(descriptor.conflicts_with.iter())
|
||||
{
|
||||
if !self
|
||||
.descriptors
|
||||
.iter()
|
||||
.any(|candidate| candidate.type_name == *relation)
|
||||
{
|
||||
errors.push(format!(
|
||||
"{} references unregistered component `{relation}`",
|
||||
descriptor.id
|
||||
));
|
||||
}
|
||||
}
|
||||
for required_id in self.required_component_ids(descriptor) {
|
||||
if self.by_id(required_id).is_none() {
|
||||
errors.push(format!(
|
||||
"{} requires unknown stable component id `{required_id}`",
|
||||
descriptor.id
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
if errors.is_empty() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(errors)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for EditorComponentRegistry {
|
||||
@ -42,6 +311,7 @@ impl Default for EditorComponentRegistry {
|
||||
Self {
|
||||
descriptors: vec![
|
||||
EditorComponentDescriptor {
|
||||
id: shared::AUTHORING_COMPONENT_STATIC_MESH_RENDERER,
|
||||
type_name: "shared::components::StaticMeshRenderer",
|
||||
display_name: "Static Mesh Renderer",
|
||||
category: EditorComponentCategory::Rendering,
|
||||
@ -56,11 +326,35 @@ impl Default for EditorComponentRegistry {
|
||||
conflicts_with: &[
|
||||
"shared::components::Primitive",
|
||||
"shared::components::BrushDesc",
|
||||
"shared::animation::SkinnedMeshRenderer",
|
||||
],
|
||||
hydration_effect:
|
||||
"Hydrates into generated mesh children and material bindings.",
|
||||
},
|
||||
EditorComponentDescriptor {
|
||||
id: shared::AUTHORING_COMPONENT_SKINNED_MESH_RENDERER,
|
||||
type_name: "shared::animation::SkinnedMeshRenderer",
|
||||
display_name: "Skinned Mesh Renderer",
|
||||
category: EditorComponentCategory::Rendering,
|
||||
addable: false,
|
||||
removable: true,
|
||||
reorderable: true,
|
||||
hidden: false,
|
||||
icon: icons::PERSON_SIMPLE_RUN.as_str(),
|
||||
description:
|
||||
"Renders rigged geometry while preserving its imported joints and skin bindings.",
|
||||
search_terms: &["mesh", "renderer", "skinned", "skeletal", "rigged", "joints"],
|
||||
recommended: &["shared::animation::AnimationControllerDesc"],
|
||||
conflicts_with: &[
|
||||
"shared::components::Primitive",
|
||||
"shared::components::BrushDesc",
|
||||
"shared::components::StaticMeshRenderer",
|
||||
],
|
||||
hydration_effect:
|
||||
"Hydrates through a dedicated scene root containing Bevy skinned meshes and joints.",
|
||||
},
|
||||
EditorComponentDescriptor {
|
||||
id: shared::AUTHORING_COMPONENT_BRUSH,
|
||||
type_name: "shared::components::BrushDesc",
|
||||
display_name: "Brush",
|
||||
category: EditorComponentCategory::Authoring,
|
||||
@ -75,10 +369,12 @@ impl Default for EditorComponentRegistry {
|
||||
conflicts_with: &[
|
||||
"shared::components::Primitive",
|
||||
"shared::components::StaticMeshRenderer",
|
||||
"shared::animation::SkinnedMeshRenderer",
|
||||
],
|
||||
hydration_effect: "Hydrates into generated brush mesh children.",
|
||||
},
|
||||
EditorComponentDescriptor {
|
||||
id: shared::AUTHORING_COMPONENT_MATERIAL,
|
||||
type_name: "shared::components::MaterialDesc",
|
||||
display_name: "Authoring Material",
|
||||
category: EditorComponentCategory::Rendering,
|
||||
@ -94,6 +390,7 @@ impl Default for EditorComponentRegistry {
|
||||
hydration_effect: "Hydrates into Bevy material assets for renderable actors.",
|
||||
},
|
||||
EditorComponentDescriptor {
|
||||
id: shared::AUTHORING_COMPONENT_RIGID_BODY,
|
||||
type_name: "shared::components::RigidBodyDesc",
|
||||
display_name: "Rigid Body",
|
||||
category: EditorComponentCategory::Physics,
|
||||
@ -109,6 +406,7 @@ impl Default for EditorComponentRegistry {
|
||||
hydration_effect: "Hydrates into runtime Avian rigid-body state.",
|
||||
},
|
||||
EditorComponentDescriptor {
|
||||
id: shared::AUTHORING_COMPONENT_COLLIDER,
|
||||
type_name: "shared::components::ColliderDesc",
|
||||
display_name: "Collider",
|
||||
category: EditorComponentCategory::Physics,
|
||||
@ -124,6 +422,7 @@ impl Default for EditorComponentRegistry {
|
||||
hydration_effect: "Hydrates into runtime Avian collider state.",
|
||||
},
|
||||
EditorComponentDescriptor {
|
||||
id: shared::AUTHORING_COMPONENT_LIGHT,
|
||||
type_name: "shared::components::LightDesc",
|
||||
display_name: "Light",
|
||||
category: EditorComponentCategory::Rendering,
|
||||
@ -139,6 +438,7 @@ impl Default for EditorComponentRegistry {
|
||||
hydration_effect: "Hydrates into runtime Bevy light components.",
|
||||
},
|
||||
EditorComponentDescriptor {
|
||||
id: shared::AUTHORING_COMPONENT_ANIMATION_CONTROLLER,
|
||||
type_name: "shared::animation::AnimationControllerDesc",
|
||||
display_name: "Animation Controller",
|
||||
category: EditorComponentCategory::Animation,
|
||||
@ -149,12 +449,13 @@ impl Default for EditorComponentRegistry {
|
||||
icon: icons::FILM_SLATE.as_str(),
|
||||
description: "Authors named animation states for an imported model rig.",
|
||||
search_terms: &["animation", "controller", "state", "clip", "skeleton", "rig"],
|
||||
recommended: &[],
|
||||
recommended: &["shared::animation::SkinnedMeshRenderer"],
|
||||
conflicts_with: &[],
|
||||
hydration_effect:
|
||||
"Hydrates into a Bevy animation graph and player bound to the imported model.",
|
||||
},
|
||||
EditorComponentDescriptor {
|
||||
id: shared::AUTHORING_COMPONENT_AUDIO_SOURCE,
|
||||
type_name: "shared::components::AudioSourceDesc",
|
||||
display_name: "Audio Source",
|
||||
category: EditorComponentCategory::Audio,
|
||||
@ -171,6 +472,7 @@ impl Default for EditorComponentRegistry {
|
||||
"Hydrates into runtime Bevy audio voices with bus and attenuation control.",
|
||||
},
|
||||
EditorComponentDescriptor {
|
||||
id: shared::AUTHORING_COMPONENT_AUDIO_LISTENER,
|
||||
type_name: "shared::components::AudioListenerDesc",
|
||||
display_name: "Audio Listener",
|
||||
category: EditorComponentCategory::Audio,
|
||||
@ -187,6 +489,7 @@ impl Default for EditorComponentRegistry {
|
||||
"The highest-priority enabled listener becomes the runtime listener.",
|
||||
},
|
||||
EditorComponentDescriptor {
|
||||
id: shared::AUTHORING_COMPONENT_PRIMITIVE,
|
||||
type_name: "shared::components::Primitive",
|
||||
display_name: "Primitive",
|
||||
category: EditorComponentCategory::Authoring,
|
||||
@ -201,11 +504,13 @@ impl Default for EditorComponentRegistry {
|
||||
conflicts_with: &[
|
||||
"shared::components::StaticMeshRenderer",
|
||||
"shared::components::BrushDesc",
|
||||
"shared::animation::SkinnedMeshRenderer",
|
||||
],
|
||||
hydration_effect:
|
||||
"Hydrates into generated primitive render and collision data.",
|
||||
},
|
||||
EditorComponentDescriptor {
|
||||
id: shared::AUTHORING_COMPONENT_PLAYER_SPAWN,
|
||||
type_name: "shared::components::PlayerSpawn",
|
||||
display_name: "Player Spawn",
|
||||
category: EditorComponentCategory::Gameplay,
|
||||
@ -222,6 +527,7 @@ impl Default for EditorComponentRegistry {
|
||||
"Used by PIE/session startup; no saved runtime player is created.",
|
||||
},
|
||||
EditorComponentDescriptor {
|
||||
id: shared::AUTHORING_COMPONENT_WEAPON_SPAWN,
|
||||
type_name: "shared::components::WeaponSpawn",
|
||||
display_name: "Weapon Spawn",
|
||||
category: EditorComponentCategory::Gameplay,
|
||||
@ -237,6 +543,7 @@ impl Default for EditorComponentRegistry {
|
||||
hydration_effect: "Hydrates into gameplay spawn metadata.",
|
||||
},
|
||||
EditorComponentDescriptor {
|
||||
id: shared::AUTHORING_COMPONENT_TRIGGER_VOLUME,
|
||||
type_name: "shared::components::TriggerVolume",
|
||||
display_name: "Trigger Volume",
|
||||
category: EditorComponentCategory::Volumes,
|
||||
@ -252,6 +559,7 @@ impl Default for EditorComponentRegistry {
|
||||
hydration_effect: "Hydrates into gameplay trigger queries and visualizers.",
|
||||
},
|
||||
EditorComponentDescriptor {
|
||||
id: shared::AUTHORING_COMPONENT_TEAM_SPAWN,
|
||||
type_name: "shared::components::TeamSpawn",
|
||||
display_name: "Team Spawn",
|
||||
category: EditorComponentCategory::Gameplay,
|
||||
@ -267,6 +575,7 @@ impl Default for EditorComponentRegistry {
|
||||
hydration_effect: "Hydrates into gameplay spawn metadata.",
|
||||
},
|
||||
EditorComponentDescriptor {
|
||||
id: shared::AUTHORING_COMPONENT_OBJECTIVE,
|
||||
type_name: "shared::components::ObjectiveMarker",
|
||||
display_name: "Objective Marker",
|
||||
category: EditorComponentCategory::Gameplay,
|
||||
@ -282,6 +591,7 @@ impl Default for EditorComponentRegistry {
|
||||
hydration_effect: "Hydrates into gameplay objective metadata.",
|
||||
},
|
||||
EditorComponentDescriptor {
|
||||
id: shared::AUTHORING_COMPONENT_POST_PROCESS_VOLUME,
|
||||
type_name: "shared::components::PostProcessVolumeDesc",
|
||||
display_name: "Post-Process Volume",
|
||||
category: EditorComponentCategory::Volumes,
|
||||
@ -298,6 +608,215 @@ impl Default for EditorComponentRegistry {
|
||||
"Affects active camera rendering when the camera is inside the volume.",
|
||||
},
|
||||
EditorComponentDescriptor {
|
||||
id: shared::AUTHORING_COMPONENT_NAVIGATION_BOUNDS,
|
||||
type_name: "shared::navigation::NavigationBounds",
|
||||
display_name: "Navigation Bounds",
|
||||
category: EditorComponentCategory::Navigation,
|
||||
addable: true,
|
||||
removable: true,
|
||||
reorderable: true,
|
||||
hidden: false,
|
||||
icon: icons::PATH.as_str(),
|
||||
description: "Defines the world region and agent profile for a navigation bake.",
|
||||
search_terms: &["navigation", "navmesh", "bounds", "agent", "bake"],
|
||||
recommended: &[],
|
||||
conflicts_with: &[
|
||||
"shared::navigation::NavigationObstacle",
|
||||
"shared::navigation::NavigationArea",
|
||||
"shared::navigation::NavigationLink",
|
||||
],
|
||||
hydration_effect: "Feeds the generated navigation artifact; no render component is hydrated.",
|
||||
},
|
||||
EditorComponentDescriptor {
|
||||
id: shared::AUTHORING_COMPONENT_NAVIGATION_OBSTACLE,
|
||||
type_name: "shared::navigation::NavigationObstacle",
|
||||
display_name: "Navigation Obstacle",
|
||||
category: EditorComponentCategory::Navigation,
|
||||
addable: true,
|
||||
removable: true,
|
||||
reorderable: true,
|
||||
hidden: false,
|
||||
icon: icons::SELECTION_SLASH.as_str(),
|
||||
description: "Carves an axis-aligned blocked volume from overlapping bakes.",
|
||||
search_terms: &["navigation", "navmesh", "obstacle", "block", "carve"],
|
||||
recommended: &[],
|
||||
conflicts_with: &[
|
||||
"shared::navigation::NavigationBounds",
|
||||
"shared::navigation::NavigationArea",
|
||||
"shared::navigation::NavigationLink",
|
||||
],
|
||||
hydration_effect: "Contributes a deterministic unwalkable volume during bake.",
|
||||
},
|
||||
EditorComponentDescriptor {
|
||||
id: shared::AUTHORING_COMPONENT_NAVIGATION_AREA,
|
||||
type_name: "shared::navigation::NavigationArea",
|
||||
display_name: "Navigation Area",
|
||||
category: EditorComponentCategory::Navigation,
|
||||
addable: true,
|
||||
removable: true,
|
||||
reorderable: true,
|
||||
hidden: false,
|
||||
icon: icons::POLYGON.as_str(),
|
||||
description: "Labels a walkable or blocked navigation area and traversal cost.",
|
||||
search_terms: &["navigation", "navmesh", "area", "cost", "walkable"],
|
||||
recommended: &[],
|
||||
conflicts_with: &[
|
||||
"shared::navigation::NavigationBounds",
|
||||
"shared::navigation::NavigationObstacle",
|
||||
"shared::navigation::NavigationLink",
|
||||
],
|
||||
hydration_effect: "Stores area metadata and blocked-volume contribution in the bake artifact.",
|
||||
},
|
||||
EditorComponentDescriptor {
|
||||
id: shared::AUTHORING_COMPONENT_NAVIGATION_LINK,
|
||||
type_name: "shared::navigation::NavigationLink",
|
||||
display_name: "Navigation Link",
|
||||
category: EditorComponentCategory::Navigation,
|
||||
addable: true,
|
||||
removable: true,
|
||||
reorderable: true,
|
||||
hidden: false,
|
||||
icon: icons::LINK.as_str(),
|
||||
description: "Connects otherwise isolated navigation regions with an explicit traversal.",
|
||||
search_terms: &["navigation", "navmesh", "link", "jump", "door", "ladder"],
|
||||
recommended: &[],
|
||||
conflicts_with: &[
|
||||
"shared::navigation::NavigationBounds",
|
||||
"shared::navigation::NavigationObstacle",
|
||||
"shared::navigation::NavigationArea",
|
||||
],
|
||||
hydration_effect: "Adds a validated directed or bidirectional runtime path connection.",
|
||||
},
|
||||
EditorComponentDescriptor {
|
||||
id: shared::AUTHORING_COMPONENT_LEGACY_PHYSICS_BODY,
|
||||
type_name: "shared::components::PhysicsBody",
|
||||
display_name: "Legacy Physics Body",
|
||||
category: EditorComponentCategory::Physics,
|
||||
addable: false,
|
||||
removable: true,
|
||||
reorderable: true,
|
||||
hidden: false,
|
||||
icon: icons::SPHERE.as_str(),
|
||||
description: "Legacy combined physics authoring retained for scene compatibility.",
|
||||
search_terms: &["legacy", "physics", "body"],
|
||||
recommended: &[],
|
||||
conflicts_with: &[],
|
||||
hydration_effect: "Hydrates through the compatibility physics path.",
|
||||
},
|
||||
EditorComponentDescriptor {
|
||||
id: "prefab.instance",
|
||||
type_name: "shared::components::PrefabInstance",
|
||||
display_name: "Prefab Instance",
|
||||
category: EditorComponentCategory::Authoring,
|
||||
addable: false,
|
||||
removable: false,
|
||||
reorderable: true,
|
||||
hidden: false,
|
||||
icon: icons::PACKAGE.as_str(),
|
||||
description: "Stores a linked prefab instance and its overrides.",
|
||||
search_terms: &["prefab", "instance", "link"],
|
||||
recommended: &[],
|
||||
conflicts_with: &[],
|
||||
hydration_effect: "Hydrates linked prefab members beneath this anchor.",
|
||||
},
|
||||
EditorComponentDescriptor {
|
||||
id: "prefab.reference",
|
||||
type_name: "shared::components::PrefabRef",
|
||||
display_name: "Prefab Reference",
|
||||
category: EditorComponentCategory::Authoring,
|
||||
addable: false,
|
||||
removable: false,
|
||||
reorderable: false,
|
||||
hidden: true,
|
||||
icon: icons::PACKAGE.as_str(),
|
||||
description: "Stable source reference owned by a prefab anchor.",
|
||||
search_terms: &["prefab", "reference"],
|
||||
recommended: &[],
|
||||
conflicts_with: &[],
|
||||
hydration_effect: "Resolves the prefab source asset.",
|
||||
},
|
||||
EditorComponentDescriptor {
|
||||
id: "render.imported_model",
|
||||
type_name: "shared::components::ModelRef",
|
||||
display_name: "Imported Model Reference",
|
||||
category: EditorComponentCategory::Rendering,
|
||||
addable: false,
|
||||
removable: false,
|
||||
reorderable: false,
|
||||
hidden: true,
|
||||
icon: icons::CUBE_TRANSPARENT.as_str(),
|
||||
description: "Legacy full-scene imported model reference.",
|
||||
search_terms: &["model", "imported", "scene"],
|
||||
recommended: &[],
|
||||
conflicts_with: &[],
|
||||
hydration_effect: "Hydrates the referenced imported model scene.",
|
||||
},
|
||||
EditorComponentDescriptor {
|
||||
id: "render.material_override_legacy",
|
||||
type_name: "shared::components::MaterialOverride",
|
||||
display_name: "Legacy Material Override",
|
||||
category: EditorComponentCategory::Rendering,
|
||||
addable: false,
|
||||
removable: false,
|
||||
reorderable: false,
|
||||
hidden: true,
|
||||
icon: icons::PALETTE.as_str(),
|
||||
description: "Legacy renderer material slot overrides retained for migration.",
|
||||
search_terms: &["material", "override", "legacy"],
|
||||
recommended: &[],
|
||||
conflicts_with: &[],
|
||||
hydration_effect: "Applies compatibility material overrides.",
|
||||
},
|
||||
EditorComponentDescriptor {
|
||||
id: "render.project_sun",
|
||||
type_name: "shared::components::ProjectSun",
|
||||
display_name: "Project Sun",
|
||||
category: EditorComponentCategory::Rendering,
|
||||
addable: false,
|
||||
removable: false,
|
||||
reorderable: true,
|
||||
hidden: false,
|
||||
icon: icons::SUN.as_str(),
|
||||
description: "Marks the scene light that overrides the project sun.",
|
||||
search_terms: &["sun", "directional", "project"],
|
||||
recommended: &[],
|
||||
conflicts_with: &[],
|
||||
hydration_effect: "Selects the authored directional project light.",
|
||||
},
|
||||
EditorComponentDescriptor {
|
||||
id: "core.actor_kind",
|
||||
type_name: "shared::components::ActorKind",
|
||||
display_name: "Actor Kind Hint",
|
||||
category: EditorComponentCategory::Editor,
|
||||
addable: false,
|
||||
removable: false,
|
||||
reorderable: false,
|
||||
hidden: true,
|
||||
icon: icons::TAG.as_str(),
|
||||
description: "Compatibility and presentation hint derived from primary components.",
|
||||
search_terms: &["actor", "kind", "hint"],
|
||||
recommended: &[],
|
||||
conflicts_with: &[],
|
||||
hydration_effect: "No runtime effect; validation uses component composition.",
|
||||
},
|
||||
EditorComponentDescriptor {
|
||||
id: "editor.component_states",
|
||||
type_name: "shared::components::AuthoringComponentStates",
|
||||
display_name: "Authoring Component States",
|
||||
category: EditorComponentCategory::Editor,
|
||||
addable: false,
|
||||
removable: false,
|
||||
reorderable: false,
|
||||
hidden: true,
|
||||
icon: icons::TOGGLE_LEFT.as_str(),
|
||||
description: "Stores persisted component enable state independently of inspector layout.",
|
||||
search_terms: &["editor", "active", "enabled"],
|
||||
recommended: &[],
|
||||
conflicts_with: &[],
|
||||
hydration_effect: "Controls whether opted-in authoring components hydrate.",
|
||||
},
|
||||
EditorComponentDescriptor {
|
||||
id: "editor.inspector_order",
|
||||
type_name: "shared::components::InspectorOrder",
|
||||
display_name: "Inspector Order",
|
||||
category: EditorComponentCategory::Editor,
|
||||
@ -306,14 +825,62 @@ impl Default for EditorComponentRegistry {
|
||||
reorderable: false,
|
||||
hidden: true,
|
||||
icon: icons::LIST_BULLETS.as_str(),
|
||||
description: "Stores editor-only component ordering and active-state metadata.",
|
||||
description: "Stores editor-only component card ordering.",
|
||||
search_terms: &["editor", "order"],
|
||||
recommended: &[],
|
||||
conflicts_with: &[],
|
||||
hydration_effect:
|
||||
"Controls editor presentation and authoring component active state.",
|
||||
hydration_effect: "Controls editor presentation only; runtime active state is stored separately.",
|
||||
},
|
||||
],
|
||||
inspectors: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Registers one statically linked game/editor component with reflection,
|
||||
/// lifecycle metadata, and its editor inspector in a single call.
|
||||
pub fn register_authoring_component<T>(
|
||||
app: &mut App,
|
||||
descriptor: EditorComponentDescriptor,
|
||||
inspector: ComponentInspectorFn,
|
||||
) -> Result<(), String>
|
||||
where
|
||||
T: Component + Reflect + GetTypeRegistration,
|
||||
{
|
||||
app.register_type::<T>();
|
||||
if !app.world().contains_resource::<EditorComponentRegistry>() {
|
||||
app.init_resource::<EditorComponentRegistry>();
|
||||
}
|
||||
app.world_mut()
|
||||
.resource_mut::<EditorComponentRegistry>()
|
||||
.register_with_inspector(descriptor, inspector)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn built_in_registry_is_unique_and_reflectable() {
|
||||
let mut app = App::new();
|
||||
app.add_plugins(shared::SharedTypesPlugin)
|
||||
.init_resource::<EditorComponentRegistry>();
|
||||
|
||||
app.world()
|
||||
.resource::<EditorComponentRegistry>()
|
||||
.validate(app.world())
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn static_extensions_cannot_shadow_stable_ids_or_types() {
|
||||
let mut registry = EditorComponentRegistry::default();
|
||||
let mut duplicate = registry.descriptors[0].clone();
|
||||
duplicate.type_name = "game::editor_ext::StaticFixture";
|
||||
assert!(registry.register(duplicate).is_err());
|
||||
|
||||
let mut duplicate = registry.descriptors[0].clone();
|
||||
duplicate.id = "game.static_fixture";
|
||||
assert!(registry.register(duplicate).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@ -91,6 +91,7 @@ pub fn light_snapshot(name: &str, translation: Vec3) -> EditorEntitySnapshot {
|
||||
primitive: None,
|
||||
brush: None,
|
||||
static_mesh_renderer: None,
|
||||
skinned_mesh_renderer: None,
|
||||
material: None,
|
||||
material_override: None,
|
||||
rigid_body: None,
|
||||
@ -109,8 +110,18 @@ pub fn light_snapshot(name: &str, translation: Vec3) -> EditorEntitySnapshot {
|
||||
post_process_volume: None,
|
||||
team_spawn: None,
|
||||
objective: None,
|
||||
|
||||
navigation_bounds: None,
|
||||
|
||||
navigation_obstacle: None,
|
||||
|
||||
navigation_area: None,
|
||||
|
||||
navigation_link: None,
|
||||
hierarchy_sibling_index: 0,
|
||||
editor_visibility: shared::EditorVisibility::default(),
|
||||
inspector_order: None,
|
||||
component_states: None,
|
||||
children: Vec::new(),
|
||||
}
|
||||
}
|
||||
@ -142,6 +153,7 @@ pub fn ensure_player_spawn_for_edit(world: &mut World) -> Entity {
|
||||
primitive: None,
|
||||
brush: None,
|
||||
static_mesh_renderer: None,
|
||||
skinned_mesh_renderer: None,
|
||||
material: None,
|
||||
material_override: None,
|
||||
rigid_body: None,
|
||||
@ -160,8 +172,18 @@ pub fn ensure_player_spawn_for_edit(world: &mut World) -> Entity {
|
||||
post_process_volume: None,
|
||||
team_spawn: None,
|
||||
objective: None,
|
||||
|
||||
navigation_bounds: None,
|
||||
|
||||
navigation_obstacle: None,
|
||||
|
||||
navigation_area: None,
|
||||
|
||||
navigation_link: None,
|
||||
hierarchy_sibling_index: 0,
|
||||
editor_visibility: shared::EditorVisibility::default(),
|
||||
inspector_order: None,
|
||||
component_states: None,
|
||||
children: Vec::new(),
|
||||
},
|
||||
)
|
||||
@ -233,6 +255,7 @@ pub fn create_scene_sun_override_from_project_settings(world: &mut World) -> Ent
|
||||
primitive: None,
|
||||
brush: None,
|
||||
static_mesh_renderer: None,
|
||||
skinned_mesh_renderer: None,
|
||||
material: None,
|
||||
material_override: None,
|
||||
rigid_body: None,
|
||||
@ -251,8 +274,18 @@ pub fn create_scene_sun_override_from_project_settings(world: &mut World) -> Ent
|
||||
post_process_volume: None,
|
||||
team_spawn: None,
|
||||
objective: None,
|
||||
|
||||
navigation_bounds: None,
|
||||
|
||||
navigation_obstacle: None,
|
||||
|
||||
navigation_area: None,
|
||||
|
||||
navigation_link: None,
|
||||
hierarchy_sibling_index: 0,
|
||||
editor_visibility: shared::EditorVisibility::default(),
|
||||
inspector_order: None,
|
||||
component_states: None,
|
||||
children: Vec::new(),
|
||||
},
|
||||
)
|
||||
|
||||
@ -4,6 +4,7 @@ use bevy::prelude::*;
|
||||
use shared::{
|
||||
ActorKind, AuthoringLightKind, EditorVisibility, HierarchySiblingIndex, HydratedPrefabMember,
|
||||
LevelObject, LightDesc, ModelRef, PhysicsBody, PlayerSpawn, PrefabInstance,
|
||||
SkinnedMeshRenderer,
|
||||
};
|
||||
|
||||
use super::helpers::{entity_name, is_level_object, HierarchyNodeKind};
|
||||
@ -203,7 +204,10 @@ fn actor_kind_sort_key(world: &World, entity: Entity) -> u8 {
|
||||
.copied()
|
||||
.map(|kind| match kind {
|
||||
ActorKind::Empty => 0,
|
||||
ActorKind::Brush | ActorKind::StaticMesh | ActorKind::ImportedModel => 1,
|
||||
ActorKind::Brush
|
||||
| ActorKind::StaticMesh
|
||||
| ActorKind::SkinnedMesh
|
||||
| ActorKind::ImportedModel => 1,
|
||||
ActorKind::Light => 2,
|
||||
ActorKind::AudioSource | ActorKind::AudioListener => 3,
|
||||
ActorKind::PrefabAnchor => 3,
|
||||
@ -213,6 +217,7 @@ fn actor_kind_sort_key(world: &World, entity: Entity) -> u8 {
|
||||
ActorKind::PostProcessVolume => 6,
|
||||
ActorKind::TeamSpawn => 7,
|
||||
ActorKind::Objective => 8,
|
||||
ActorKind::Navigation => 6,
|
||||
})
|
||||
.unwrap_or(9)
|
||||
}
|
||||
@ -464,6 +469,7 @@ pub fn actor_kind_icon(kind: ActorKind) -> &'static str {
|
||||
ActorKind::Empty => icons::FOLDER.as_str(),
|
||||
ActorKind::Brush => icons::CUBE.as_str(),
|
||||
ActorKind::StaticMesh => icons::CUBE.as_str(),
|
||||
ActorKind::SkinnedMesh => icons::PERSON_SIMPLE_RUN.as_str(),
|
||||
ActorKind::ImportedModel => icons::CUBE_TRANSPARENT.as_str(),
|
||||
ActorKind::Light => icons::LIGHTBULB.as_str(),
|
||||
ActorKind::AudioSource => icons::SPEAKER_HIGH.as_str(),
|
||||
@ -475,6 +481,7 @@ pub fn actor_kind_icon(kind: ActorKind) -> &'static str {
|
||||
ActorKind::PostProcessVolume => icons::CAMERA.as_str(),
|
||||
ActorKind::TeamSpawn => icons::FLAG.as_str(),
|
||||
ActorKind::Objective => icons::TARGET.as_str(),
|
||||
ActorKind::Navigation => icons::PATH.as_str(),
|
||||
}
|
||||
}
|
||||
|
||||
@ -534,6 +541,10 @@ pub fn entity_matches_filter(world: &World, entity: Entity, filter_lower: &str)
|
||||
.is_some()
|
||||
.then_some("spawn"),
|
||||
world.get::<ModelRef>(entity).is_some().then_some("model"),
|
||||
world
|
||||
.get::<SkinnedMeshRenderer>(entity)
|
||||
.is_some()
|
||||
.then_some("skinned model"),
|
||||
world
|
||||
.get::<PhysicsBody>(entity)
|
||||
.is_some()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -158,6 +158,44 @@ pub fn top_menu_bar(
|
||||
crate::rendering_diagnostics::spawn_post_process_volume_at_camera(world);
|
||||
ui.close();
|
||||
}
|
||||
ui.separator();
|
||||
ui.menu_button("Navigation", |ui| {
|
||||
use super::navigation_inspector::{
|
||||
spawn_navigation_actor, NavigationActorType,
|
||||
};
|
||||
for (label, actor_type) in [
|
||||
("Create Bounds", NavigationActorType::Bounds),
|
||||
("Create Obstacle", NavigationActorType::Obstacle),
|
||||
("Create Area", NavigationActorType::Area),
|
||||
("Create Link", NavigationActorType::Link),
|
||||
] {
|
||||
if menu_item(ui, label, None, true).clicked() {
|
||||
spawn_navigation_actor(world, actor_type);
|
||||
ui.close();
|
||||
}
|
||||
}
|
||||
ui.separator();
|
||||
let selected_bounds =
|
||||
selected.as_slice().iter().copied().find(|entity| {
|
||||
world.get::<shared::NavigationBounds>(*entity).is_some()
|
||||
});
|
||||
if menu_item(ui, "Bake Selected Bounds", None, selected_bounds.is_some())
|
||||
.clicked()
|
||||
{
|
||||
if let Some(entity) = selected_bounds {
|
||||
super::navigation_inspector::bake_bounds(world, entity);
|
||||
}
|
||||
ui.close();
|
||||
}
|
||||
if menu_item(ui, "Test Selected Path", None, selected_bounds.is_some())
|
||||
.clicked()
|
||||
{
|
||||
if let Some(entity) = selected_bounds {
|
||||
super::navigation_inspector::query_preview(world, entity);
|
||||
}
|
||||
ui.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
ui.menu_button("Build", |ui| {
|
||||
|
||||
@ -3,7 +3,7 @@ mod animation_inspector;
|
||||
mod asset_browser;
|
||||
mod audio_inspector;
|
||||
mod build;
|
||||
mod component_registry;
|
||||
pub mod component_registry;
|
||||
mod diagnostics;
|
||||
mod dock_tabs;
|
||||
mod fonts;
|
||||
@ -14,6 +14,7 @@ pub mod hierarchy_state;
|
||||
pub(crate) mod inspector;
|
||||
mod layout;
|
||||
mod menu;
|
||||
pub(crate) mod navigation_inspector;
|
||||
mod play_controls;
|
||||
mod post_process_volume_ui;
|
||||
mod scene_tabs;
|
||||
@ -294,6 +295,7 @@ impl Plugin for EditorUiPlugin {
|
||||
.init_resource::<inspector::InspectorClipboard>()
|
||||
.init_resource::<inspector::InspectorPanelState>()
|
||||
.init_resource::<animation_inspector::AnimationInspectorState>()
|
||||
.init_resource::<navigation_inspector::NavigationEditorState>()
|
||||
.init_resource::<ViewportUiState>()
|
||||
.init_resource::<LayoutSaveTimer>()
|
||||
.add_systems(
|
||||
@ -301,6 +303,7 @@ impl Plugin for EditorUiPlugin {
|
||||
init_hierarchy_panel_from_prefs
|
||||
.after(crate::project_io::load_user_preferences_on_startup),
|
||||
)
|
||||
.add_systems(Startup, validate_editor_component_registry)
|
||||
.add_systems(
|
||||
Startup,
|
||||
apply_ui_state_from_prefs
|
||||
@ -312,11 +315,25 @@ impl Plugin for EditorUiPlugin {
|
||||
(
|
||||
hierarchy_state::tick_hierarchy_prefs_save,
|
||||
build::tick_build_job,
|
||||
navigation_inspector::sync_navigation_state,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_editor_component_registry(world: &mut World) {
|
||||
let errors = world
|
||||
.resource::<component_registry::EditorComponentRegistry>()
|
||||
.validate(world)
|
||||
.err();
|
||||
if let Some(errors) = errors {
|
||||
panic!(
|
||||
"invalid authoring component registry:\n{}",
|
||||
errors.join("\n")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn apply_ui_state_from_prefs(mut ui_state: ResMut<UiState>, prefs: Res<UserPreferences>) {
|
||||
*ui_state = UiState::from_prefs(&prefs);
|
||||
}
|
||||
|
||||
765
crates/editor/src/ui/navigation_inspector.rs
Normal file
765
crates/editor/src/ui/navigation_inspector.rs
Normal file
@ -0,0 +1,765 @@
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use bevy::prelude::*;
|
||||
use bevy_egui::egui;
|
||||
use egui_phosphor_icons::icons;
|
||||
use scene::navigation::{
|
||||
aabb_overlaps, bake_navigation, geometry_overlaps_bounds, navigation_geometry_from_parts,
|
||||
navigation_source_fingerprint, point_in_aabb, read_navigation_artifact,
|
||||
write_navigation_artifact, NavigationAreaInput, NavigationBakeArtifact, NavigationBakeInput,
|
||||
NavigationLinkInput, NavigationVolumeInput,
|
||||
};
|
||||
use shared::{
|
||||
ActorId, BrushDesc, NavigationArea, NavigationBounds, NavigationLink, NavigationObstacle,
|
||||
Primitive, COMPONENT_NAVIGATION_AREA, COMPONENT_NAVIGATION_BOUNDS, COMPONENT_NAVIGATION_LINK,
|
||||
COMPONENT_NAVIGATION_OBSTACLE,
|
||||
};
|
||||
|
||||
use crate::history::{set_navigation_with_history, NavigationComponentState};
|
||||
use crate::project_io::ProjectWorkspace;
|
||||
use crate::scene_io::SceneIo;
|
||||
use crate::ui::inspector::{
|
||||
apply_component_card_response, component_card, component_card_context, property_row,
|
||||
ComponentCardOptions,
|
||||
};
|
||||
use crate::ui::theme::{ERROR, SUCCESS, TEXT_DIM, WARNING};
|
||||
use crate::viewport::{editor_spawn_ground_position, snap_translation, ViewportSettings};
|
||||
|
||||
#[derive(Resource, Debug, Clone)]
|
||||
pub struct NavigationEditorState {
|
||||
pub bounds_entity: Option<Entity>,
|
||||
pub artifact: Option<NavigationBakeArtifact>,
|
||||
pub preview_start: Vec3,
|
||||
pub preview_end: Vec3,
|
||||
pub preview_path: Vec<Vec3>,
|
||||
pub status: String,
|
||||
pub error: bool,
|
||||
pub show_mesh: bool,
|
||||
observed_fingerprint: Option<String>,
|
||||
auto_bake_attempt: Option<String>,
|
||||
debounce_elapsed: f32,
|
||||
}
|
||||
|
||||
impl Default for NavigationEditorState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
bounds_entity: None,
|
||||
artifact: None,
|
||||
preview_start: Vec3::new(-4.0, 0.0, 0.0),
|
||||
preview_end: Vec3::new(4.0, 0.0, 0.0),
|
||||
preview_path: Vec::new(),
|
||||
status: "Not baked".into(),
|
||||
error: false,
|
||||
show_mesh: true,
|
||||
observed_fingerprint: None,
|
||||
auto_bake_attempt: None,
|
||||
debounce_elapsed: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn navigation_bounds_inspector_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) {
|
||||
let Some(mut bounds) = world.get::<NavigationBounds>(entity).cloned() else {
|
||||
return;
|
||||
};
|
||||
let original = bounds.clone();
|
||||
let mut preview_start = world.resource::<NavigationEditorState>().preview_start;
|
||||
let mut preview_end = world.resource::<NavigationEditorState>().preview_end;
|
||||
let mut show_mesh = world.resource::<NavigationEditorState>().show_mesh;
|
||||
let status = world.resource::<NavigationEditorState>().status.clone();
|
||||
let status_error = world.resource::<NavigationEditorState>().error;
|
||||
let current = build_input(world, entity, &bounds).ok();
|
||||
let stale = current.as_ref().is_some_and(|input| {
|
||||
world
|
||||
.resource::<NavigationEditorState>()
|
||||
.artifact
|
||||
.as_ref()
|
||||
.is_none_or(|artifact| artifact.is_stale_for(input))
|
||||
});
|
||||
let mut bake = false;
|
||||
let mut preview = false;
|
||||
|
||||
let card = component_card_context(
|
||||
world,
|
||||
entity,
|
||||
ComponentCardOptions::removable(
|
||||
COMPONENT_NAVIGATION_BOUNDS,
|
||||
"Navigation Bounds",
|
||||
icons::PATH,
|
||||
),
|
||||
);
|
||||
let response = component_card(ui, &card, |ui| {
|
||||
property_row(ui, "Half extents", |ui| {
|
||||
vec3_editor(ui, &mut bounds.half_extents, 0.1)
|
||||
});
|
||||
property_row(ui, "Agent ID", |ui| {
|
||||
ui.text_edit_singleline(&mut bounds.agent.id)
|
||||
});
|
||||
property_row(ui, "Radius / height", |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(egui::DragValue::new(&mut bounds.agent.radius).speed(0.01));
|
||||
ui.add(egui::DragValue::new(&mut bounds.agent.height).speed(0.01))
|
||||
})
|
||||
.response
|
||||
});
|
||||
property_row(ui, "Climb / slope", |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(egui::DragValue::new(&mut bounds.agent.max_climb).speed(0.01));
|
||||
ui.add(
|
||||
egui::DragValue::new(&mut bounds.agent.max_slope_deg)
|
||||
.speed(0.25)
|
||||
.suffix(" deg"),
|
||||
)
|
||||
})
|
||||
.response
|
||||
});
|
||||
property_row(ui, "Voxel detail", |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(
|
||||
egui::DragValue::new(&mut bounds.agent.cell_size_fraction)
|
||||
.speed(0.1)
|
||||
.range(1.0..=12.0),
|
||||
);
|
||||
ui.add(
|
||||
egui::DragValue::new(&mut bounds.agent.cell_height_fraction)
|
||||
.speed(0.1)
|
||||
.range(1.0..=24.0),
|
||||
)
|
||||
})
|
||||
.response
|
||||
});
|
||||
property_row(ui, "Artifact", |ui| {
|
||||
ui.text_edit_singleline(&mut bounds.artifact_path)
|
||||
});
|
||||
property_row(ui, "Auto bake", |ui| {
|
||||
ui.checkbox(&mut bounds.auto_bake, "Debounced")
|
||||
});
|
||||
|
||||
ui.separator();
|
||||
ui.horizontal(|ui| {
|
||||
bake = ui
|
||||
.button(format!("{} Bake", icons::HAMMER.as_str()))
|
||||
.on_hover_text("Bake deterministic navigation artifact")
|
||||
.clicked();
|
||||
preview = ui
|
||||
.add_enabled(
|
||||
!stale,
|
||||
egui::Button::new(format!("{} Test Path", icons::PATH.as_str())),
|
||||
)
|
||||
.on_hover_text("Query the baked artifact using the game runtime API")
|
||||
.clicked();
|
||||
ui.checkbox(&mut show_mesh, "Overlay");
|
||||
});
|
||||
let status_color = if status_error {
|
||||
ERROR
|
||||
} else if stale {
|
||||
WARNING
|
||||
} else {
|
||||
SUCCESS
|
||||
};
|
||||
ui.colored_label(
|
||||
status_color,
|
||||
if stale {
|
||||
format!("Stale: {status}")
|
||||
} else {
|
||||
status.clone()
|
||||
},
|
||||
);
|
||||
|
||||
property_row(ui, "Path start", |ui| {
|
||||
vec3_editor(ui, &mut preview_start, 0.1)
|
||||
});
|
||||
property_row(ui, "Path end", |ui| vec3_editor(ui, &mut preview_end, 0.1));
|
||||
if let Some(input) = current.as_ref() {
|
||||
ui.label(
|
||||
egui::RichText::new(format!(
|
||||
"{} obstacles {} areas {} links",
|
||||
input.obstacles.len(),
|
||||
input.areas.len(),
|
||||
input.links.len()
|
||||
))
|
||||
.small()
|
||||
.color(TEXT_DIM),
|
||||
);
|
||||
}
|
||||
});
|
||||
apply_component_card_response(world, entity, response);
|
||||
|
||||
if bounds != original {
|
||||
set_navigation_with_history(
|
||||
world,
|
||||
entity,
|
||||
NavigationComponentState {
|
||||
bounds: Some(bounds.clone()),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
{
|
||||
let mut state = world.resource_mut::<NavigationEditorState>();
|
||||
state.preview_start = preview_start;
|
||||
state.preview_end = preview_end;
|
||||
state.show_mesh = show_mesh;
|
||||
}
|
||||
if bake {
|
||||
bake_bounds(world, entity);
|
||||
}
|
||||
if preview {
|
||||
query_preview(world, entity);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn navigation_obstacle_inspector_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) {
|
||||
let Some(mut value) = world.get::<NavigationObstacle>(entity).cloned() else {
|
||||
return;
|
||||
};
|
||||
let original = value.clone();
|
||||
let card = component_card_context(
|
||||
world,
|
||||
entity,
|
||||
ComponentCardOptions::removable(
|
||||
COMPONENT_NAVIGATION_OBSTACLE,
|
||||
"Navigation Obstacle",
|
||||
icons::SELECTION_SLASH,
|
||||
),
|
||||
);
|
||||
let response = component_card(ui, &card, |ui| {
|
||||
property_row(ui, "Half extents", |ui| {
|
||||
vec3_editor(ui, &mut value.half_extents, 0.05)
|
||||
});
|
||||
property_row(ui, "Runtime", |ui| {
|
||||
ui.checkbox(&mut value.dynamic, "Dynamic obstacle")
|
||||
});
|
||||
if value.dynamic {
|
||||
ui.colored_label(
|
||||
WARNING,
|
||||
"Dynamic carving is not baked in navigation v1; this volume remains a static bake input.",
|
||||
);
|
||||
}
|
||||
});
|
||||
apply_component_card_response(world, entity, response);
|
||||
if value != original {
|
||||
set_navigation_with_history(
|
||||
world,
|
||||
entity,
|
||||
NavigationComponentState {
|
||||
obstacle: Some(value),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn navigation_area_inspector_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) {
|
||||
let Some(mut value) = world.get::<NavigationArea>(entity).cloned() else {
|
||||
return;
|
||||
};
|
||||
let original = value.clone();
|
||||
let card = component_card_context(
|
||||
world,
|
||||
entity,
|
||||
ComponentCardOptions::removable(
|
||||
COMPONENT_NAVIGATION_AREA,
|
||||
"Navigation Area",
|
||||
icons::POLYGON,
|
||||
),
|
||||
);
|
||||
let response = component_card(ui, &card, |ui| {
|
||||
property_row(ui, "Area ID", |ui| ui.text_edit_singleline(&mut value.id));
|
||||
property_row(ui, "Half extents", |ui| {
|
||||
vec3_editor(ui, &mut value.half_extents, 0.05)
|
||||
});
|
||||
property_row(ui, "Cost", |ui| {
|
||||
ui.add(
|
||||
egui::DragValue::new(&mut value.cost)
|
||||
.speed(0.05)
|
||||
.range(0.05..=100.0),
|
||||
)
|
||||
});
|
||||
property_row(ui, "Walkable", |ui| {
|
||||
ui.checkbox(&mut value.walkable, "Enabled")
|
||||
});
|
||||
});
|
||||
apply_component_card_response(world, entity, response);
|
||||
if value != original {
|
||||
set_navigation_with_history(
|
||||
world,
|
||||
entity,
|
||||
NavigationComponentState {
|
||||
area: Some(value),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn navigation_link_inspector_ui(world: &mut World, ui: &mut egui::Ui, entity: Entity) {
|
||||
let Some(mut value) = world.get::<NavigationLink>(entity).cloned() else {
|
||||
return;
|
||||
};
|
||||
let original = value.clone();
|
||||
let card = component_card_context(
|
||||
world,
|
||||
entity,
|
||||
ComponentCardOptions::removable(COMPONENT_NAVIGATION_LINK, "Navigation Link", icons::LINK),
|
||||
);
|
||||
let response = component_card(ui, &card, |ui| {
|
||||
property_row(ui, "Start", |ui| vec3_editor(ui, &mut value.start, 0.05));
|
||||
property_row(ui, "End", |ui| vec3_editor(ui, &mut value.end, 0.05));
|
||||
property_row(ui, "Direction", |ui| {
|
||||
ui.checkbox(&mut value.bidirectional, "Bidirectional")
|
||||
});
|
||||
property_row(ui, "Cost", |ui| {
|
||||
ui.add(
|
||||
egui::DragValue::new(&mut value.cost)
|
||||
.speed(0.05)
|
||||
.range(0.05..=100.0),
|
||||
)
|
||||
});
|
||||
property_row(ui, "Enabled", |ui| {
|
||||
ui.checkbox(&mut value.enabled, "Active")
|
||||
});
|
||||
});
|
||||
apply_component_card_response(world, entity, response);
|
||||
if value != original {
|
||||
set_navigation_with_history(
|
||||
world,
|
||||
entity,
|
||||
NavigationComponentState {
|
||||
link: Some(value),
|
||||
..Default::default()
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn bake_bounds(world: &mut World, entity: Entity) {
|
||||
let Some(bounds) = world.get::<NavigationBounds>(entity).cloned() else {
|
||||
set_status(
|
||||
world,
|
||||
"Bake failed: selected actor has no Navigation Bounds",
|
||||
true,
|
||||
);
|
||||
return;
|
||||
};
|
||||
let input = match build_input(world, entity, &bounds) {
|
||||
Ok(input) => input,
|
||||
Err(error) => {
|
||||
set_status(world, format!("Bake failed: {error}"), true);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let artifact = match bake_navigation(&input) {
|
||||
Ok(artifact) => artifact,
|
||||
Err(error) => {
|
||||
set_status(world, format!("Bake failed: {error}"), true);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let path = match artifact_path(world, &bounds.artifact_path) {
|
||||
Ok(path) => path,
|
||||
Err(error) => {
|
||||
set_status(world, format!("Bake failed: {error}"), true);
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Err(error) = write_navigation_artifact(&path, &artifact) {
|
||||
set_status(world, format!("Bake failed: {error}"), true);
|
||||
return;
|
||||
}
|
||||
let polygon_count: usize = artifact
|
||||
.mesh
|
||||
.layers
|
||||
.iter()
|
||||
.map(|layer| layer.polygons.len())
|
||||
.sum();
|
||||
let mut state = world.resource_mut::<NavigationEditorState>();
|
||||
state.bounds_entity = Some(entity);
|
||||
state.artifact = Some(artifact);
|
||||
state.preview_path.clear();
|
||||
state.error = false;
|
||||
state.status = format!("Baked {polygon_count} polygons to {}", bounds.artifact_path);
|
||||
}
|
||||
|
||||
pub fn query_preview(world: &mut World, entity: Entity) {
|
||||
let (artifact, start, end) = {
|
||||
let state = world.resource::<NavigationEditorState>();
|
||||
(
|
||||
state.artifact.clone(),
|
||||
state.preview_start,
|
||||
state.preview_end,
|
||||
)
|
||||
};
|
||||
let Some(artifact) = artifact else {
|
||||
set_status(world, "Path test failed: bake navigation first", true);
|
||||
return;
|
||||
};
|
||||
let runtime = match game::navigation::NavigationRuntime::from_artifact(artifact) {
|
||||
Ok(runtime) => runtime,
|
||||
Err(error) => {
|
||||
set_status(world, format!("Path test failed: {error}"), true);
|
||||
return;
|
||||
}
|
||||
};
|
||||
match runtime.query(start, end) {
|
||||
Ok(path) => {
|
||||
let mut state = world.resource_mut::<NavigationEditorState>();
|
||||
state.bounds_entity = Some(entity);
|
||||
state.preview_path = path.points;
|
||||
state.error = false;
|
||||
state.status = format!(
|
||||
"Path {:.2} m, {} waypoints{}",
|
||||
path.length,
|
||||
state.preview_path.len(),
|
||||
path.used_link_actor_id
|
||||
.as_deref()
|
||||
.map(|id| format!(", link {id}"))
|
||||
.unwrap_or_default()
|
||||
);
|
||||
}
|
||||
Err(error) => set_status(world, format!("Path test failed: {error}"), true),
|
||||
}
|
||||
}
|
||||
|
||||
fn build_input(
|
||||
world: &mut World,
|
||||
bounds_entity: Entity,
|
||||
bounds: &NavigationBounds,
|
||||
) -> Result<NavigationBakeInput, String> {
|
||||
let active_path = world
|
||||
.resource::<SceneIo>()
|
||||
.active_path
|
||||
.as_ref()
|
||||
.cloned()
|
||||
.ok_or_else(|| "save the scene before baking navigation".to_string())?;
|
||||
let project_root = Path::new(&world.resource::<ProjectWorkspace>().root);
|
||||
let source_scene = active_path
|
||||
.strip_prefix(project_root)
|
||||
.unwrap_or(&active_path)
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/");
|
||||
let bounds_actor_id = actor_id(world, bounds_entity)?;
|
||||
let bounds_transform = world_transform(world, bounds_entity);
|
||||
let center = bounds_transform.translation.to_array();
|
||||
let half_extents = world_aabb_half_extents(bounds_transform, bounds.half_extents).to_array();
|
||||
|
||||
let mut obstacles = Vec::new();
|
||||
let mut obstacle_query = world.query::<(Entity, &NavigationObstacle)>();
|
||||
for (entity, obstacle) in obstacle_query.iter(world) {
|
||||
let value = NavigationVolumeInput {
|
||||
actor_id: actor_id(world, entity)?,
|
||||
center: world_position(world, entity).to_array(),
|
||||
half_extents: world_aabb_half_extents(
|
||||
world_transform(world, entity),
|
||||
obstacle.half_extents,
|
||||
)
|
||||
.to_array(),
|
||||
};
|
||||
if aabb_overlaps(center, half_extents, value.center, value.half_extents) {
|
||||
obstacles.push(value);
|
||||
}
|
||||
}
|
||||
obstacles.sort_by(|a, b| a.actor_id.cmp(&b.actor_id));
|
||||
|
||||
let mut areas = Vec::new();
|
||||
let mut area_query = world.query::<(Entity, &NavigationArea)>();
|
||||
for (entity, area) in area_query.iter(world) {
|
||||
let value = NavigationAreaInput {
|
||||
actor_id: actor_id(world, entity)?,
|
||||
id: area.id.clone(),
|
||||
center: world_position(world, entity).to_array(),
|
||||
half_extents: world_aabb_half_extents(
|
||||
world_transform(world, entity),
|
||||
area.half_extents,
|
||||
)
|
||||
.to_array(),
|
||||
cost: area.cost,
|
||||
walkable: area.walkable,
|
||||
};
|
||||
if aabb_overlaps(center, half_extents, value.center, value.half_extents) {
|
||||
areas.push(value);
|
||||
}
|
||||
}
|
||||
areas.sort_by(|a, b| a.actor_id.cmp(&b.actor_id));
|
||||
|
||||
let mut links = Vec::new();
|
||||
let mut link_query = world.query::<(Entity, &NavigationLink)>();
|
||||
for (entity, link) in link_query.iter(world) {
|
||||
let transform = world_transform(world, entity);
|
||||
let value = NavigationLinkInput {
|
||||
actor_id: actor_id(world, entity)?,
|
||||
start: transform.transform_point(link.start).to_array(),
|
||||
end: transform.transform_point(link.end).to_array(),
|
||||
bidirectional: link.bidirectional,
|
||||
cost: link.cost,
|
||||
enabled: link.enabled,
|
||||
};
|
||||
if point_in_aabb(value.start, center, half_extents)
|
||||
|| point_in_aabb(value.end, center, half_extents)
|
||||
{
|
||||
links.push(value);
|
||||
}
|
||||
}
|
||||
links.sort_by(|a, b| a.actor_id.cmp(&b.actor_id));
|
||||
|
||||
let mut geometry = Vec::new();
|
||||
let mut geometry_query = world.query::<(Entity, Option<&Primitive>, Option<&BrushDesc>)>();
|
||||
for (entity, primitive, brush) in geometry_query.iter(world) {
|
||||
if primitive.is_none() && brush.is_none() {
|
||||
continue;
|
||||
}
|
||||
let transform = world_transform(world, entity);
|
||||
let Some(value) = navigation_geometry_from_parts(
|
||||
actor_id(world, entity)?,
|
||||
transform.translation.to_array(),
|
||||
transform.rotation.to_array(),
|
||||
transform.scale.to_array(),
|
||||
primitive,
|
||||
brush,
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
if geometry_overlaps_bounds(&value, center, half_extents) {
|
||||
geometry.push(value);
|
||||
}
|
||||
}
|
||||
geometry.sort_by(|a, b| a.actor_id.cmp(&b.actor_id));
|
||||
|
||||
Ok(NavigationBakeInput {
|
||||
source_scene,
|
||||
bounds_actor_id,
|
||||
center,
|
||||
half_extents,
|
||||
agent: bounds.agent.clone(),
|
||||
geometry,
|
||||
obstacles,
|
||||
areas,
|
||||
links,
|
||||
})
|
||||
}
|
||||
|
||||
fn actor_id(world: &World, entity: Entity) -> Result<String, String> {
|
||||
world
|
||||
.get::<ActorId>(entity)
|
||||
.map(|id| id.0.clone())
|
||||
.filter(|id| !id.trim().is_empty())
|
||||
.ok_or_else(|| format!("navigation actor {entity:?} requires a stable ActorId"))
|
||||
}
|
||||
|
||||
fn world_transform(world: &World, entity: Entity) -> Transform {
|
||||
let mut chain = Vec::new();
|
||||
let mut current = entity;
|
||||
let mut visited = HashSet::new();
|
||||
while visited.insert(current) {
|
||||
chain.push(world.get::<Transform>(current).copied().unwrap_or_default());
|
||||
let Some(parent) = world.get::<ChildOf>(current).map(ChildOf::parent) else {
|
||||
break;
|
||||
};
|
||||
current = parent;
|
||||
}
|
||||
if world.get::<ChildOf>(current).is_some() {
|
||||
return Transform::default();
|
||||
}
|
||||
let mut chain = chain.into_iter().rev();
|
||||
let root = chain.next().unwrap_or_default();
|
||||
chain
|
||||
.fold(GlobalTransform::from(root), |global, local| {
|
||||
global.mul_transform(local)
|
||||
})
|
||||
.compute_transform()
|
||||
}
|
||||
|
||||
fn world_position(world: &World, entity: Entity) -> Vec3 {
|
||||
world_transform(world, entity).translation
|
||||
}
|
||||
|
||||
fn world_aabb_half_extents(transform: Transform, local_half_extents: Vec3) -> Vec3 {
|
||||
let half = transform.scale.abs() * local_half_extents;
|
||||
let x = transform.rotation * Vec3::X;
|
||||
let y = transform.rotation * Vec3::Y;
|
||||
let z = transform.rotation * Vec3::Z;
|
||||
x.abs() * half.x + y.abs() * half.y + z.abs() * half.z
|
||||
}
|
||||
|
||||
fn artifact_path(world: &World, authored: &str) -> Result<PathBuf, String> {
|
||||
let relative = Path::new(authored);
|
||||
if relative.is_absolute()
|
||||
|| relative
|
||||
.components()
|
||||
.any(|component| matches!(component, std::path::Component::ParentDir))
|
||||
{
|
||||
return Err("navigation artifact path must stay inside the project".into());
|
||||
}
|
||||
if !authored
|
||||
.replace('\\', "/")
|
||||
.starts_with("assets/navigation/generated/")
|
||||
{
|
||||
return Err("navigation artifacts must be under assets/navigation/generated/".into());
|
||||
}
|
||||
Ok(Path::new(&world.resource::<ProjectWorkspace>().root).join(relative))
|
||||
}
|
||||
|
||||
fn set_status(world: &mut World, message: impl Into<String>, error: bool) {
|
||||
let message = message.into();
|
||||
let mut state = world.resource_mut::<NavigationEditorState>();
|
||||
state.status.clone_from(&message);
|
||||
state.error = error;
|
||||
if error {
|
||||
state.preview_path.clear();
|
||||
}
|
||||
world.resource_mut::<SceneIo>().set_status(message);
|
||||
}
|
||||
|
||||
fn vec3_editor(ui: &mut egui::Ui, value: &mut Vec3, speed: f64) -> egui::Response {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add(egui::DragValue::new(&mut value.x).speed(speed));
|
||||
ui.add(egui::DragValue::new(&mut value.y).speed(speed));
|
||||
ui.add(egui::DragValue::new(&mut value.z).speed(speed))
|
||||
})
|
||||
.response
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub enum NavigationActorType {
|
||||
Bounds,
|
||||
Obstacle,
|
||||
Area,
|
||||
Link,
|
||||
}
|
||||
|
||||
pub fn spawn_navigation_actor(world: &mut World, actor_type: NavigationActorType) -> Entity {
|
||||
let rect = world.resource::<crate::ui::UiState>().viewport_rect;
|
||||
let settings = world.resource::<ViewportSettings>().clone();
|
||||
let translation = snap_translation(editor_spawn_ground_position(world, rect), &settings);
|
||||
let (name, bounds, obstacle, area, link) = match actor_type {
|
||||
NavigationActorType::Bounds => (
|
||||
"Navigation Bounds",
|
||||
Some(NavigationBounds::default()),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
),
|
||||
NavigationActorType::Obstacle => (
|
||||
"Navigation Obstacle",
|
||||
None,
|
||||
Some(NavigationObstacle::default()),
|
||||
None,
|
||||
None,
|
||||
),
|
||||
NavigationActorType::Area => (
|
||||
"Navigation Area",
|
||||
None,
|
||||
None,
|
||||
Some(NavigationArea::default()),
|
||||
None,
|
||||
),
|
||||
NavigationActorType::Link => (
|
||||
"Navigation Link",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(NavigationLink::default()),
|
||||
),
|
||||
};
|
||||
let mut snapshot =
|
||||
super::helpers::primitive_snapshot(name, shared::PrimitiveShape::Box, translation);
|
||||
snapshot.actor_kind = shared::ActorKind::Navigation;
|
||||
snapshot.primitive = None;
|
||||
snapshot.material = None;
|
||||
snapshot.physics = None;
|
||||
snapshot.collider = None;
|
||||
snapshot.rigid_body = None;
|
||||
snapshot.navigation_bounds = bounds;
|
||||
snapshot.navigation_obstacle = obstacle;
|
||||
snapshot.navigation_area = area;
|
||||
snapshot.navigation_link = link;
|
||||
crate::history::spawn_with_history(world, snapshot)
|
||||
}
|
||||
|
||||
pub fn sync_navigation_state(world: &mut World) {
|
||||
let selected = world.resource::<crate::selection::SelectedEntity>().0;
|
||||
let Some(entity) = selected.filter(|entity| world.get::<NavigationBounds>(*entity).is_some())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
let Some(bounds) = world.get::<NavigationBounds>(entity).cloned() else {
|
||||
return;
|
||||
};
|
||||
let input = match build_input(world, entity, &bounds) {
|
||||
Ok(input) => input,
|
||||
Err(_) => return,
|
||||
};
|
||||
let fingerprint = navigation_source_fingerprint(&input);
|
||||
let changed_bounds = world.resource::<NavigationEditorState>().bounds_entity != Some(entity);
|
||||
if changed_bounds {
|
||||
let loaded = artifact_path(world, &bounds.artifact_path)
|
||||
.ok()
|
||||
.and_then(|path| read_navigation_artifact(&path).ok());
|
||||
let mut state = world.resource_mut::<NavigationEditorState>();
|
||||
state.bounds_entity = Some(entity);
|
||||
state.artifact = loaded;
|
||||
state.preview_path.clear();
|
||||
state.observed_fingerprint = None;
|
||||
state.auto_bake_attempt = None;
|
||||
state.debounce_elapsed = 0.0;
|
||||
}
|
||||
|
||||
let delta = world.resource::<Time>().delta_secs();
|
||||
let mut should_bake = false;
|
||||
{
|
||||
let mut state = world.resource_mut::<NavigationEditorState>();
|
||||
if state.observed_fingerprint.as_deref() != Some(&fingerprint) {
|
||||
state.observed_fingerprint = Some(fingerprint.clone());
|
||||
state.auto_bake_attempt = None;
|
||||
state.debounce_elapsed = 0.0;
|
||||
}
|
||||
let stale = state
|
||||
.artifact
|
||||
.as_ref()
|
||||
.is_none_or(|artifact| artifact.source_fingerprint != fingerprint);
|
||||
if stale {
|
||||
state.debounce_elapsed += delta;
|
||||
if !state.error {
|
||||
state.status = "Authored navigation changed; rebake required".into();
|
||||
}
|
||||
should_bake = bounds.auto_bake
|
||||
&& state.debounce_elapsed >= 0.75
|
||||
&& state.auto_bake_attempt.as_deref() != Some(&fingerprint);
|
||||
if should_bake {
|
||||
state.auto_bake_attempt = Some(fingerprint.clone());
|
||||
}
|
||||
} else if state.status == "Not baked"
|
||||
|| state.status == "Authored navigation changed; rebake required"
|
||||
{
|
||||
state.status = format!("Current: {}", bounds.artifact_path);
|
||||
state.error = false;
|
||||
}
|
||||
}
|
||||
if should_bake {
|
||||
bake_bounds(world, entity);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn world_transform_uses_current_authored_parent_chain() {
|
||||
let mut world = World::new();
|
||||
let parent = world.spawn(Transform::from_xyz(4.0, 0.0, 0.0)).id();
|
||||
let child = world
|
||||
.spawn((Transform::from_xyz(0.0, 0.0, 2.0), ChildOf(parent)))
|
||||
.id();
|
||||
|
||||
world
|
||||
.entity_mut(child)
|
||||
.insert(Transform::from_xyz(0.0, 0.0, 3.0));
|
||||
|
||||
assert_eq!(
|
||||
world_transform(&world, child).translation,
|
||||
Vec3::new(4.0, 0.0, 3.0)
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -141,6 +141,25 @@ fn left_toolbar(world: &mut World, ui: &mut egui::Ui) {
|
||||
if icon_button(ui, icons::PENCIL_SIMPLE_LINE, "Draw brush (B)").clicked() {
|
||||
start_draw_brush_tool(world);
|
||||
}
|
||||
let selected_navigation_bounds = world
|
||||
.resource::<crate::selection::SelectedEntity>()
|
||||
.0
|
||||
.filter(|entity| world.get::<shared::NavigationBounds>(*entity).is_some());
|
||||
let navigation_tip = if selected_navigation_bounds.is_some() {
|
||||
"Bake selected navigation bounds"
|
||||
} else {
|
||||
"Create navigation bounds"
|
||||
};
|
||||
if icon_button(ui, icons::PATH, navigation_tip).clicked() {
|
||||
if let Some(entity) = selected_navigation_bounds {
|
||||
super::navigation_inspector::bake_bounds(world, entity);
|
||||
} else {
|
||||
super::navigation_inspector::spawn_navigation_actor(
|
||||
world,
|
||||
super::navigation_inspector::NavigationActorType::Bounds,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
toolbar_separator(ui);
|
||||
|
||||
|
||||
@ -875,7 +875,7 @@ fn viewport_asset_drop_ui(
|
||||
.as_slice()
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|entity| world.get::<shared::ModelRef>(*entity).is_some())
|
||||
.find(|entity| world.get::<shared::SkinnedMeshRenderer>(*entity).is_some())
|
||||
{
|
||||
assign_animation_clip_operator(world, selection, entity);
|
||||
} else {
|
||||
@ -1011,7 +1011,7 @@ fn asset_drag_descriptor(
|
||||
} if selected
|
||||
.as_slice()
|
||||
.iter()
|
||||
.any(|entity| world.get::<shared::ModelRef>(*entity).is_some()) =>
|
||||
.any(|entity| world.get::<shared::SkinnedMeshRenderer>(*entity).is_some()) =>
|
||||
{
|
||||
"Assign clip to animated actor"
|
||||
}
|
||||
|
||||
@ -16,8 +16,8 @@ use bevy::shader::ShaderRef;
|
||||
use shared::{
|
||||
ActorKind, AudioListenerDesc, AudioSourceDesc, AuthoringLightKind, ColliderDesc, LevelObject,
|
||||
LightDesc, ModelRef, ObjectiveMarker, PhysicsBody, PlayerSpawn, PostProcessVolumeDesc,
|
||||
PrefabInstance, PrefabRef, Primitive, RaytracingExcluded, StaticMeshRenderer, TeamSpawn,
|
||||
TriggerVolume, WeaponSpawn,
|
||||
PrefabInstance, PrefabRef, Primitive, RaytracingExcluded, SkinnedMeshRenderer,
|
||||
StaticMeshRenderer, TeamSpawn, TriggerVolume, WeaponSpawn,
|
||||
};
|
||||
|
||||
use crate::camera::EditorCamera;
|
||||
@ -203,6 +203,7 @@ struct ActorIconComponents {
|
||||
light_kind: Option<AuthoringLightKind>,
|
||||
has_primitive: bool,
|
||||
has_static_mesh_renderer: bool,
|
||||
has_skinned_mesh_renderer: bool,
|
||||
has_model_ref: bool,
|
||||
has_prefab_ref: bool,
|
||||
has_prefab_instance: bool,
|
||||
@ -386,6 +387,7 @@ fn sync_actor_icons(
|
||||
),
|
||||
(With<LevelObject>, Without<EditorOnly>),
|
||||
>,
|
||||
skinned_meshes: Query<(), With<SkinnedMeshRenderer>>,
|
||||
audio: Query<(Has<AudioSourceDesc>, Has<AudioListenerDesc>)>,
|
||||
physics: Query<(), Or<(With<PhysicsBody>, With<ColliderDesc>)>>,
|
||||
) {
|
||||
@ -419,6 +421,7 @@ fn sync_actor_icons(
|
||||
has_primitive,
|
||||
has_static_mesh_renderer: static_mesh_renderer
|
||||
.is_some_and(has_resolved_static_mesh_slot),
|
||||
has_skinned_mesh_renderer: skinned_meshes.contains(target),
|
||||
has_model_ref,
|
||||
has_prefab_ref,
|
||||
has_prefab_instance,
|
||||
@ -489,6 +492,7 @@ fn sync_actor_icons(
|
||||
has_primitive,
|
||||
has_static_mesh_renderer: static_mesh_renderer
|
||||
.is_some_and(has_resolved_static_mesh_slot),
|
||||
has_skinned_mesh_renderer: skinned_meshes.contains(target),
|
||||
has_model_ref,
|
||||
has_prefab_ref,
|
||||
has_prefab_instance,
|
||||
@ -637,6 +641,16 @@ fn icon_for_actor_components(components: ActorIconComponents) -> ActorIconSpec {
|
||||
warning
|
||||
}
|
||||
}
|
||||
ActorKind::SkinnedMesh => {
|
||||
if components.has_skinned_mesh_renderer {
|
||||
ActorIconSpec {
|
||||
image: ActorIconImage::Mesh,
|
||||
category: ActorIconCategory::Mesh,
|
||||
}
|
||||
} else {
|
||||
warning
|
||||
}
|
||||
}
|
||||
ActorKind::ImportedModel => {
|
||||
if components.has_model_ref {
|
||||
ActorIconSpec {
|
||||
@ -752,6 +766,10 @@ fn icon_for_actor_components(components: ActorIconComponents) -> ActorIconSpec {
|
||||
warning
|
||||
}
|
||||
}
|
||||
ActorKind::Navigation => ActorIconSpec {
|
||||
image: ActorIconImage::GameMarker,
|
||||
category: ActorIconCategory::Volume,
|
||||
},
|
||||
ActorKind::Empty => {
|
||||
if components.has_physics {
|
||||
ActorIconSpec {
|
||||
|
||||
@ -374,6 +374,7 @@ fn brush_snapshot_from_convex_points(
|
||||
primitive: None,
|
||||
brush: Some(brush),
|
||||
static_mesh_renderer: None,
|
||||
skinned_mesh_renderer: None,
|
||||
material: Some(MaterialDesc::default()),
|
||||
material_override: None,
|
||||
rigid_body: None,
|
||||
@ -392,8 +393,18 @@ fn brush_snapshot_from_convex_points(
|
||||
post_process_volume: None,
|
||||
team_spawn: None,
|
||||
objective: None,
|
||||
|
||||
navigation_bounds: None,
|
||||
|
||||
navigation_obstacle: None,
|
||||
|
||||
navigation_area: None,
|
||||
|
||||
navigation_link: None,
|
||||
hierarchy_sibling_index: 0,
|
||||
editor_visibility: EditorVisibility::default(),
|
||||
inspector_order: None,
|
||||
component_states: None,
|
||||
children: Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
@ -290,14 +290,33 @@ fn solari_geometry_summary(world: &World, ui: &mut egui::Ui) {
|
||||
ui.separator();
|
||||
ui.heading("Solari raytracing scene");
|
||||
ui.label(format!(
|
||||
"Project meshes: {} tagged, {} eligible, {} Solari-compatible assets, {} unsupported, {} excluded helpers, {} outside level roots",
|
||||
"Project meshes: {} tagged, {} eligible, {} Solari-compatible assets, {} unsupported, {} deformed safely omitted, {} excluded helpers, {} outside level roots",
|
||||
stats.tagged_meshes,
|
||||
stats.project_meshes,
|
||||
stats.compatible_mesh_assets,
|
||||
stats.unsupported_meshes,
|
||||
stats.deformed_meshes_excluded,
|
||||
stats.excluded_meshes,
|
||||
stats.missing_level_root_meshes
|
||||
));
|
||||
if stats.surface_material_meshes > 0 {
|
||||
ui.label(format!(
|
||||
"Surface ABI materials: {} meshes, {} missing an active evaluator generation",
|
||||
stats.surface_material_meshes, stats.surface_materials_missing_evaluator
|
||||
));
|
||||
}
|
||||
if stats.deformed_meshes_excluded > 0 {
|
||||
ui.colored_label(
|
||||
egui::Color32::from_rgb(255, 180, 100),
|
||||
"Skinned/morphed meshes are omitted from Solari until dynamic deformed BLAS is available; no bind-pose proxy is traced.",
|
||||
);
|
||||
}
|
||||
if stats.surface_materials_missing_evaluator > 0 {
|
||||
ui.colored_label(
|
||||
egui::Color32::from_rgb(255, 140, 140),
|
||||
"Surface meshes without a validated evaluator are omitted instead of using an approximate ray-tracing material.",
|
||||
);
|
||||
}
|
||||
if stats.incompatible_mesh_assets > 0 {
|
||||
ui.colored_label(
|
||||
egui::Color32::from_rgb(255, 180, 100),
|
||||
@ -457,6 +476,7 @@ pub fn spawn_post_process_volume_at_camera(world: &mut World) {
|
||||
primitive: None,
|
||||
brush: None,
|
||||
static_mesh_renderer: None,
|
||||
skinned_mesh_renderer: None,
|
||||
material: None,
|
||||
material_override: None,
|
||||
rigid_body: None,
|
||||
@ -475,8 +495,18 @@ pub fn spawn_post_process_volume_at_camera(world: &mut World) {
|
||||
post_process_volume: Some(PostProcessVolumeDesc::default()),
|
||||
team_spawn: None,
|
||||
objective: None,
|
||||
|
||||
navigation_bounds: None,
|
||||
|
||||
navigation_obstacle: None,
|
||||
|
||||
navigation_area: None,
|
||||
|
||||
navigation_link: None,
|
||||
hierarchy_sibling_index: 0,
|
||||
editor_visibility: EditorVisibility::default(),
|
||||
inspector_order: None,
|
||||
component_states: None,
|
||||
children: Vec::new(),
|
||||
};
|
||||
spawn_with_history(world, snapshot);
|
||||
|
||||
@ -5,8 +5,9 @@ use bevy::ecs::system::SystemParam;
|
||||
use bevy::prelude::*;
|
||||
use shared::{
|
||||
AudioListenerDesc, AudioSourceDesc, AuthoringLightKind, ColliderDesc, ColliderShapeDesc,
|
||||
LevelObject, LightDesc, ModelRef, ObjectiveMarker, PhysicsBody, PlayerSpawn,
|
||||
PostProcessVolumeDesc, PrefabRef, ProjectSun, RaytracingExcluded, TeamSpawn, TriggerVolume,
|
||||
LevelObject, LightDesc, ModelRef, NavigationArea, NavigationBounds, NavigationLink,
|
||||
NavigationObstacle, ObjectiveMarker, PhysicsBody, PlayerSpawn, PostProcessVolumeDesc,
|
||||
PrefabRef, ProjectSun, RaytracingExcluded, SkinnedMeshRenderer, TeamSpawn, TriggerVolume,
|
||||
WeaponSpawn,
|
||||
};
|
||||
use sim::tuning::{EYE_OFFSET_STAND, PLAYER_LENGTH, PLAYER_RADIUS};
|
||||
@ -107,7 +108,15 @@ struct VisualizerTargetQueries<'w, 's> {
|
||||
lights: Query<'w, 's, Entity, (With<LevelObject>, With<LightDesc>)>,
|
||||
spawns: Query<'w, 's, Entity, (With<LevelObject>, With<PlayerSpawn>)>,
|
||||
prefabs: Query<'w, 's, Entity, (With<LevelObject>, With<PrefabRef>)>,
|
||||
models: Query<'w, 's, Entity, (With<LevelObject>, With<ModelRef>)>,
|
||||
models: Query<
|
||||
'w,
|
||||
's,
|
||||
Entity,
|
||||
(
|
||||
With<LevelObject>,
|
||||
Or<(With<ModelRef>, With<SkinnedMeshRenderer>)>,
|
||||
),
|
||||
>,
|
||||
players: Query<'w, 's, Entity, With<Player>>,
|
||||
cameras: Query<'w, 's, Entity, With<PlayerCamera>>,
|
||||
project_suns: Query<'w, 's, Entity, With<ProjectSun>>,
|
||||
@ -127,6 +136,7 @@ impl Plugin for EditorVisualizersPlugin {
|
||||
draw_audio_visualizers,
|
||||
draw_split_collider_visualizers,
|
||||
draw_post_process_volume_gizmos,
|
||||
draw_navigation_visualizers,
|
||||
)
|
||||
.chain()
|
||||
.run_if(scene_tools_active)
|
||||
@ -135,6 +145,119 @@ impl Plugin for EditorVisualizersPlugin {
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_navigation_visualizers(
|
||||
state: Res<crate::ui::navigation_inspector::NavigationEditorState>,
|
||||
mut gizmos: Gizmos,
|
||||
bounds: Query<(&NavigationBounds, &GlobalTransform), With<LevelObject>>,
|
||||
obstacles: Query<(&NavigationObstacle, &GlobalTransform), With<LevelObject>>,
|
||||
areas: Query<(&NavigationArea, &GlobalTransform), With<LevelObject>>,
|
||||
links: Query<(&NavigationLink, &GlobalTransform), With<LevelObject>>,
|
||||
) {
|
||||
for (value, global) in &bounds {
|
||||
let (_, rotation, center) = global.to_scale_rotation_translation();
|
||||
draw_box(
|
||||
&mut gizmos,
|
||||
center,
|
||||
rotation,
|
||||
value.half_extents,
|
||||
Color::srgba(0.12, 0.82, 0.78, 0.72),
|
||||
);
|
||||
}
|
||||
for (value, global) in &obstacles {
|
||||
let (_, rotation, center) = global.to_scale_rotation_translation();
|
||||
draw_box(
|
||||
&mut gizmos,
|
||||
center,
|
||||
rotation,
|
||||
value.half_extents,
|
||||
Color::srgba(1.0, 0.28, 0.22, 0.78),
|
||||
);
|
||||
}
|
||||
for (value, global) in &areas {
|
||||
let (_, rotation, center) = global.to_scale_rotation_translation();
|
||||
let color = if value.walkable {
|
||||
Color::srgba(0.32, 0.72, 1.0, 0.62)
|
||||
} else {
|
||||
Color::srgba(1.0, 0.55, 0.18, 0.72)
|
||||
};
|
||||
draw_box(&mut gizmos, center, rotation, value.half_extents, color);
|
||||
}
|
||||
for (value, global) in &links {
|
||||
let start = global.transform_point(value.start);
|
||||
let end = global.transform_point(value.end);
|
||||
let color = if value.enabled {
|
||||
Color::srgba(0.95, 0.78, 0.22, 0.95)
|
||||
} else {
|
||||
Color::srgba(0.48, 0.48, 0.5, 0.55)
|
||||
};
|
||||
gizmos.line(start, end, color);
|
||||
gizmos
|
||||
.sphere(Isometry3d::from_translation(start), 0.11, color)
|
||||
.resolution(12);
|
||||
gizmos
|
||||
.sphere(Isometry3d::from_translation(end), 0.11, color)
|
||||
.resolution(12);
|
||||
}
|
||||
|
||||
if state.show_mesh {
|
||||
if let Some(artifact) = &state.artifact {
|
||||
for layer in &artifact.mesh.layers {
|
||||
for polygon in &layer.polygons {
|
||||
for index in 0..polygon.vertices.len() {
|
||||
let a_index = polygon.vertices[index] as usize;
|
||||
let b_index =
|
||||
polygon.vertices[(index + 1) % polygon.vertices.len()] as usize;
|
||||
let Some(a) = navigation_vertex(layer, a_index) else {
|
||||
continue;
|
||||
};
|
||||
let Some(b) = navigation_vertex(layer, b_index) else {
|
||||
continue;
|
||||
};
|
||||
gizmos.line(
|
||||
a + Vec3::Y * 0.025,
|
||||
b + Vec3::Y * 0.025,
|
||||
Color::srgba(0.08, 0.9, 0.72, 0.7),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for edge in state.preview_path.windows(2) {
|
||||
gizmos.line(
|
||||
edge[0] + Vec3::Y * 0.08,
|
||||
edge[1] + Vec3::Y * 0.08,
|
||||
Color::srgba(0.98, 0.88, 0.2, 1.0),
|
||||
);
|
||||
}
|
||||
if let Some(start) = state.preview_path.first().copied() {
|
||||
gizmos
|
||||
.sphere(
|
||||
Isometry3d::from_translation(start + Vec3::Y * 0.08),
|
||||
0.16,
|
||||
Color::srgba(0.2, 1.0, 0.55, 1.0),
|
||||
)
|
||||
.resolution(14);
|
||||
}
|
||||
if let Some(end) = state.preview_path.last().copied() {
|
||||
gizmos
|
||||
.sphere(
|
||||
Isometry3d::from_translation(end + Vec3::Y * 0.08),
|
||||
0.16,
|
||||
Color::srgba(1.0, 0.28, 0.22, 1.0),
|
||||
)
|
||||
.resolution(14);
|
||||
}
|
||||
}
|
||||
|
||||
fn navigation_vertex(layer: &polyanya::Layer, index: usize) -> Option<Vec3> {
|
||||
let vertex = layer.vertices.get(index)?;
|
||||
let height = layer.height.get(index).copied().unwrap_or_default();
|
||||
let coords = vertex.coords + layer.offset;
|
||||
Some(Vec3::new(coords.x, height, coords.y))
|
||||
}
|
||||
|
||||
fn draw_audio_visualizers(
|
||||
viewport_mode: Res<EditorViewportMode>,
|
||||
settings: Res<EditorVisualizationSettings>,
|
||||
@ -399,7 +522,13 @@ fn draw_visualizers(
|
||||
lights: Query<(Entity, &LightDesc, &GlobalTransform), With<LevelObject>>,
|
||||
spawns: Query<(Entity, &GlobalTransform), (With<LevelObject>, With<PlayerSpawn>)>,
|
||||
prefabs: Query<(Entity, &GlobalTransform), (With<LevelObject>, With<PrefabRef>)>,
|
||||
models: Query<(Entity, &GlobalTransform), (With<LevelObject>, With<ModelRef>)>,
|
||||
models: Query<
|
||||
(Entity, &GlobalTransform),
|
||||
(
|
||||
With<LevelObject>,
|
||||
Or<(With<ModelRef>, With<SkinnedMeshRenderer>)>,
|
||||
),
|
||||
>,
|
||||
players: Query<(Entity, &GlobalTransform), With<Player>>,
|
||||
cameras: Query<(Entity, &GlobalTransform), With<PlayerCamera>>,
|
||||
project_suns: Query<(Entity, &GlobalTransform), With<ProjectSun>>,
|
||||
|
||||
@ -18,6 +18,8 @@ bevy_ufbx.workspace = true
|
||||
cpal = "0.17.3"
|
||||
game_hot = { path = "../game_hot" }
|
||||
hot-lib-reloader = { version = "0.8.2", optional = true }
|
||||
nav_glam.workspace = true
|
||||
polyanya.workspace = true
|
||||
protocol.workspace = true
|
||||
ron = "0.12"
|
||||
scene.workspace = true
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
//! Runtime adapter for scene-authored animation controllers.
|
||||
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
@ -12,11 +12,14 @@ use bevy::animation::{
|
||||
RepeatAnimation,
|
||||
};
|
||||
use bevy::gltf::GltfAssetLabel;
|
||||
use bevy::mesh::morph::MorphWeights;
|
||||
use bevy::mesh::skinning::{SkinnedMesh, SkinnedMeshInverseBindposes};
|
||||
use bevy::prelude::*;
|
||||
use bevy::world_serialization::WorldInstanceReady;
|
||||
use shared::{
|
||||
animation_clip_source_index, asset_server_path, AnimationControllerDesc, AnimationEventDesc,
|
||||
AnimationManifest, AnimationStateDesc, HydratedModelRoot, ANIMATION_ARTIFACT_DIR,
|
||||
AnimationManifest, AnimationStateDesc, HydratedSkinnedMeshRoot, ANIMATION_ARTIFACT_DIR,
|
||||
ANIMATION_MANIFEST_SCHEMA_VERSION,
|
||||
};
|
||||
use sim::SimEnabled;
|
||||
|
||||
@ -114,6 +117,29 @@ pub struct AuthoredAnimationRuntime {
|
||||
last_event_time: Option<f32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct SkinnedPoseBaselineEntry {
|
||||
entity: Entity,
|
||||
transform: Option<Transform>,
|
||||
morph_weights: Option<MorphWeights>,
|
||||
}
|
||||
|
||||
/// Imported rest-pose state captured before Blacksite starts authored animation playback.
|
||||
///
|
||||
/// This component is runtime-only. It is replaced whenever a new hydrated hierarchy becomes
|
||||
/// ready, so renderer reloads cannot restore values onto an older scene generation.
|
||||
#[derive(Component, Debug, Clone)]
|
||||
pub struct SkinnedPoseBaseline {
|
||||
pub root: Entity,
|
||||
entries: Vec<SkinnedPoseBaselineEntry>,
|
||||
}
|
||||
|
||||
impl SkinnedPoseBaseline {
|
||||
pub fn entry_count(&self) -> usize {
|
||||
self.entries.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// Editor-only transient preview request consumed by the shared runtime adapter.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct AnimationPreviewRequest {
|
||||
@ -163,10 +189,14 @@ impl Plugin for GameAnimationPlugin {
|
||||
fn attach_controller_when_model_ready(
|
||||
ready: On<WorldInstanceReady>,
|
||||
mut commands: Commands,
|
||||
roots: Query<&HydratedModelRoot>,
|
||||
roots: Query<&HydratedSkinnedMeshRoot>,
|
||||
controllers: Query<&AnimationControllerDesc>,
|
||||
children: Query<&Children>,
|
||||
players: Query<Entity, With<AnimationPlayer>>,
|
||||
transforms: Query<&Transform>,
|
||||
morph_weights: Query<&MorphWeights>,
|
||||
skinned_meshes: Query<(Entity, &SkinnedMesh)>,
|
||||
inverse_bindposes: Option<Res<Assets<SkinnedMeshInverseBindposes>>>,
|
||||
asset_server: Res<AssetServer>,
|
||||
mut graphs: ResMut<Assets<AnimationGraph>>,
|
||||
mut diagnostics: ResMut<AnimationRuntimeDiagnostics>,
|
||||
@ -175,6 +205,18 @@ fn attach_controller_when_model_ready(
|
||||
let Ok(root) = roots.get(ready.entity) else {
|
||||
return;
|
||||
};
|
||||
let subtree = std::iter::once(ready.entity)
|
||||
.chain(children.iter_descendants(ready.entity))
|
||||
.collect::<HashSet<_>>();
|
||||
let baseline = capture_pose_baseline(ready.entity, &subtree, &transforms, &morph_weights);
|
||||
commands.entity(root.owner).insert(baseline);
|
||||
validate_skinned_bindings(
|
||||
root.owner,
|
||||
&subtree,
|
||||
&skinned_meshes,
|
||||
inverse_bindposes.as_deref(),
|
||||
&mut diagnostics,
|
||||
);
|
||||
let Ok(controller) = controllers.get(root.owner) else {
|
||||
return;
|
||||
};
|
||||
@ -182,7 +224,7 @@ fn attach_controller_when_model_ready(
|
||||
diagnostics.push(
|
||||
Some(root.owner),
|
||||
"animation.player_missing",
|
||||
"The hydrated model contains no AnimationPlayer. Verify that the selected glTF scene contains an animated hierarchy.",
|
||||
"The hydrated skinned mesh contains no AnimationPlayer. Verify that the selected glTF scene contains an animated hierarchy.",
|
||||
);
|
||||
return;
|
||||
};
|
||||
@ -199,6 +241,75 @@ fn attach_controller_when_model_ready(
|
||||
);
|
||||
}
|
||||
|
||||
fn capture_pose_baseline(
|
||||
root: Entity,
|
||||
subtree: &HashSet<Entity>,
|
||||
transforms: &Query<&Transform>,
|
||||
morph_weights: &Query<&MorphWeights>,
|
||||
) -> SkinnedPoseBaseline {
|
||||
let mut entities = subtree.iter().copied().collect::<Vec<_>>();
|
||||
entities.sort_by_key(|entity| entity.to_bits());
|
||||
let entries = entities
|
||||
.into_iter()
|
||||
.filter_map(|entity| {
|
||||
let transform = transforms.get(entity).ok().copied();
|
||||
let morph_weights = morph_weights.get(entity).ok().cloned();
|
||||
(transform.is_some() || morph_weights.is_some()).then_some(SkinnedPoseBaselineEntry {
|
||||
entity,
|
||||
transform,
|
||||
morph_weights,
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
SkinnedPoseBaseline { root, entries }
|
||||
}
|
||||
|
||||
fn validate_skinned_bindings(
|
||||
actor: Entity,
|
||||
subtree: &HashSet<Entity>,
|
||||
skinned_meshes: &Query<(Entity, &SkinnedMesh)>,
|
||||
inverse_bindposes: Option<&Assets<SkinnedMeshInverseBindposes>>,
|
||||
diagnostics: &mut AnimationRuntimeDiagnostics,
|
||||
) {
|
||||
for (mesh_entity, skin) in skinned_meshes.iter() {
|
||||
if !subtree.contains(&mesh_entity) {
|
||||
continue;
|
||||
}
|
||||
if let Some(inverse_bindposes) = inverse_bindposes {
|
||||
let Some(bindposes) = inverse_bindposes.get(&skin.inverse_bindposes) else {
|
||||
diagnostics.push(
|
||||
Some(actor),
|
||||
"animation.inverse_bindposes_missing",
|
||||
format!(
|
||||
"Skinned mesh {mesh_entity} has no loaded inverse-bind-pose asset; the imported hierarchy was preserved but cannot be validated."
|
||||
),
|
||||
);
|
||||
continue;
|
||||
};
|
||||
if skin.joints.len() != bindposes.len() {
|
||||
diagnostics.push(
|
||||
Some(actor),
|
||||
"animation.skin_binding_count_mismatch",
|
||||
format!(
|
||||
"Skinned mesh {mesh_entity} has {} joints but {} inverse bind poses. Re-export the rig and reimport the model.",
|
||||
skin.joints.len(),
|
||||
bindposes.len()
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(joint) = skin.joints.iter().find(|joint| !subtree.contains(joint)) {
|
||||
diagnostics.push(
|
||||
Some(actor),
|
||||
"animation.skin_joint_outside_hierarchy",
|
||||
format!(
|
||||
"Skinned mesh {mesh_entity} references joint {joint} outside its hydrated hierarchy. Re-export the rig as one scene hierarchy."
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn reconcile_changed_controllers(
|
||||
mut commands: Commands,
|
||||
@ -209,7 +320,7 @@ fn reconcile_changed_controllers(
|
||||
Changed<AnimationControllerDesc>,
|
||||
)>,
|
||||
>,
|
||||
roots: Query<(Entity, &HydratedModelRoot)>,
|
||||
roots: Query<(Entity, &HydratedSkinnedMeshRoot)>,
|
||||
children: Query<&Children>,
|
||||
players: Query<Entity, With<AnimationPlayer>>,
|
||||
asset_server: Res<AssetServer>,
|
||||
@ -266,6 +377,9 @@ fn configure_runtime(
|
||||
Some(controller.default_crossfade_seconds),
|
||||
)
|
||||
});
|
||||
commands.queue(move |world: &mut World| {
|
||||
restore_pose_baseline(world, actor);
|
||||
});
|
||||
commands.entity(player).insert((
|
||||
AnimationGraphHandle(graph.clone()),
|
||||
AnimationTransitions::new(),
|
||||
@ -298,6 +412,7 @@ fn clear_failed_runtime_configuration(commands: &mut Commands, actor: Entity, pl
|
||||
if let Some(mut animation_player) = world.get_mut::<AnimationPlayer>(player) {
|
||||
animation_player.stop_all();
|
||||
}
|
||||
restore_pose_baseline(world, actor);
|
||||
if let Ok(mut player_entity) = world.get_entity_mut(player) {
|
||||
player_entity
|
||||
.remove::<AnimationGraphHandle>()
|
||||
@ -468,7 +583,17 @@ fn load_runtime_animation_manifest(
|
||||
let path = runtime_animation_manifest_path(assets_root, asset_id);
|
||||
let text = std::fs::read_to_string(&path)
|
||||
.map_err(|error| format!("could not read {}: {error}", path.display()))?;
|
||||
ron::from_str(&text).map_err(|error| format!("could not parse {}: {error}", path.display()))
|
||||
let manifest: AnimationManifest = ron::from_str(&text)
|
||||
.map_err(|error| format!("could not parse {}: {error}", path.display()))?;
|
||||
if manifest.schema_version != ANIMATION_MANIFEST_SCHEMA_VERSION {
|
||||
return Err(format!(
|
||||
"animation manifest {} uses schema {} but runtime requires {}; reimport the source model",
|
||||
path.display(),
|
||||
manifest.schema_version,
|
||||
ANIMATION_MANIFEST_SCHEMA_VERSION
|
||||
));
|
||||
}
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
fn process_animation_state_requests(
|
||||
@ -550,8 +675,18 @@ fn sync_authored_animation_playback(
|
||||
.request
|
||||
.as_ref()
|
||||
.filter(|request| request.actor == actor);
|
||||
let should_run = sim_enabled.0 || preview_request.is_some();
|
||||
if !should_run {
|
||||
let desired_state = preview_request
|
||||
.map(|request| request.state_id.as_str())
|
||||
.or(runtime.desired_state.as_deref())
|
||||
.or((!controller.default_state.trim().is_empty())
|
||||
.then_some(controller.default_state.as_str()))
|
||||
.map(str::to_owned);
|
||||
let sampling_edit_rest = !sim_enabled.0
|
||||
&& preview_request.is_none()
|
||||
&& runtime.active_state.is_none()
|
||||
&& desired_state.is_some();
|
||||
let should_evaluate = sim_enabled.0 || preview_request.is_some() || sampling_edit_rest;
|
||||
if !should_evaluate {
|
||||
if runtime.active_state.is_some() {
|
||||
player.pause_all();
|
||||
runtime.paused_by_sim = true;
|
||||
@ -572,12 +707,6 @@ fn sync_authored_animation_playback(
|
||||
.unwrap_or_default()
|
||||
});
|
||||
|
||||
let desired_state = preview_request
|
||||
.map(|request| request.state_id.as_str())
|
||||
.or(runtime.desired_state.as_deref())
|
||||
.or((!controller.default_state.trim().is_empty())
|
||||
.then_some(controller.default_state.as_str()))
|
||||
.map(str::to_owned);
|
||||
let Some(desired_state) = desired_state else {
|
||||
continue;
|
||||
};
|
||||
@ -600,7 +729,7 @@ fn sync_authored_animation_playback(
|
||||
};
|
||||
|
||||
if runtime.active_state.as_deref() != Some(desired_state.as_str()) {
|
||||
let crossfade = if preview_request.is_some() {
|
||||
let crossfade = if preview_request.is_some() || sampling_edit_rest {
|
||||
0.0
|
||||
} else {
|
||||
runtime
|
||||
@ -830,23 +959,49 @@ fn crossed_animation_events<'a>(
|
||||
crossed
|
||||
}
|
||||
|
||||
fn cleanup_stale_animation_runtimes(
|
||||
mut commands: Commands,
|
||||
controllers: Query<(), With<AnimationControllerDesc>>,
|
||||
runtimes: Query<(Entity, &AuthoredAnimationRuntime)>,
|
||||
mut players: Query<&mut AnimationPlayer>,
|
||||
) {
|
||||
for (actor, runtime) in &runtimes {
|
||||
if controllers.get(actor).is_ok() && players.get(runtime.player).is_ok() {
|
||||
continue;
|
||||
fn cleanup_stale_animation_runtimes(world: &mut World) {
|
||||
let runtimes = world
|
||||
.query::<(Entity, &AuthoredAnimationRuntime)>()
|
||||
.iter(world)
|
||||
.map(|(actor, runtime)| (actor, runtime.player))
|
||||
.collect::<Vec<_>>();
|
||||
let stale = runtimes
|
||||
.into_iter()
|
||||
.filter(|(actor, player)| {
|
||||
let controller_exists = world.get::<AnimationControllerDesc>(*actor).is_some();
|
||||
let player_exists = world.get::<AnimationPlayer>(*player).is_some();
|
||||
!controller_exists || !player_exists
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
for (actor, player) in stale {
|
||||
if let Some(mut animation_player) = world.get_mut::<AnimationPlayer>(player) {
|
||||
animation_player.stop_all();
|
||||
}
|
||||
if let Ok(mut player_entity) = world.get_entity_mut(player) {
|
||||
player_entity.remove::<SuspendedAnimationTransitions>();
|
||||
}
|
||||
restore_pose_baseline(world, actor);
|
||||
if let Ok(mut actor_entity) = world.get_entity_mut(actor) {
|
||||
actor_entity.remove::<AuthoredAnimationRuntime>();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn restore_pose_baseline(world: &mut World, actor: Entity) {
|
||||
let Some(baseline) = world.get::<SkinnedPoseBaseline>(actor).cloned() else {
|
||||
return;
|
||||
};
|
||||
for entry in baseline.entries {
|
||||
if let Some(transform) = entry.transform {
|
||||
if let Some(mut current) = world.get_mut::<Transform>(entry.entity) {
|
||||
*current = transform;
|
||||
}
|
||||
}
|
||||
if let Some(morph_weights) = entry.morph_weights {
|
||||
if let Some(mut current) = world.get_mut::<MorphWeights>(entry.entity) {
|
||||
*current = morph_weights;
|
||||
}
|
||||
if let Ok(mut player) = players.get_mut(runtime.player) {
|
||||
player.stop_all();
|
||||
commands
|
||||
.entity(runtime.player)
|
||||
.remove::<SuspendedAnimationTransitions>();
|
||||
}
|
||||
commands.entity(actor).remove::<AuthoredAnimationRuntime>();
|
||||
}
|
||||
}
|
||||
|
||||
@ -919,8 +1074,15 @@ pub fn stop_animation_preview(world: &mut World) {
|
||||
animation_player.stop_all();
|
||||
}
|
||||
}
|
||||
restore_pose_baseline(world, actor);
|
||||
let edit_rest_state = world
|
||||
.get::<AnimationControllerDesc>(actor)
|
||||
.map(|controller| controller.default_state.clone())
|
||||
.filter(|state| !state.trim().is_empty());
|
||||
if let Some(mut runtime) = world.get_mut::<AuthoredAnimationRuntime>(actor) {
|
||||
runtime.active_state = None;
|
||||
runtime.desired_state = edit_rest_state;
|
||||
runtime.requested_crossfade_seconds = Some(0.0);
|
||||
runtime.paused_by_sim = false;
|
||||
runtime.applied_preview_revision = None;
|
||||
runtime.last_event_time = None;
|
||||
@ -943,10 +1105,15 @@ pub fn reset_authored_animation_runtime(world: &mut World) {
|
||||
if let Some(mut animation_player) = world.get_mut::<AnimationPlayer>(player) {
|
||||
animation_player.stop_all();
|
||||
}
|
||||
restore_pose_baseline(world, actor);
|
||||
let edit_rest_state = world
|
||||
.get::<AnimationControllerDesc>(actor)
|
||||
.map(|controller| controller.default_state.clone())
|
||||
.filter(|state| !state.trim().is_empty());
|
||||
if let Some(mut runtime) = world.get_mut::<AuthoredAnimationRuntime>(actor) {
|
||||
runtime.active_state = None;
|
||||
runtime.desired_state = None;
|
||||
runtime.requested_crossfade_seconds = None;
|
||||
runtime.desired_state = edit_rest_state;
|
||||
runtime.requested_crossfade_seconds = Some(0.0);
|
||||
runtime.paused_by_sim = false;
|
||||
runtime.applied_preview_revision = None;
|
||||
runtime.last_event_time = None;
|
||||
@ -960,7 +1127,7 @@ mod tests {
|
||||
use bevy::asset::{AssetApp, AssetPlugin};
|
||||
use shared::{
|
||||
AnimationClipRecord, AnimationManifestSource, AnimationPlaybackRange,
|
||||
AnimationSourceFingerprint, EditorAssetRef, ANIMATION_MANIFEST_SCHEMA_VERSION,
|
||||
AnimationSourceFingerprint, EditorAssetRef,
|
||||
};
|
||||
|
||||
fn state(id: &str, index: usize) -> AnimationStateDesc {
|
||||
@ -1094,6 +1261,7 @@ mod tests {
|
||||
schema_version: ANIMATION_MANIFEST_SCHEMA_VERSION,
|
||||
asset_id: state.clip.asset_id.clone(),
|
||||
label: "Moved Model".into(),
|
||||
default_animation_clip_id: None,
|
||||
source: AnimationManifestSource {
|
||||
path: "assets/models/relocated.glb".into(),
|
||||
format: "glb".into(),
|
||||
@ -1144,7 +1312,7 @@ mod tests {
|
||||
let actor = app.world_mut().spawn_empty().id();
|
||||
let root = app
|
||||
.world_mut()
|
||||
.spawn((HydratedModelRoot { owner: actor }, ChildOf(actor)))
|
||||
.spawn((HydratedSkinnedMeshRoot { owner: actor }, ChildOf(actor)))
|
||||
.id();
|
||||
let stale_node = AnimationNodeIndex::new(0);
|
||||
let mut animation_player = AnimationPlayer::default();
|
||||
@ -1362,6 +1530,202 @@ mod tests {
|
||||
.all_paused());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edit_mode_samples_and_pauses_the_explicit_default_state() {
|
||||
let (mut app, actor, player, _) = runtime_test_app(false);
|
||||
|
||||
app.update();
|
||||
|
||||
assert_eq!(
|
||||
app.world()
|
||||
.get::<AuthoredAnimationRuntime>(actor)
|
||||
.and_then(|runtime| runtime.active_state.as_deref()),
|
||||
Some("idle")
|
||||
);
|
||||
assert!(app
|
||||
.world()
|
||||
.get::<AnimationPlayer>(player)
|
||||
.is_some_and(AnimationPlayer::all_paused));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stopping_preview_restores_transform_and_morph_baseline_before_resampling() {
|
||||
let mut app = App::new();
|
||||
app.init_resource::<AnimationPreviewRuntime>();
|
||||
let baseline_transform = Transform::from_xyz(1.0, 2.0, 3.0);
|
||||
let baseline_morph = MorphWeights::new(vec![0.1, 0.9], None).unwrap();
|
||||
let target = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
Transform::from_xyz(9.0, 8.0, 7.0),
|
||||
MorphWeights::new(vec![0.8, 0.2], None).unwrap(),
|
||||
))
|
||||
.id();
|
||||
let node = AnimationNodeIndex::new(0);
|
||||
let mut animation_player = AnimationPlayer::default();
|
||||
animation_player.start(node);
|
||||
let player = app.world_mut().spawn(animation_player).id();
|
||||
let actor = app
|
||||
.world_mut()
|
||||
.spawn(AnimationControllerDesc {
|
||||
states: vec![state("idle", 0)],
|
||||
default_state: "idle".into(),
|
||||
..Default::default()
|
||||
})
|
||||
.id();
|
||||
app.world_mut().entity_mut(actor).insert((
|
||||
SkinnedPoseBaseline {
|
||||
root: target,
|
||||
entries: vec![SkinnedPoseBaselineEntry {
|
||||
entity: target,
|
||||
transform: Some(baseline_transform),
|
||||
morph_weights: Some(baseline_morph),
|
||||
}],
|
||||
},
|
||||
AuthoredAnimationRuntime {
|
||||
player,
|
||||
graph: Handle::default(),
|
||||
state_nodes: HashMap::from([("idle".into(), node)]),
|
||||
active_state: Some("idle".into()),
|
||||
desired_state: Some("idle".into()),
|
||||
requested_crossfade_seconds: None,
|
||||
paused_by_sim: false,
|
||||
applied_preview_revision: None,
|
||||
metadata_by_state: HashMap::new(),
|
||||
last_event_time: None,
|
||||
},
|
||||
));
|
||||
set_animation_preview(
|
||||
app.world_mut(),
|
||||
AnimationPreviewRequest {
|
||||
actor,
|
||||
state_id: "idle".into(),
|
||||
seek_seconds: Some(0.7),
|
||||
playing: true,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
stop_animation_preview(app.world_mut());
|
||||
|
||||
assert_eq!(
|
||||
app.world().get::<Transform>(target),
|
||||
Some(&baseline_transform)
|
||||
);
|
||||
assert_eq!(
|
||||
app.world().get::<MorphWeights>(target).unwrap().weights(),
|
||||
&[0.1, 0.9]
|
||||
);
|
||||
assert!(app
|
||||
.world()
|
||||
.get::<AnimationPlayer>(player)
|
||||
.unwrap()
|
||||
.playing_animations()
|
||||
.next()
|
||||
.is_none());
|
||||
let runtime = app.world().get::<AuthoredAnimationRuntime>(actor).unwrap();
|
||||
assert!(runtime.active_state.is_none());
|
||||
assert_eq!(runtime.desired_state.as_deref(), Some("idle"));
|
||||
assert_eq!(runtime.requested_crossfade_seconds, Some(0.0));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_controller_restores_baseline_and_clears_runtime() {
|
||||
let mut world = World::new();
|
||||
let target = world.spawn(Transform::from_xyz(9.0, 0.0, 0.0)).id();
|
||||
let player = world.spawn(AnimationPlayer::default()).id();
|
||||
let actor = world
|
||||
.spawn((
|
||||
SkinnedPoseBaseline {
|
||||
root: target,
|
||||
entries: vec![SkinnedPoseBaselineEntry {
|
||||
entity: target,
|
||||
transform: Some(Transform::from_xyz(1.0, 0.0, 0.0)),
|
||||
morph_weights: None,
|
||||
}],
|
||||
},
|
||||
AuthoredAnimationRuntime {
|
||||
player,
|
||||
graph: Handle::default(),
|
||||
state_nodes: HashMap::new(),
|
||||
active_state: Some("idle".into()),
|
||||
desired_state: Some("idle".into()),
|
||||
requested_crossfade_seconds: None,
|
||||
paused_by_sim: false,
|
||||
applied_preview_revision: None,
|
||||
metadata_by_state: HashMap::new(),
|
||||
last_event_time: None,
|
||||
},
|
||||
))
|
||||
.id();
|
||||
|
||||
cleanup_stale_animation_runtimes(&mut world);
|
||||
|
||||
assert_eq!(world.get::<Transform>(target).unwrap().translation, Vec3::X);
|
||||
assert!(world.get::<AuthoredAnimationRuntime>(actor).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn duplicate_skin_records_are_validated_independently_against_shared_joints() {
|
||||
let mut world = World::new();
|
||||
let actor = world.spawn_empty().id();
|
||||
let root = world.spawn_empty().id();
|
||||
let joint = world.spawn_empty().id();
|
||||
let mut inverse_bindposes = Assets::<SkinnedMeshInverseBindposes>::default();
|
||||
let first_bindposes =
|
||||
inverse_bindposes.add(SkinnedMeshInverseBindposes::from(vec![Mat4::IDENTITY]));
|
||||
let second_bindposes =
|
||||
inverse_bindposes.add(SkinnedMeshInverseBindposes::from(vec![Mat4::IDENTITY]));
|
||||
let first_mesh = world
|
||||
.spawn(SkinnedMesh {
|
||||
inverse_bindposes: first_bindposes,
|
||||
joints: vec![joint],
|
||||
})
|
||||
.id();
|
||||
let second_mesh = world
|
||||
.spawn(SkinnedMesh {
|
||||
inverse_bindposes: second_bindposes,
|
||||
joints: vec![joint],
|
||||
})
|
||||
.id();
|
||||
let subtree = HashSet::from([root, joint, first_mesh, second_mesh]);
|
||||
let mut diagnostics = AnimationRuntimeDiagnostics::default();
|
||||
let mut query_state =
|
||||
bevy::ecs::system::SystemState::<Query<(Entity, &SkinnedMesh)>>::new(&mut world);
|
||||
|
||||
{
|
||||
let skins = query_state.get(&world).unwrap();
|
||||
validate_skinned_bindings(
|
||||
actor,
|
||||
&subtree,
|
||||
&skins,
|
||||
Some(&inverse_bindposes),
|
||||
&mut diagnostics,
|
||||
);
|
||||
}
|
||||
assert!(diagnostics.entries().next().is_none());
|
||||
|
||||
world
|
||||
.get_mut::<SkinnedMesh>(second_mesh)
|
||||
.unwrap()
|
||||
.joints
|
||||
.push(joint);
|
||||
{
|
||||
let skins = query_state.get(&world).unwrap();
|
||||
validate_skinned_bindings(
|
||||
actor,
|
||||
&subtree,
|
||||
&skins,
|
||||
Some(&inverse_bindposes),
|
||||
&mut diagnostics,
|
||||
);
|
||||
}
|
||||
assert_eq!(
|
||||
diagnostics.latest().map(|diagnostic| diagnostic.code),
|
||||
Some("animation.skin_binding_count_mismatch")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_state_request_is_rejected_without_message_side_effect() {
|
||||
let mut app = App::new();
|
||||
|
||||
@ -12,9 +12,9 @@ use settings::{
|
||||
AudioSettings, ProjectSettings, AUDIO_BUS_MASTER_ID, AUDIO_GAIN_DB_MAX, AUDIO_GAIN_DB_MIN,
|
||||
};
|
||||
use shared::{
|
||||
asset_server_path, inspector_component_active, ActorId, AudioAttenuationDesc,
|
||||
AudioListenerDesc, AudioRolloff, AudioSourceDesc, InspectorOrder, LevelObject,
|
||||
COMPONENT_AUDIO_LISTENER_DESC, COMPONENT_AUDIO_SOURCE_DESC,
|
||||
asset_server_path, authoring_component_active, ActorId, AudioAttenuationDesc,
|
||||
AudioListenerDesc, AudioRolloff, AudioSourceDesc, AuthoringComponentStates, InspectorOrder,
|
||||
LevelObject, COMPONENT_AUDIO_LISTENER_DESC, COMPONENT_AUDIO_SOURCE_DESC,
|
||||
};
|
||||
use sim::{PlayerCamera, SimEnabled};
|
||||
|
||||
@ -265,10 +265,13 @@ fn reconcile_audio_listener(world: &mut World) {
|
||||
Entity,
|
||||
&AudioListenerDesc,
|
||||
Option<&ActorId>,
|
||||
Option<&AuthoringComponentStates>,
|
||||
Option<&InspectorOrder>,
|
||||
), With<LevelObject>>();
|
||||
for (entity, desc, actor_id, order) in query.iter(world) {
|
||||
if desc.enabled && inspector_component_active(order, COMPONENT_AUDIO_LISTENER_DESC) {
|
||||
for (entity, desc, actor_id, states, legacy_order) in query.iter(world) {
|
||||
if desc.enabled
|
||||
&& authoring_component_active(states, legacy_order, COMPONENT_AUDIO_LISTENER_DESC)
|
||||
{
|
||||
candidates.push((
|
||||
entity,
|
||||
*desc,
|
||||
@ -343,10 +346,11 @@ fn reconcile_audio_sources(world: &mut World) {
|
||||
let mut query = world.query_filtered::<(
|
||||
Entity,
|
||||
&AudioSourceDesc,
|
||||
Option<&AuthoringComponentStates>,
|
||||
Option<&InspectorOrder>,
|
||||
), With<LevelObject>>();
|
||||
for (entity, desc, order) in query.iter(world) {
|
||||
if inspector_component_active(order, COMPONENT_AUDIO_SOURCE_DESC) {
|
||||
for (entity, desc, states, legacy_order) in query.iter(world) {
|
||||
if authoring_component_active(states, legacy_order, COMPONENT_AUDIO_SOURCE_DESC) {
|
||||
sources.push((entity, desc.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,6 +12,7 @@ pub mod animation;
|
||||
pub mod audio;
|
||||
pub mod editor_ext;
|
||||
pub mod launch;
|
||||
pub mod navigation;
|
||||
mod scene_bootstrap;
|
||||
mod schema_world_loader;
|
||||
|
||||
|
||||
257
crates/game/src/navigation.rs
Normal file
257
crates/game/src/navigation.rs
Normal file
@ -0,0 +1,257 @@
|
||||
use std::path::Path;
|
||||
|
||||
use bevy::prelude::Vec3;
|
||||
use nav_glam::{Vec2 as NavVec2, Vec3 as NavVec3};
|
||||
use polyanya::Mesh;
|
||||
use scene::navigation::{read_navigation_artifact, NavigationBakeArtifact, NavigationLinkInput};
|
||||
|
||||
/// Loaded navigation artifact and its baked Polyanya acceleration data.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NavigationRuntime {
|
||||
artifact: NavigationBakeArtifact,
|
||||
mesh: Mesh,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct NavigationPath {
|
||||
pub points: Vec<Vec3>,
|
||||
pub length: f32,
|
||||
pub used_link_actor_id: Option<String>,
|
||||
}
|
||||
|
||||
impl NavigationRuntime {
|
||||
pub fn from_artifact(artifact: NavigationBakeArtifact) -> Result<Self, String> {
|
||||
if artifact.mesh.layers.is_empty()
|
||||
|| artifact
|
||||
.mesh
|
||||
.layers
|
||||
.iter()
|
||||
.all(|layer| layer.polygons.is_empty())
|
||||
{
|
||||
return Err("navigation artifact contains no walkable polygons; rebake it".into());
|
||||
}
|
||||
let mesh = artifact.runtime_mesh();
|
||||
Ok(Self { artifact, mesh })
|
||||
}
|
||||
|
||||
pub fn load(path: &Path) -> Result<Self, String> {
|
||||
Self::from_artifact(read_navigation_artifact(path)?)
|
||||
}
|
||||
|
||||
pub fn artifact(&self) -> &NavigationBakeArtifact {
|
||||
&self.artifact
|
||||
}
|
||||
|
||||
pub fn query(&self, start: Vec3, end: Vec3) -> Result<NavigationPath, String> {
|
||||
if !start.is_finite() || !end.is_finite() {
|
||||
return Err("navigation query endpoints must be finite".into());
|
||||
}
|
||||
|
||||
let mut best = self.direct_path(start, end).ok().map(|path| Candidate {
|
||||
path,
|
||||
weighted_cost: 0.0,
|
||||
});
|
||||
if let Some(candidate) = best.as_mut() {
|
||||
candidate.weighted_cost = candidate.path.length;
|
||||
}
|
||||
|
||||
for link in self.artifact.links.iter().filter(|link| link.enabled) {
|
||||
self.consider_link(start, end, link, false, &mut best);
|
||||
if link.bidirectional {
|
||||
self.consider_link(start, end, link, true, &mut best);
|
||||
}
|
||||
}
|
||||
|
||||
best.map(|candidate| candidate.path).ok_or_else(|| {
|
||||
"no navigation path connects the requested endpoints; inspect islands or add a link"
|
||||
.into()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn validate_links(&self) -> Vec<String> {
|
||||
let mut findings = Vec::new();
|
||||
for link in self.artifact.links.iter().filter(|link| link.enabled) {
|
||||
for (label, endpoint) in [("start", link.start), ("end", link.end)] {
|
||||
let point = NavVec2::new(endpoint[0], endpoint[2]);
|
||||
let Some(closest) = self.mesh.get_closest_point(point) else {
|
||||
findings.push(format!(
|
||||
"navigation link {} {label} endpoint is not near a walkable polygon",
|
||||
link.actor_id
|
||||
));
|
||||
continue;
|
||||
};
|
||||
if closest.position().distance(point) > self.artifact.agent.radius * 2.0 {
|
||||
findings.push(format!(
|
||||
"navigation link {} {label} endpoint is farther than two agent radii from the mesh",
|
||||
link.actor_id
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
findings
|
||||
}
|
||||
|
||||
fn consider_link(
|
||||
&self,
|
||||
start: Vec3,
|
||||
end: Vec3,
|
||||
link: &NavigationLinkInput,
|
||||
reverse: bool,
|
||||
best: &mut Option<Candidate>,
|
||||
) {
|
||||
let (entry, exit) = if reverse {
|
||||
(vec3(link.end), vec3(link.start))
|
||||
} else {
|
||||
(vec3(link.start), vec3(link.end))
|
||||
};
|
||||
let (Ok(mut first), Ok(second)) =
|
||||
(self.direct_path(start, entry), self.direct_path(exit, end))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
append_distinct(&mut first.points, entry);
|
||||
append_distinct(&mut first.points, exit);
|
||||
for point in second.points.into_iter().skip(1) {
|
||||
append_distinct(&mut first.points, point);
|
||||
}
|
||||
let link_length = entry.distance(exit);
|
||||
first.length += link_length + second.length;
|
||||
first.used_link_actor_id = Some(link.actor_id.clone());
|
||||
let weighted_cost = first.length + link_length * (link.cost - 1.0);
|
||||
if best
|
||||
.as_ref()
|
||||
.is_none_or(|candidate| weighted_cost < candidate.weighted_cost)
|
||||
{
|
||||
*best = Some(Candidate {
|
||||
path: first,
|
||||
weighted_cost,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn direct_path(&self, start: Vec3, end: Vec3) -> Result<NavigationPath, String> {
|
||||
let start2 = NavVec2::new(start.x, start.z);
|
||||
let end2 = NavVec2::new(end.x, end.z);
|
||||
let path = self.mesh.path(start2, end2).ok_or_else(|| {
|
||||
"navigation endpoints are outside the same reachable mesh island".to_string()
|
||||
})?;
|
||||
let length = path.length;
|
||||
let nav_start = NavVec3::new(start.x, start.y, start.z);
|
||||
let nav_end = NavVec3::new(end.x, end.y, end.z);
|
||||
let mut points = vec![start];
|
||||
for point in path.path_with_height(nav_start, nav_end, &self.mesh) {
|
||||
append_distinct(&mut points, Vec3::new(point.x, point.y, point.z));
|
||||
}
|
||||
append_distinct(&mut points, end);
|
||||
Ok(NavigationPath {
|
||||
points,
|
||||
length,
|
||||
used_link_actor_id: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
struct Candidate {
|
||||
path: NavigationPath,
|
||||
weighted_cost: f32,
|
||||
}
|
||||
|
||||
fn append_distinct(points: &mut Vec<Vec3>, point: Vec3) {
|
||||
if points
|
||||
.last()
|
||||
.is_none_or(|last| last.distance_squared(point) > 1.0e-8)
|
||||
{
|
||||
points.push(point);
|
||||
}
|
||||
}
|
||||
|
||||
fn vec3(value: [f32; 3]) -> Vec3 {
|
||||
Vec3::from_array(value)
|
||||
}
|
||||
|
||||
pub fn query_navigation_path(
|
||||
artifact_path: &Path,
|
||||
start: Vec3,
|
||||
end: Vec3,
|
||||
) -> Result<NavigationPath, String> {
|
||||
NavigationRuntime::load(artifact_path)?.query(start, end)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use scene::navigation::{
|
||||
bake_navigation, NavigationBakeInput, NavigationLinkInput, NavigationVolumeInput,
|
||||
};
|
||||
use shared::NavigationAgentProfile;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn input() -> NavigationBakeInput {
|
||||
NavigationBakeInput {
|
||||
source_scene: "assets/levels/navigation_showcase.scn.ron".into(),
|
||||
bounds_actor_id: "bounds".into(),
|
||||
center: [0.0, 0.0, 0.0],
|
||||
half_extents: [6.0, 2.0, 6.0],
|
||||
agent: NavigationAgentProfile {
|
||||
min_region_size: 1,
|
||||
merge_region_size: 2,
|
||||
..Default::default()
|
||||
},
|
||||
geometry: Vec::new(),
|
||||
obstacles: vec![NavigationVolumeInput {
|
||||
actor_id: "wall".into(),
|
||||
center: [0.0, 0.5, 0.0],
|
||||
half_extents: [0.5, 1.0, 4.5],
|
||||
}],
|
||||
areas: Vec::new(),
|
||||
links: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_query_routes_on_the_baked_mesh() {
|
||||
let runtime = NavigationRuntime::from_artifact(bake_navigation(&input()).unwrap()).unwrap();
|
||||
let path = runtime
|
||||
.query(Vec3::new(-4.0, 0.0, -5.0), Vec3::new(4.0, 0.0, -5.0))
|
||||
.unwrap();
|
||||
assert!(path.length >= 8.0);
|
||||
assert!(path.points.len() >= 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_link_can_connect_isolated_regions() {
|
||||
let mut input = input();
|
||||
input.obstacles[0].half_extents[2] = 6.0;
|
||||
input.links.push(NavigationLinkInput {
|
||||
actor_id: "door-link".into(),
|
||||
start: [-1.0, 0.0, 0.0],
|
||||
end: [1.0, 0.0, 0.0],
|
||||
bidirectional: true,
|
||||
cost: 1.0,
|
||||
enabled: true,
|
||||
});
|
||||
let runtime = NavigationRuntime::from_artifact(bake_navigation(&input).unwrap()).unwrap();
|
||||
let path = runtime
|
||||
.query(Vec3::new(-4.0, 0.0, 0.0), Vec3::new(4.0, 0.0, 0.0))
|
||||
.unwrap();
|
||||
assert_eq!(path.used_link_actor_id.as_deref(), Some("door-link"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_link_endpoint_is_reported_with_actor_identity() {
|
||||
let mut input = input();
|
||||
input.links.push(NavigationLinkInput {
|
||||
actor_id: "bad-link".into(),
|
||||
start: [100.0, 0.0, 100.0],
|
||||
end: [0.0, 0.0, 0.0],
|
||||
bidirectional: false,
|
||||
cost: 1.0,
|
||||
enabled: true,
|
||||
});
|
||||
let runtime = NavigationRuntime::from_artifact(bake_navigation(&input).unwrap()).unwrap();
|
||||
assert!(runtime
|
||||
.validate_links()
|
||||
.iter()
|
||||
.any(|finding| finding.contains("bad-link start")));
|
||||
}
|
||||
}
|
||||
@ -18,6 +18,7 @@ avian3d.workspace = true
|
||||
bevy.workspace = true
|
||||
bevy_core_pipeline.workspace = true
|
||||
bevy_solari.workspace = true
|
||||
blacksite_surface.workspace = true
|
||||
protocol.workspace = true
|
||||
shared.workspace = true
|
||||
sim.workspace = true
|
||||
|
||||
@ -5,7 +5,7 @@ use bevy::core_pipeline::prepass::{
|
||||
DeferredPrepass, DeferredPrepassDoubleBuffer, DepthPrepass, DepthPrepassDoubleBuffer,
|
||||
MotionVectorPrepass,
|
||||
};
|
||||
use bevy::mesh::{Indices, PrimitiveTopology};
|
||||
use bevy::mesh::{morph::MeshMorphWeights, skinning::SkinnedMesh, Indices, PrimitiveTopology};
|
||||
use bevy::pbr::{DefaultOpaqueRendererMethod, ExtractedDirectionalLight};
|
||||
use bevy::prelude::*;
|
||||
use bevy::render::render_resource::TextureUsages;
|
||||
@ -14,6 +14,7 @@ use bevy::render::{Render, RenderApp, RenderSystems};
|
||||
use bevy_solari::prelude::{RaytracingMesh3d, SolariLighting};
|
||||
use bevy_solari::scene::RaytracingSceneBindings;
|
||||
use bevy_solari::SolariPlugins;
|
||||
use blacksite_surface::{SurfaceEvaluatorRegistry, SurfaceMaterial};
|
||||
use settings::{GiPath, ProjectSettings, RenderingCapabilities};
|
||||
use shared::{LevelObject, RaytracingExcluded};
|
||||
use std::sync::{Arc, Mutex};
|
||||
@ -24,6 +25,10 @@ pub struct SolariRaytracingSceneStats {
|
||||
pub tagged_meshes: usize,
|
||||
pub excluded_meshes: usize,
|
||||
pub unsupported_meshes: usize,
|
||||
/// Skinned or morphed meshes deliberately omitted until Solari consumes deformed vertices.
|
||||
pub deformed_meshes_excluded: usize,
|
||||
pub surface_material_meshes: usize,
|
||||
pub surface_materials_missing_evaluator: usize,
|
||||
pub missing_level_root_meshes: usize,
|
||||
pub emissive_meshes: usize,
|
||||
pub compatible_mesh_assets: usize,
|
||||
@ -61,7 +66,8 @@ pub struct SolariRenderingPlugin;
|
||||
|
||||
impl Plugin for SolariRenderingPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.add_plugins(SolariPlugins)
|
||||
app.add_plugins(blacksite_surface::SurfaceMaterialPlugin)
|
||||
.add_plugins(SolariPlugins)
|
||||
.init_resource::<RenderingCapabilities>()
|
||||
.init_resource::<settings::EffectiveRenderStack>()
|
||||
.init_resource::<SolariRaytracingSceneStats>()
|
||||
@ -213,11 +219,16 @@ fn sync_hydrated_raytracing_meshes(
|
||||
mut commands: Commands,
|
||||
mut stats: ResMut<SolariRaytracingSceneStats>,
|
||||
materials: Res<Assets<StandardMaterial>>,
|
||||
surface_materials: Res<Assets<SurfaceMaterial>>,
|
||||
surface_evaluators: Res<SurfaceEvaluatorRegistry>,
|
||||
mesh_assets: Res<Assets<Mesh>>,
|
||||
meshes: Query<(
|
||||
Entity,
|
||||
&Mesh3d,
|
||||
Option<&MeshMaterial3d<StandardMaterial>>,
|
||||
Option<&MeshMaterial3d<SurfaceMaterial>>,
|
||||
Option<&SkinnedMesh>,
|
||||
Option<&MeshMorphWeights>,
|
||||
Option<&RaytracingMesh3d>,
|
||||
Option<&RaytracingExcluded>,
|
||||
)>,
|
||||
@ -230,6 +241,9 @@ fn sync_hydrated_raytracing_meshes(
|
||||
next_stats.tagged_meshes = 0;
|
||||
next_stats.excluded_meshes = 0;
|
||||
next_stats.unsupported_meshes = 0;
|
||||
next_stats.deformed_meshes_excluded = 0;
|
||||
next_stats.surface_material_meshes = 0;
|
||||
next_stats.surface_materials_missing_evaluator = 0;
|
||||
next_stats.missing_level_root_meshes = 0;
|
||||
next_stats.emissive_meshes = 0;
|
||||
next_stats.compatible_mesh_assets = 0;
|
||||
@ -255,7 +269,9 @@ fn sync_hydrated_raytracing_meshes(
|
||||
return;
|
||||
}
|
||||
|
||||
for (entity, mesh, material, rt_mesh, excluded) in &meshes {
|
||||
for (entity, mesh, standard_material, surface_material, skin, morph, rt_mesh, excluded) in
|
||||
&meshes
|
||||
{
|
||||
if excluded.is_some() {
|
||||
next_stats.excluded_meshes += 1;
|
||||
if rt_mesh.is_some() {
|
||||
@ -271,19 +287,79 @@ fn sync_hydrated_raytracing_meshes(
|
||||
|
||||
next_stats.project_meshes += 1;
|
||||
|
||||
let Some(material) = material else {
|
||||
// Bevy 0.19's mesh allocator exposes the source vertex slab to Solari, not the
|
||||
// morph-then-skin output consumed by raster rendering. Tagging this mesh would trace the
|
||||
// bind pose, so omit it until the fork owns an instance-keyed deformed vertex/BLAS path.
|
||||
let mesh_has_morph_targets = mesh_assets
|
||||
.get(&mesh.0)
|
||||
.and_then(Mesh::get_morph_targets)
|
||||
.is_some();
|
||||
if !solari_deformation_is_supported(skin.is_some(), morph.is_some(), mesh_has_morph_targets)
|
||||
{
|
||||
next_stats.deformed_meshes_excluded += 1;
|
||||
if rt_mesh.is_some() {
|
||||
commands.entity(entity).remove::<RaytracingMesh3d>();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if standard_material.is_none() && surface_material.is_none() {
|
||||
next_stats.unsupported_meshes += 1;
|
||||
if rt_mesh.is_some() {
|
||||
commands.entity(entity).remove::<RaytracingMesh3d>();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(surface_handle) = surface_material {
|
||||
next_stats.surface_material_meshes += 1;
|
||||
let Some(material) = surface_materials.get(&surface_handle.0) else {
|
||||
next_stats.unsupported_meshes += 1;
|
||||
if rt_mesh.is_some() {
|
||||
commands.entity(entity).remove::<RaytracingMesh3d>();
|
||||
}
|
||||
continue;
|
||||
};
|
||||
|
||||
if materials
|
||||
.get(&material.0)
|
||||
.is_some_and(|material| material.emissive.to_vec3() != Vec3::ZERO)
|
||||
if !solari_alpha_mode_is_supported(material.base.alpha_mode) {
|
||||
next_stats.unsupported_meshes += 1;
|
||||
if rt_mesh.is_some() {
|
||||
commands.entity(entity).remove::<RaytracingMesh3d>();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if !surface_evaluators
|
||||
.evaluators
|
||||
.contains_key(&material.extension.uniform.shader_id)
|
||||
{
|
||||
next_stats.surface_materials_missing_evaluator += 1;
|
||||
next_stats.unsupported_meshes += 1;
|
||||
if rt_mesh.is_some() {
|
||||
commands.entity(entity).remove::<RaytracingMesh3d>();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// A custom evaluator may emit light as a function of parameters, textures, or world
|
||||
// position. Conservatively register Surface instances as potential emissive sources;
|
||||
// zero-emission samples contribute no energy.
|
||||
next_stats.emissive_meshes += 1;
|
||||
} else if let Some(standard_handle) = standard_material {
|
||||
let Some(material) = materials.get(&standard_handle.0) else {
|
||||
next_stats.unsupported_meshes += 1;
|
||||
if rt_mesh.is_some() {
|
||||
commands.entity(entity).remove::<RaytracingMesh3d>();
|
||||
}
|
||||
continue;
|
||||
};
|
||||
if !solari_alpha_mode_is_supported(material.alpha_mode) {
|
||||
next_stats.unsupported_meshes += 1;
|
||||
if rt_mesh.is_some() {
|
||||
commands.entity(entity).remove::<RaytracingMesh3d>();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if material.emissive.to_vec3() != Vec3::ZERO {
|
||||
next_stats.emissive_meshes += 1;
|
||||
}
|
||||
}
|
||||
|
||||
match mesh_assets
|
||||
@ -323,7 +399,7 @@ fn sync_hydrated_raytracing_meshes(
|
||||
|
||||
if *stats != next_stats {
|
||||
info!(
|
||||
"Solari mesh eligibility: requested={:?} eligible={} tagged={} compatible_assets={} incompatible_assets={} missing_tangents={} missing_u32_indices={} unsupported={} excluded={} outside_level_roots={} emissive={}",
|
||||
"Solari mesh eligibility: requested={:?} eligible={} tagged={} compatible_assets={} incompatible_assets={} missing_tangents={} missing_u32_indices={} unsupported={} deformed_excluded={} surface_materials={} surface_missing_evaluator={} excluded={} outside_level_roots={} emissive_candidates={}",
|
||||
caps.requested_gi_path,
|
||||
next_stats.project_meshes,
|
||||
next_stats.tagged_meshes,
|
||||
@ -332,6 +408,9 @@ fn sync_hydrated_raytracing_meshes(
|
||||
next_stats.mesh_assets_missing_tangents,
|
||||
next_stats.mesh_assets_missing_u32_indices,
|
||||
next_stats.unsupported_meshes,
|
||||
next_stats.deformed_meshes_excluded,
|
||||
next_stats.surface_material_meshes,
|
||||
next_stats.surface_materials_missing_evaluator,
|
||||
next_stats.excluded_meshes,
|
||||
next_stats.missing_level_root_meshes,
|
||||
next_stats.emissive_meshes,
|
||||
@ -340,6 +419,21 @@ fn sync_hydrated_raytracing_meshes(
|
||||
}
|
||||
}
|
||||
|
||||
fn solari_deformation_is_supported(
|
||||
has_skin: bool,
|
||||
has_morph_weights: bool,
|
||||
mesh_has_morph_targets: bool,
|
||||
) -> bool {
|
||||
!has_skin && !has_morph_weights && !mesh_has_morph_targets
|
||||
}
|
||||
|
||||
fn solari_alpha_mode_is_supported(alpha_mode: AlphaMode) -> bool {
|
||||
matches!(
|
||||
alpha_mode,
|
||||
AlphaMode::Opaque | AlphaMode::Mask(_) | AlphaMode::AlphaToCoverage
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum SolariMeshCompatibility {
|
||||
Compatible,
|
||||
@ -380,17 +474,24 @@ fn mesh_is_solari_project_geometry(
|
||||
Entity,
|
||||
&Mesh3d,
|
||||
Option<&MeshMaterial3d<StandardMaterial>>,
|
||||
Option<&MeshMaterial3d<SurfaceMaterial>>,
|
||||
Option<&SkinnedMesh>,
|
||||
Option<&MeshMorphWeights>,
|
||||
Option<&RaytracingMesh3d>,
|
||||
Option<&RaytracingExcluded>,
|
||||
)>,
|
||||
parents: &Query<&ChildOf>,
|
||||
level_roots: &Query<(), With<LevelObject>>,
|
||||
) -> bool {
|
||||
let Ok((_, _, material, _, excluded)) = meshes.get(entity) else {
|
||||
let Ok((_, _, standard_material, surface_material, skin, morph, _, excluded)) =
|
||||
meshes.get(entity)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
excluded.is_none()
|
||||
&& material.is_some()
|
||||
&& skin.is_none()
|
||||
&& morph.is_none()
|
||||
&& (standard_material.is_some() || surface_material.is_some())
|
||||
&& has_level_object_ancestor(entity, parents, level_roots)
|
||||
}
|
||||
|
||||
@ -569,4 +670,22 @@ mod tests {
|
||||
GiPath::SolariDeferred
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deformation_is_excluded_instead_of_tracing_the_bind_pose() {
|
||||
assert!(solari_deformation_is_supported(false, false, false));
|
||||
assert!(!solari_deformation_is_supported(true, false, false));
|
||||
assert!(!solari_deformation_is_supported(false, true, false));
|
||||
assert!(!solari_deformation_is_supported(false, false, true));
|
||||
assert!(!solari_deformation_is_supported(true, true, true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn solari_accepts_only_opaque_and_cutout_materials() {
|
||||
assert!(solari_alpha_mode_is_supported(AlphaMode::Opaque));
|
||||
assert!(solari_alpha_mode_is_supported(AlphaMode::Mask(0.5)));
|
||||
assert!(solari_alpha_mode_is_supported(AlphaMode::AlphaToCoverage));
|
||||
assert!(!solari_alpha_mode_is_supported(AlphaMode::Blend));
|
||||
assert!(!solari_alpha_mode_is_supported(AlphaMode::Premultiplied));
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,8 +7,8 @@ use settings::{
|
||||
ProjectSettings, RenderingCapabilities, RenderingSettings, VolumeContribution,
|
||||
};
|
||||
use shared::{
|
||||
inspector_component_active, InspectorOrder, LevelObject, PostProcessVolumeDesc,
|
||||
PostProcessVolumeOverrides, COMPONENT_POST_PROCESS_VOLUME,
|
||||
authoring_component_active, AuthoringComponentStates, InspectorOrder, LevelObject,
|
||||
PostProcessVolumeDesc, PostProcessVolumeOverrides, COMPONENT_POST_PROCESS_VOLUME,
|
||||
};
|
||||
|
||||
use super::solari::SolariRaytracingSceneStats;
|
||||
@ -264,6 +264,7 @@ pub fn sync_active_camera_render_profile(
|
||||
Entity,
|
||||
&PostProcessVolumeDesc,
|
||||
&GlobalTransform,
|
||||
Option<&AuthoringComponentStates>,
|
||||
Option<&InspectorOrder>,
|
||||
),
|
||||
With<LevelObject>,
|
||||
@ -305,10 +306,10 @@ pub fn sync_active_camera_render_profile(
|
||||
|
||||
let volume_list: Vec<_> = volumes
|
||||
.iter()
|
||||
.filter(|(_, _, _, order)| {
|
||||
inspector_component_active(*order, COMPONENT_POST_PROCESS_VOLUME)
|
||||
.filter(|(_, _, _, states, legacy_order)| {
|
||||
authoring_component_active(*states, *legacy_order, COMPONENT_POST_PROCESS_VOLUME)
|
||||
})
|
||||
.map(|(entity, desc, transform, _)| (entity, desc, transform))
|
||||
.map(|(entity, desc, transform, _, _)| (entity, desc, transform))
|
||||
.collect();
|
||||
let (resolved, entries) = resolve_active_camera_render_profile(
|
||||
camera_tf.translation(),
|
||||
|
||||
@ -4,8 +4,8 @@ use bevy::light::CascadeShadowConfig;
|
||||
use bevy::prelude::*;
|
||||
use settings::ProjectSettings;
|
||||
use shared::{
|
||||
cascade_config_from_rendering, inspector_component_active, AuthoringLightKind, InspectorOrder,
|
||||
LevelObject, LightDesc, ProjectSun, COMPONENT_LIGHT_DESC,
|
||||
authoring_component_active, cascade_config_from_rendering, AuthoringComponentStates,
|
||||
AuthoringLightKind, InspectorOrder, LevelObject, LightDesc, ProjectSun, COMPONENT_LIGHT_DESC,
|
||||
};
|
||||
|
||||
/// Spawns the directional "sun" from project rendering settings.
|
||||
@ -14,7 +14,14 @@ pub fn spawn_sun(
|
||||
mut commands: Commands,
|
||||
settings: Res<ProjectSettings>,
|
||||
existing: Query<(), With<DirectionalLight>>,
|
||||
scene_suns: Query<(&LightDesc, Option<&InspectorOrder>), With<LevelObject>>,
|
||||
scene_suns: Query<
|
||||
(
|
||||
&LightDesc,
|
||||
Option<&AuthoringComponentStates>,
|
||||
Option<&InspectorOrder>,
|
||||
),
|
||||
With<LevelObject>,
|
||||
>,
|
||||
) {
|
||||
if !existing.is_empty() {
|
||||
return;
|
||||
@ -39,7 +46,14 @@ pub fn spawn_sun(
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub fn sync_project_sun_visibility(
|
||||
scene_suns: Query<(&LightDesc, Option<&InspectorOrder>), With<LevelObject>>,
|
||||
scene_suns: Query<
|
||||
(
|
||||
&LightDesc,
|
||||
Option<&AuthoringComponentStates>,
|
||||
Option<&InspectorOrder>,
|
||||
),
|
||||
With<LevelObject>,
|
||||
>,
|
||||
mut project_suns: Query<(&mut DirectionalLight, &mut Visibility), With<ProjectSun>>,
|
||||
) {
|
||||
let scene_override = has_scene_sun_override(&scene_suns);
|
||||
@ -54,10 +68,17 @@ pub fn sync_project_sun_visibility(
|
||||
}
|
||||
|
||||
fn has_scene_sun_override(
|
||||
scene_suns: &Query<(&LightDesc, Option<&InspectorOrder>), With<LevelObject>>,
|
||||
scene_suns: &Query<
|
||||
(
|
||||
&LightDesc,
|
||||
Option<&AuthoringComponentStates>,
|
||||
Option<&InspectorOrder>,
|
||||
),
|
||||
With<LevelObject>,
|
||||
>,
|
||||
) -> bool {
|
||||
scene_suns.iter().any(|(light, order)| {
|
||||
inspector_component_active(order, COMPONENT_LIGHT_DESC)
|
||||
scene_suns.iter().any(|(light, states, legacy_order)| {
|
||||
authoring_component_active(states, legacy_order, COMPONENT_LIGHT_DESC)
|
||||
&& matches!(light.kind, AuthoringLightKind::Directional)
|
||||
})
|
||||
}
|
||||
@ -99,7 +120,14 @@ pub fn sync_scene_directional_shadow_cascades(
|
||||
#[unsafe(no_mangle)]
|
||||
pub fn sync_project_sun_from_settings(
|
||||
settings: Res<ProjectSettings>,
|
||||
scene_suns: Query<(&LightDesc, Option<&InspectorOrder>), With<LevelObject>>,
|
||||
scene_suns: Query<
|
||||
(
|
||||
&LightDesc,
|
||||
Option<&AuthoringComponentStates>,
|
||||
Option<&InspectorOrder>,
|
||||
),
|
||||
With<LevelObject>,
|
||||
>,
|
||||
mut project_suns: Query<(&mut DirectionalLight, &mut Visibility), With<ProjectSun>>,
|
||||
) {
|
||||
let scene_override = has_scene_sun_override(&scene_suns);
|
||||
|
||||
@ -10,5 +10,8 @@ blake3 = "1"
|
||||
serde = { workspace = true }
|
||||
ron = "0.8"
|
||||
ron-edit = "=0.2.0"
|
||||
nav_glam.workspace = true
|
||||
polyanya.workspace = true
|
||||
rerecast.workspace = true
|
||||
shared.workspace = true
|
||||
settings.workspace = true
|
||||
|
||||
@ -743,7 +743,7 @@ mod tests {
|
||||
let body = "(resources: {}, entities: {})";
|
||||
let document = SceneDocument::from_ron_text(body).unwrap();
|
||||
let text = document.to_ron_text().unwrap();
|
||||
assert!(text.starts_with("(schema_version: 2,"));
|
||||
assert!(text.starts_with("(schema_version: 4,"));
|
||||
assert!(SceneDocument::from_ron_text(&text)
|
||||
.unwrap()
|
||||
.entities
|
||||
|
||||
@ -6,15 +6,19 @@
|
||||
mod composition;
|
||||
pub mod document;
|
||||
mod migrate;
|
||||
pub mod navigation;
|
||||
mod prefab;
|
||||
mod project_validation;
|
||||
mod upgrade;
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub use composition::{validate_composition_graph, validate_scene_composition};
|
||||
pub use migrate::{migrate_v1_to_v2, DEFAULT_SUN_ILLUMINANCE_LUX};
|
||||
pub use migrate::{
|
||||
migrate_v1_to_v2, migrate_v2_to_v3, migrate_v3_to_v4, DEFAULT_SUN_ILLUMINANCE_LUX,
|
||||
};
|
||||
pub use prefab::{
|
||||
prefab_graph_revision, validate_prefab_graph, validate_prefab_graph_text,
|
||||
validate_prefab_references, validate_prefab_references_text,
|
||||
@ -23,8 +27,9 @@ pub use project_validation::{
|
||||
validate_project, ProjectDependency, ProjectValidationFinding, ProjectValidationReport,
|
||||
ValidationSeverity,
|
||||
};
|
||||
pub use upgrade::{upgrade_project, ProjectUpgradeChange, ProjectUpgradeReport};
|
||||
|
||||
pub const CURRENT_SCENE_SCHEMA_VERSION: u32 = 2;
|
||||
pub const CURRENT_SCENE_SCHEMA_VERSION: u32 = 4;
|
||||
|
||||
/// Whether an asset-relative path names a tree excluded from runtime packages.
|
||||
pub fn is_runtime_package_excluded_tree(relative: &Path) -> bool {
|
||||
@ -129,8 +134,10 @@ pub fn migrate_scene_text(text: &str) -> Result<String, String> {
|
||||
};
|
||||
|
||||
let migrated_body = match version {
|
||||
0 | 1 => migrate_v1_to_v2(&body),
|
||||
2 => body,
|
||||
0 | 1 => migrate_v3_to_v4(&migrate_v2_to_v3(&migrate_v1_to_v2(&body))),
|
||||
2 => migrate_v3_to_v4(&migrate_v2_to_v3(&body)),
|
||||
3 => migrate_v3_to_v4(&body),
|
||||
4 => body,
|
||||
_ => {
|
||||
return Err(format!(
|
||||
"scene schema version {version} is newer than supported version {CURRENT_SCENE_SCHEMA_VERSION}"
|
||||
@ -220,16 +227,16 @@ mod tests {
|
||||
fn stamp_and_strip_round_trip() {
|
||||
let body = "(resources: {}, entities: {})";
|
||||
let stamped = stamp_schema_version(body).unwrap();
|
||||
assert!(stamped.starts_with("(schema_version: 2,"));
|
||||
assert!(stamped.starts_with("(schema_version: 4,"));
|
||||
let stripped = strip_schema_version(&stamped).unwrap();
|
||||
assert_eq!(stripped, body);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_scene_migrates_to_v2() {
|
||||
fn legacy_scene_migrates_to_current_schema() {
|
||||
let legacy = "(resources: {}, entities: {4294967133: ()})";
|
||||
let migrated = migrate_scene_text(legacy).unwrap();
|
||||
assert!(migrated.contains("schema_version: 2"));
|
||||
assert!(migrated.contains("schema_version: 4"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@ -12,6 +12,57 @@ pub fn migrate_v1_to_v2(body: &str) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// Migrates v2 animated imported-model actors to the dedicated skinned renderer contract.
|
||||
///
|
||||
/// `ModelRef` and `SkinnedMeshRenderer` intentionally share the same serialized source fields, so
|
||||
/// the migration can preserve stable asset IDs and cached paths without lossy reconstruction.
|
||||
pub fn migrate_v2_to_v3(body: &str) -> String {
|
||||
let mut result = String::with_capacity(body.len() + 256);
|
||||
let mut rest = body;
|
||||
while let Some(index) = rest.find("components: {") {
|
||||
result.push_str(&rest[..index]);
|
||||
let after = &rest[index..];
|
||||
let Some(close) = find_components_close(after) else {
|
||||
result.push_str(after);
|
||||
return result;
|
||||
};
|
||||
let block = &after[..close];
|
||||
if block.contains("\"shared::animation::AnimationControllerDesc\"")
|
||||
&& block.contains("\"shared::components::ModelRef\"")
|
||||
{
|
||||
result.push_str(
|
||||
&block
|
||||
.replace(
|
||||
"\"shared::components::ModelRef\"",
|
||||
"\"shared::animation::SkinnedMeshRenderer\"",
|
||||
)
|
||||
.replace(
|
||||
"\"shared::components::ActorKind\": ImportedModel",
|
||||
"\"shared::components::ActorKind\": SkinnedMesh",
|
||||
)
|
||||
.replace(
|
||||
"\"shared::components::ActorKind\":ImportedModel",
|
||||
"\"shared::components::ActorKind\":SkinnedMesh",
|
||||
),
|
||||
);
|
||||
} else {
|
||||
result.push_str(block);
|
||||
}
|
||||
rest = &after[close..];
|
||||
}
|
||||
result.push_str(rest);
|
||||
result
|
||||
}
|
||||
|
||||
/// Migrates schema-v3 documents into the registry-driven renderer/component foundation.
|
||||
///
|
||||
/// The v4 authoring types deliberately use serde defaults for material-slot sets and component
|
||||
/// enable state, so normal loading can remain read-only. The explicit project upgrader later
|
||||
/// materializes shared Material assets and canonical slot IDs on disk.
|
||||
pub fn migrate_v3_to_v4(body: &str) -> String {
|
||||
body.to_string()
|
||||
}
|
||||
|
||||
fn backfill_actor_kinds(text: &str) -> String {
|
||||
let mut result = String::with_capacity(text.len() + 1024);
|
||||
let mut rest = text;
|
||||
@ -89,6 +140,9 @@ fn infer_kind_from_block(block: &str) -> &'static str {
|
||||
if block.contains("\"shared::components::LightDesc\"") {
|
||||
return actor_kind_ron(ActorKind::Light);
|
||||
}
|
||||
if block.contains("\"shared::animation::SkinnedMeshRenderer\"") {
|
||||
return actor_kind_ron(ActorKind::SkinnedMesh);
|
||||
}
|
||||
if block.contains("\"shared::components::ModelRef\"") {
|
||||
return actor_kind_ron(ActorKind::ImportedModel);
|
||||
}
|
||||
@ -111,6 +165,9 @@ fn infer_kind_from_block(block: &str) -> &'static str {
|
||||
if block.contains("\"shared::components::AudioListenerDesc\"") {
|
||||
return actor_kind_ron(ActorKind::AudioListener);
|
||||
}
|
||||
if block.contains("\"shared::navigation::Navigation") {
|
||||
return actor_kind_ron(ActorKind::Navigation);
|
||||
}
|
||||
actor_kind_ron(ActorKind::Empty)
|
||||
}
|
||||
|
||||
@ -119,6 +176,7 @@ fn actor_kind_ron(kind: ActorKind) -> &'static str {
|
||||
ActorKind::Empty => "Empty",
|
||||
ActorKind::Brush => "Brush",
|
||||
ActorKind::StaticMesh => "StaticMesh",
|
||||
ActorKind::SkinnedMesh => "SkinnedMesh",
|
||||
ActorKind::ImportedModel => "ImportedModel",
|
||||
ActorKind::Light => "Light",
|
||||
ActorKind::PrefabAnchor => "PrefabAnchor",
|
||||
@ -130,6 +188,7 @@ fn actor_kind_ron(kind: ActorKind) -> &'static str {
|
||||
ActorKind::Objective => "Objective",
|
||||
ActorKind::AudioSource => "AudioSource",
|
||||
ActorKind::AudioListener => "AudioListener",
|
||||
ActorKind::Navigation => "Navigation",
|
||||
}
|
||||
}
|
||||
|
||||
@ -241,6 +300,35 @@ mod tests {
|
||||
assert!(migrated.contains("Brush"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn v2_animation_actor_moves_to_dedicated_skinned_renderer() {
|
||||
let body = r#"(
|
||||
resources: {},
|
||||
entities: {
|
||||
1: (components: {
|
||||
"shared::components::LevelObject": (),
|
||||
"shared::components::ActorKind": ImportedModel,
|
||||
"shared::components::ModelRef": (asset_id: "hero", path: "models/hero.glb", scene_index: 0),
|
||||
"shared::animation::AnimationControllerDesc": (skeleton: None, states: [], default_state: "", default_crossfade_seconds: 0.2),
|
||||
}),
|
||||
2: (components: {
|
||||
"shared::components::ActorKind": ImportedModel,
|
||||
"shared::components::ModelRef": (asset_id: "prop", path: "models/prop.glb", scene_index: 0),
|
||||
}),
|
||||
},
|
||||
)"#;
|
||||
|
||||
let migrated = migrate_v2_to_v3(body);
|
||||
|
||||
assert!(migrated.contains("\"shared::animation::SkinnedMeshRenderer\""));
|
||||
assert!(migrated.contains("\"shared::components::ActorKind\": SkinnedMesh"));
|
||||
assert_eq!(
|
||||
migrated.matches("\"shared::components::ModelRef\"").count(),
|
||||
1,
|
||||
"generic scene instance must remain a ModelRef"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn backfills_actor_kind_for_audio_source_entity() {
|
||||
let body = r#"(
|
||||
|
||||
899
crates/scene/src/navigation.rs
Normal file
899
crates/scene/src/navigation.rs
Normal file
@ -0,0 +1,899 @@
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use nav_glam::{UVec3, Vec2, Vec3, Vec3A};
|
||||
use polyanya::{Mesh, RecastFullMesh};
|
||||
use rerecast::{
|
||||
Aabb3d, AreaType, ConfigBuilder, ConvexVolume, DetailNavmesh, HeightfieldBuilder, TriMesh,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use shared::{
|
||||
BrushDesc, BrushKind, NavigationAgentProfile, NavigationArea, NavigationBounds, NavigationLink,
|
||||
NavigationObstacle, Primitive, PrimitiveShape, COMPONENT_BRUSH_DESC, COMPONENT_NAVIGATION_AREA,
|
||||
COMPONENT_NAVIGATION_BOUNDS, COMPONENT_NAVIGATION_LINK, COMPONENT_NAVIGATION_OBSTACLE,
|
||||
COMPONENT_PRIMITIVE,
|
||||
};
|
||||
|
||||
use crate::document::{SceneDocument, SceneEntity};
|
||||
|
||||
pub const NAVIGATION_ARTIFACT_SCHEMA_VERSION: u32 = 1;
|
||||
pub const NAVIGATION_GENERATOR_ID: &str = "rerecast-0.3/polyanya-0.16";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct NavigationVolumeInput {
|
||||
pub actor_id: String,
|
||||
pub center: [f32; 3],
|
||||
pub half_extents: [f32; 3],
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct NavigationAreaInput {
|
||||
pub actor_id: String,
|
||||
pub id: String,
|
||||
pub center: [f32; 3],
|
||||
pub half_extents: [f32; 3],
|
||||
pub cost: f32,
|
||||
pub walkable: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct NavigationLinkInput {
|
||||
pub actor_id: String,
|
||||
pub start: [f32; 3],
|
||||
pub end: [f32; 3],
|
||||
pub bidirectional: bool,
|
||||
pub cost: f32,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct NavigationGeometryInput {
|
||||
pub actor_id: String,
|
||||
pub triangles: Vec<[[f32; 3]; 3]>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct NavigationBakeInput {
|
||||
pub source_scene: String,
|
||||
pub bounds_actor_id: String,
|
||||
pub center: [f32; 3],
|
||||
pub half_extents: [f32; 3],
|
||||
pub agent: NavigationAgentProfile,
|
||||
#[serde(default)]
|
||||
pub geometry: Vec<NavigationGeometryInput>,
|
||||
#[serde(default)]
|
||||
pub obstacles: Vec<NavigationVolumeInput>,
|
||||
#[serde(default)]
|
||||
pub areas: Vec<NavigationAreaInput>,
|
||||
#[serde(default)]
|
||||
pub links: Vec<NavigationLinkInput>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct NavigationBakeJob {
|
||||
pub artifact_path: String,
|
||||
pub input: NavigationBakeInput,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NavigationBakeArtifact {
|
||||
pub schema_version: u32,
|
||||
pub generator: String,
|
||||
pub source_scene: String,
|
||||
pub source_fingerprint: String,
|
||||
pub bounds_actor_id: String,
|
||||
pub center: [f32; 3],
|
||||
pub half_extents: [f32; 3],
|
||||
pub agent: NavigationAgentProfile,
|
||||
pub mesh: Mesh,
|
||||
pub links: Vec<NavigationLinkInput>,
|
||||
pub areas: Vec<NavigationAreaInput>,
|
||||
}
|
||||
|
||||
impl NavigationBakeArtifact {
|
||||
pub fn runtime_mesh(&self) -> Mesh {
|
||||
let mut mesh = self.mesh.clone();
|
||||
mesh.bake();
|
||||
mesh
|
||||
}
|
||||
|
||||
pub fn is_stale_for(&self, input: &NavigationBakeInput) -> bool {
|
||||
self.schema_version != NAVIGATION_ARTIFACT_SCHEMA_VERSION
|
||||
|| self.generator != NAVIGATION_GENERATOR_ID
|
||||
|| self.source_fingerprint != navigation_source_fingerprint(input)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn navigation_source_fingerprint(input: &NavigationBakeInput) -> String {
|
||||
let bytes = ron::ser::to_string(input)
|
||||
.expect("navigation bake input contains only serializable authoring data");
|
||||
blake3::hash(bytes.as_bytes()).to_hex().to_string()
|
||||
}
|
||||
|
||||
pub fn bake_navigation(input: &NavigationBakeInput) -> Result<NavigationBakeArtifact, String> {
|
||||
validate_navigation_input(input)?;
|
||||
|
||||
let center = vec3(input.center);
|
||||
let half_extents = vec3(input.half_extents);
|
||||
let min = center - half_extents;
|
||||
let max = center + half_extents;
|
||||
let mut trimesh = floor_trimesh(min, max, center.y);
|
||||
append_geometry(&mut trimesh, &input.geometry);
|
||||
let config = ConfigBuilder {
|
||||
cell_size_fraction: input.agent.cell_size_fraction,
|
||||
cell_height_fraction: input.agent.cell_height_fraction,
|
||||
agent_height: input.agent.height,
|
||||
agent_radius: input.agent.radius,
|
||||
walkable_climb: input.agent.max_climb,
|
||||
walkable_slope_angle: input.agent.max_slope_deg.to_radians(),
|
||||
min_region_size: input.agent.min_region_size,
|
||||
merge_region_size: input.agent.merge_region_size,
|
||||
aabb: Aabb3d { min, max },
|
||||
..Default::default()
|
||||
}
|
||||
.build();
|
||||
|
||||
trimesh.mark_walkable_triangles(config.walkable_slope_angle);
|
||||
let mut heightfield = HeightfieldBuilder {
|
||||
aabb: config.aabb,
|
||||
cell_size: config.cell_size,
|
||||
cell_height: config.cell_height,
|
||||
}
|
||||
.build()
|
||||
.map_err(|error| format!("failed to allocate navigation heightfield: {error}"))?;
|
||||
heightfield
|
||||
.rasterize_triangles(&trimesh, config.walkable_climb)
|
||||
.map_err(|error| format!("failed to rasterize navigation geometry: {error}"))?;
|
||||
heightfield.filter_low_hanging_walkable_obstacles(config.walkable_climb);
|
||||
heightfield.filter_ledge_spans(config.walkable_height, config.walkable_climb);
|
||||
heightfield.filter_walkable_low_height_spans(config.walkable_height);
|
||||
|
||||
let mut compact = heightfield
|
||||
.into_compact(config.walkable_height, config.walkable_climb)
|
||||
.map_err(|error| format!("failed to compact navigation heightfield: {error}"))?;
|
||||
compact.erode_walkable_area(config.walkable_radius);
|
||||
for obstacle in &input.obstacles {
|
||||
compact.mark_convex_poly_area(&unwalkable_volume(obstacle.center, obstacle.half_extents));
|
||||
}
|
||||
for area in input.areas.iter().filter(|area| !area.walkable) {
|
||||
compact.mark_convex_poly_area(&unwalkable_volume(area.center, area.half_extents));
|
||||
}
|
||||
|
||||
compact.build_distance_field();
|
||||
compact
|
||||
.build_regions(
|
||||
config.border_size,
|
||||
config.min_region_area,
|
||||
config.merge_region_area,
|
||||
)
|
||||
.map_err(|error| format!("failed to partition navigation regions: {error}"))?;
|
||||
let contours = compact.build_contours(
|
||||
config.max_simplification_error,
|
||||
config.max_edge_len,
|
||||
config.contour_flags,
|
||||
);
|
||||
let polygon = contours
|
||||
.into_polygon_mesh(config.max_vertices_per_polygon)
|
||||
.map_err(|error| format!("failed to build navigation polygons: {error}"))?;
|
||||
if polygon.polygon_count() == 0 {
|
||||
return Err(
|
||||
"navigation bake produced no walkable polygons; enlarge bounds or repair obstacles"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
let detail = DetailNavmesh::new(
|
||||
&polygon,
|
||||
&compact,
|
||||
config.detail_sample_dist,
|
||||
config.detail_sample_max_error,
|
||||
)
|
||||
.map_err(|error| format!("failed to build navigation detail mesh: {error}"))?;
|
||||
|
||||
let mut mesh: Mesh = RecastFullMesh::new(polygon, detail).into();
|
||||
mesh.unbake();
|
||||
Ok(NavigationBakeArtifact {
|
||||
schema_version: NAVIGATION_ARTIFACT_SCHEMA_VERSION,
|
||||
generator: NAVIGATION_GENERATOR_ID.into(),
|
||||
source_scene: input.source_scene.clone(),
|
||||
source_fingerprint: navigation_source_fingerprint(input),
|
||||
bounds_actor_id: input.bounds_actor_id.clone(),
|
||||
center: input.center,
|
||||
half_extents: input.half_extents,
|
||||
agent: input.agent.clone(),
|
||||
mesh,
|
||||
links: input.links.clone(),
|
||||
areas: input.areas.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn write_navigation_artifact(
|
||||
path: &Path,
|
||||
artifact: &NavigationBakeArtifact,
|
||||
) -> Result<(), String> {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).map_err(|error| {
|
||||
format!("failed to create navigation artifact directory {parent:?}: {error}")
|
||||
})?;
|
||||
}
|
||||
let text = ron::ser::to_string_pretty(artifact, ron::ser::PrettyConfig::default())
|
||||
.map_err(|error| format!("failed to serialize navigation artifact: {error}"))?;
|
||||
let temporary = temporary_path(path);
|
||||
fs::write(&temporary, text)
|
||||
.map_err(|error| format!("failed to write navigation artifact {temporary:?}: {error}"))?;
|
||||
fs::rename(&temporary, path)
|
||||
.map_err(|error| format!("failed to replace navigation artifact {path:?}: {error}"))
|
||||
}
|
||||
|
||||
pub fn read_navigation_artifact(path: &Path) -> Result<NavigationBakeArtifact, String> {
|
||||
let text = fs::read_to_string(path)
|
||||
.map_err(|error| format!("failed to read navigation artifact {path:?}: {error}"))?;
|
||||
let artifact: NavigationBakeArtifact = ron::from_str(&text)
|
||||
.map_err(|error| format!("failed to parse navigation artifact {path:?}: {error}"))?;
|
||||
if artifact.schema_version != NAVIGATION_ARTIFACT_SCHEMA_VERSION {
|
||||
return Err(format!(
|
||||
"navigation artifact schema {} is unsupported; rebake with schema {}",
|
||||
artifact.schema_version, NAVIGATION_ARTIFACT_SCHEMA_VERSION
|
||||
));
|
||||
}
|
||||
Ok(artifact)
|
||||
}
|
||||
|
||||
pub fn navigation_bake_jobs_from_document(
|
||||
document: &SceneDocument,
|
||||
source_scene: &str,
|
||||
) -> Result<Vec<NavigationBakeJob>, String> {
|
||||
if !document.entities.iter().any(|entity| {
|
||||
entity
|
||||
.components
|
||||
.iter()
|
||||
.any(|component| component.type_name == COMPONENT_NAVIGATION_BOUNDS)
|
||||
}) {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let poses = document_world_poses(document)?;
|
||||
let mut obstacles = Vec::new();
|
||||
let mut areas = Vec::new();
|
||||
let mut links = Vec::new();
|
||||
let mut bounds = Vec::new();
|
||||
let mut geometry = Vec::new();
|
||||
|
||||
for entity in &document.entities {
|
||||
let bounds_value = component::<NavigationBounds>(entity, COMPONENT_NAVIGATION_BOUNDS)?;
|
||||
let obstacle_value =
|
||||
component::<NavigationObstacle>(entity, COMPONENT_NAVIGATION_OBSTACLE)?;
|
||||
let area_value = component::<NavigationArea>(entity, COMPONENT_NAVIGATION_AREA)?;
|
||||
let link_value = component::<NavigationLink>(entity, COMPONENT_NAVIGATION_LINK)?;
|
||||
let primitive = component::<Primitive>(entity, COMPONENT_PRIMITIVE)?;
|
||||
let brush = component::<BrushDesc>(entity, COMPONENT_BRUSH_DESC)?;
|
||||
if bounds_value.is_none()
|
||||
&& obstacle_value.is_none()
|
||||
&& area_value.is_none()
|
||||
&& link_value.is_none()
|
||||
&& primitive.is_none()
|
||||
&& brush.is_none()
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let actor_id = entity
|
||||
.actor_id
|
||||
.clone()
|
||||
.ok_or_else(|| format!("navigation entity {} requires ActorId", entity.document_id))?;
|
||||
let pose = poses
|
||||
.get(&actor_id)
|
||||
.copied()
|
||||
.unwrap_or_else(WorldPose::identity);
|
||||
if let Some(value) = bounds_value {
|
||||
bounds.push((actor_id.clone(), pose, value));
|
||||
}
|
||||
if let Some(value) = obstacle_value {
|
||||
obstacles.push(NavigationVolumeInput {
|
||||
actor_id: actor_id.clone(),
|
||||
center: pose.translation.to_array(),
|
||||
half_extents: rotated_aabb_half_extents(pose, value.half_extents.to_array()),
|
||||
});
|
||||
}
|
||||
if let Some(value) = area_value {
|
||||
areas.push(NavigationAreaInput {
|
||||
actor_id: actor_id.clone(),
|
||||
id: value.id,
|
||||
center: pose.translation.to_array(),
|
||||
half_extents: rotated_aabb_half_extents(pose, value.half_extents.to_array()),
|
||||
cost: value.cost,
|
||||
walkable: value.walkable,
|
||||
});
|
||||
}
|
||||
if let Some(value) = link_value {
|
||||
links.push(NavigationLinkInput {
|
||||
actor_id: actor_id.clone(),
|
||||
start: pose.transform_point(value.start.to_array()).to_array(),
|
||||
end: pose.transform_point(value.end.to_array()).to_array(),
|
||||
bidirectional: value.bidirectional,
|
||||
cost: value.cost,
|
||||
enabled: value.enabled,
|
||||
});
|
||||
}
|
||||
if let Some(value) = navigation_geometry_from_parts(
|
||||
actor_id,
|
||||
pose.translation.to_array(),
|
||||
pose.rotation.to_array(),
|
||||
pose.scale.to_array(),
|
||||
primitive.as_ref(),
|
||||
brush.as_ref(),
|
||||
) {
|
||||
geometry.push(value);
|
||||
}
|
||||
}
|
||||
obstacles.sort_by(|a, b| a.actor_id.cmp(&b.actor_id));
|
||||
areas.sort_by(|a, b| a.actor_id.cmp(&b.actor_id));
|
||||
links.sort_by(|a, b| a.actor_id.cmp(&b.actor_id));
|
||||
geometry.sort_by(|a, b| a.actor_id.cmp(&b.actor_id));
|
||||
bounds.sort_by(|a, b| a.0.cmp(&b.0));
|
||||
|
||||
Ok(bounds
|
||||
.into_iter()
|
||||
.map(|(actor_id, pose, bounds)| {
|
||||
let center = pose.translation.to_array();
|
||||
let half_extents = rotated_aabb_half_extents(pose, bounds.half_extents.to_array());
|
||||
NavigationBakeJob {
|
||||
artifact_path: bounds.artifact_path,
|
||||
input: NavigationBakeInput {
|
||||
source_scene: source_scene.to_string(),
|
||||
bounds_actor_id: actor_id,
|
||||
center,
|
||||
half_extents,
|
||||
agent: bounds.agent,
|
||||
geometry: geometry
|
||||
.iter()
|
||||
.filter(|value| geometry_overlaps_bounds(value, center, half_extents))
|
||||
.cloned()
|
||||
.collect(),
|
||||
obstacles: obstacles
|
||||
.iter()
|
||||
.filter(|value| {
|
||||
aabb_overlaps(center, half_extents, value.center, value.half_extents)
|
||||
})
|
||||
.cloned()
|
||||
.collect(),
|
||||
areas: areas
|
||||
.iter()
|
||||
.filter(|value| {
|
||||
aabb_overlaps(center, half_extents, value.center, value.half_extents)
|
||||
})
|
||||
.cloned()
|
||||
.collect(),
|
||||
links: links
|
||||
.iter()
|
||||
.filter(|value| {
|
||||
point_in_aabb(value.start, center, half_extents)
|
||||
|| point_in_aabb(value.end, center, half_extents)
|
||||
})
|
||||
.cloned()
|
||||
.collect(),
|
||||
},
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
fn validate_navigation_input(input: &NavigationBakeInput) -> Result<(), String> {
|
||||
input
|
||||
.agent
|
||||
.validate()
|
||||
.map_err(|error| format!("invalid navigation agent profile: {error}"))?;
|
||||
if input.source_scene.trim().is_empty() {
|
||||
return Err("navigation bake requires a source scene path".into());
|
||||
}
|
||||
if input.bounds_actor_id.trim().is_empty() {
|
||||
return Err("navigation bounds actor requires a stable ActorId".into());
|
||||
}
|
||||
validate_half_extents(input.half_extents, "navigation bounds")?;
|
||||
for geometry in &input.geometry {
|
||||
if geometry.actor_id.trim().is_empty() {
|
||||
return Err("navigation geometry requires a stable actor ID".into());
|
||||
}
|
||||
for triangle in &geometry.triangles {
|
||||
if !triangle.iter().copied().all(finite3) {
|
||||
return Err(format!(
|
||||
"navigation geometry {} contains a non-finite triangle",
|
||||
geometry.actor_id
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
for obstacle in &input.obstacles {
|
||||
validate_half_extents(obstacle.half_extents, "navigation obstacle")?;
|
||||
}
|
||||
for area in &input.areas {
|
||||
validate_half_extents(area.half_extents, "navigation area")?;
|
||||
if area.id.trim().is_empty() {
|
||||
return Err(format!("navigation area {} has an empty ID", area.actor_id));
|
||||
}
|
||||
if !area.cost.is_finite() || area.cost <= 0.0 {
|
||||
return Err(format!(
|
||||
"navigation area {} cost must be finite and greater than zero",
|
||||
area.actor_id
|
||||
));
|
||||
}
|
||||
}
|
||||
for link in &input.links {
|
||||
if link.enabled && (!finite3(link.start) || !finite3(link.end)) {
|
||||
return Err(format!(
|
||||
"navigation link {} endpoints must be finite",
|
||||
link.actor_id
|
||||
));
|
||||
}
|
||||
if link.enabled && squared_distance(link.start, link.end) <= f32::EPSILON {
|
||||
return Err(format!(
|
||||
"navigation link {} endpoints must be distinct",
|
||||
link.actor_id
|
||||
));
|
||||
}
|
||||
if !link.cost.is_finite() || link.cost <= 0.0 {
|
||||
return Err(format!(
|
||||
"navigation link {} cost must be finite and greater than zero",
|
||||
link.actor_id
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn floor_trimesh(min: Vec3, max: Vec3, y: f32) -> TriMesh {
|
||||
TriMesh {
|
||||
vertices: vec![
|
||||
Vec3A::new(min.x, y, min.z),
|
||||
Vec3A::new(max.x, y, min.z),
|
||||
Vec3A::new(max.x, y, max.z),
|
||||
Vec3A::new(min.x, y, max.z),
|
||||
],
|
||||
indices: vec![UVec3::new(0, 2, 1), UVec3::new(0, 3, 2)],
|
||||
area_types: vec![AreaType::DEFAULT_WALKABLE; 2],
|
||||
}
|
||||
}
|
||||
|
||||
fn append_geometry(trimesh: &mut TriMesh, geometry: &[NavigationGeometryInput]) {
|
||||
for source in geometry {
|
||||
for triangle in &source.triangles {
|
||||
let base = trimesh.vertices.len() as u32;
|
||||
trimesh
|
||||
.vertices
|
||||
.extend(triangle.iter().map(|vertex| Vec3A::from_array(*vertex)));
|
||||
trimesh.indices.push(UVec3::new(base, base + 1, base + 2));
|
||||
trimesh.area_types.push(AreaType::DEFAULT_WALKABLE);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn navigation_geometry_from_parts(
|
||||
actor_id: String,
|
||||
translation: [f32; 3],
|
||||
rotation: [f32; 4],
|
||||
scale: [f32; 3],
|
||||
primitive: Option<&Primitive>,
|
||||
brush: Option<&BrushDesc>,
|
||||
) -> Option<NavigationGeometryInput> {
|
||||
let pose = WorldPose {
|
||||
translation: Vec3::from_array(translation),
|
||||
rotation: nav_glam::Quat::from_array(rotation).normalize(),
|
||||
scale: Vec3::from_array(scale),
|
||||
};
|
||||
let local_triangles =
|
||||
if let Some(brush) = brush.filter(|brush| brush.kind == BrushKind::Additive) {
|
||||
brush_triangles(brush)
|
||||
} else if let Some(primitive) = primitive {
|
||||
primitive_triangles(primitive)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
(!local_triangles.is_empty()).then(|| NavigationGeometryInput {
|
||||
actor_id,
|
||||
triangles: local_triangles
|
||||
.into_iter()
|
||||
.map(|triangle| triangle.map(|point| pose.transform_point(point).to_array()))
|
||||
.collect(),
|
||||
})
|
||||
}
|
||||
|
||||
fn brush_triangles(brush: &BrushDesc) -> Vec<[[f32; 3]; 3]> {
|
||||
let mut triangles = Vec::new();
|
||||
for face in &brush.faces {
|
||||
let Some(first) = face.vertices.first() else {
|
||||
continue;
|
||||
};
|
||||
for edge in face.vertices[1..].windows(2) {
|
||||
triangles.push([first.to_array(), edge[0].to_array(), edge[1].to_array()]);
|
||||
}
|
||||
}
|
||||
triangles
|
||||
}
|
||||
|
||||
fn primitive_triangles(primitive: &Primitive) -> Vec<[[f32; 3]; 3]> {
|
||||
let half = primitive.size.abs() * 0.5;
|
||||
let vertices = match primitive.shape {
|
||||
PrimitiveShape::Box => vec![
|
||||
[-half.x, -half.y, -half.z],
|
||||
[half.x, -half.y, -half.z],
|
||||
[half.x, half.y, -half.z],
|
||||
[-half.x, half.y, -half.z],
|
||||
[-half.x, -half.y, half.z],
|
||||
[half.x, -half.y, half.z],
|
||||
[half.x, half.y, half.z],
|
||||
[-half.x, half.y, half.z],
|
||||
],
|
||||
PrimitiveShape::Ramp => vec![
|
||||
[-half.x, -half.y, -half.z],
|
||||
[half.x, -half.y, -half.z],
|
||||
[half.x, half.y, -half.z],
|
||||
[-half.x, half.y, -half.z],
|
||||
[-half.x, -half.y, half.z],
|
||||
[half.x, -half.y, half.z],
|
||||
],
|
||||
PrimitiveShape::Sphere => vec![
|
||||
[0.0, half.y, 0.0],
|
||||
[half.x, 0.0, 0.0],
|
||||
[0.0, 0.0, half.z],
|
||||
[-half.x, 0.0, 0.0],
|
||||
[0.0, 0.0, -half.z],
|
||||
[0.0, -half.y, 0.0],
|
||||
],
|
||||
};
|
||||
let indices: &[[usize; 3]] = match primitive.shape {
|
||||
PrimitiveShape::Box => &[
|
||||
[0, 2, 1],
|
||||
[0, 3, 2],
|
||||
[4, 5, 6],
|
||||
[4, 6, 7],
|
||||
[0, 1, 5],
|
||||
[0, 5, 4],
|
||||
[3, 7, 6],
|
||||
[3, 6, 2],
|
||||
[0, 4, 7],
|
||||
[0, 7, 3],
|
||||
[1, 2, 6],
|
||||
[1, 6, 5],
|
||||
],
|
||||
PrimitiveShape::Ramp => &[
|
||||
[0, 2, 1],
|
||||
[0, 3, 2],
|
||||
[0, 1, 5],
|
||||
[0, 5, 4],
|
||||
[0, 4, 3],
|
||||
[1, 2, 5],
|
||||
[3, 4, 5],
|
||||
[3, 5, 2],
|
||||
],
|
||||
PrimitiveShape::Sphere => &[
|
||||
[0, 2, 1],
|
||||
[0, 3, 2],
|
||||
[0, 4, 3],
|
||||
[0, 1, 4],
|
||||
[5, 1, 2],
|
||||
[5, 2, 3],
|
||||
[5, 3, 4],
|
||||
[5, 4, 1],
|
||||
],
|
||||
};
|
||||
indices
|
||||
.iter()
|
||||
.map(|[a, b, c]| [vertices[*a], vertices[*b], vertices[*c]])
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn aabb_overlaps(
|
||||
a_center: [f32; 3],
|
||||
a_half: [f32; 3],
|
||||
b_center: [f32; 3],
|
||||
b_half: [f32; 3],
|
||||
) -> bool {
|
||||
(0..3).all(|axis| (a_center[axis] - b_center[axis]).abs() <= a_half[axis] + b_half[axis])
|
||||
}
|
||||
|
||||
pub fn point_in_aabb(point: [f32; 3], center: [f32; 3], half_extents: [f32; 3]) -> bool {
|
||||
(0..3).all(|axis| (point[axis] - center[axis]).abs() <= half_extents[axis])
|
||||
}
|
||||
|
||||
pub fn geometry_overlaps_bounds(
|
||||
geometry: &NavigationGeometryInput,
|
||||
center: [f32; 3],
|
||||
half_extents: [f32; 3],
|
||||
) -> bool {
|
||||
geometry.triangles.iter().any(|triangle| {
|
||||
let mut min = triangle[0];
|
||||
let mut max = triangle[0];
|
||||
for vertex in &triangle[1..] {
|
||||
for axis in 0..3 {
|
||||
min[axis] = min[axis].min(vertex[axis]);
|
||||
max[axis] = max[axis].max(vertex[axis]);
|
||||
}
|
||||
}
|
||||
let geometry_center = std::array::from_fn(|axis| (min[axis] + max[axis]) * 0.5);
|
||||
let geometry_half = std::array::from_fn(|axis| (max[axis] - min[axis]) * 0.5);
|
||||
aabb_overlaps(center, half_extents, geometry_center, geometry_half)
|
||||
})
|
||||
}
|
||||
|
||||
fn unwalkable_volume(center: [f32; 3], half_extents: [f32; 3]) -> ConvexVolume {
|
||||
let center = vec3(center);
|
||||
let half = vec3(half_extents);
|
||||
ConvexVolume {
|
||||
vertices: vec![
|
||||
Vec2::new(center.x - half.x, center.z - half.z),
|
||||
Vec2::new(center.x + half.x, center.z - half.z),
|
||||
Vec2::new(center.x + half.x, center.z + half.z),
|
||||
Vec2::new(center.x - half.x, center.z + half.z),
|
||||
],
|
||||
min_y: center.y - half.y,
|
||||
max_y: center.y + half.y,
|
||||
area: AreaType::NOT_WALKABLE,
|
||||
}
|
||||
}
|
||||
|
||||
fn vec3(value: [f32; 3]) -> Vec3 {
|
||||
Vec3::from_array(value)
|
||||
}
|
||||
|
||||
fn finite3(value: [f32; 3]) -> bool {
|
||||
value.into_iter().all(f32::is_finite)
|
||||
}
|
||||
|
||||
fn validate_half_extents(value: [f32; 3], label: &str) -> Result<(), String> {
|
||||
if !finite3(value) || value.into_iter().any(|axis| axis <= 0.0) {
|
||||
return Err(format!(
|
||||
"{label} half extents must be finite and greater than zero"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn squared_distance(a: [f32; 3], b: [f32; 3]) -> f32 {
|
||||
a.into_iter().zip(b).map(|(a, b)| (a - b) * (a - b)).sum()
|
||||
}
|
||||
|
||||
fn temporary_path(path: &Path) -> PathBuf {
|
||||
let mut temporary = path.as_os_str().to_os_string();
|
||||
temporary.push(".tmp");
|
||||
PathBuf::from(temporary)
|
||||
}
|
||||
|
||||
const TRANSFORM_COMPONENT: &str = "bevy_transform::components::transform::Transform";
|
||||
|
||||
#[derive(Debug, Clone, Copy, Deserialize)]
|
||||
struct SerializedTransform {
|
||||
translation: [f32; 3],
|
||||
rotation: [f32; 4],
|
||||
scale: [f32; 3],
|
||||
}
|
||||
|
||||
impl Default for SerializedTransform {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
translation: [0.0; 3],
|
||||
rotation: [0.0, 0.0, 0.0, 1.0],
|
||||
scale: [1.0; 3],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct WorldPose {
|
||||
translation: Vec3,
|
||||
rotation: nav_glam::Quat,
|
||||
scale: Vec3,
|
||||
}
|
||||
|
||||
impl WorldPose {
|
||||
fn identity() -> Self {
|
||||
Self {
|
||||
translation: Vec3::ZERO,
|
||||
rotation: nav_glam::Quat::IDENTITY,
|
||||
scale: Vec3::ONE,
|
||||
}
|
||||
}
|
||||
|
||||
fn from_serialized(value: SerializedTransform) -> Self {
|
||||
Self {
|
||||
translation: Vec3::from_array(value.translation),
|
||||
rotation: nav_glam::Quat::from_array(value.rotation).normalize(),
|
||||
scale: Vec3::from_array(value.scale),
|
||||
}
|
||||
}
|
||||
|
||||
fn compose(self, local: Self) -> Self {
|
||||
Self {
|
||||
translation: self.translation + self.rotation * (self.scale * local.translation),
|
||||
rotation: (self.rotation * local.rotation).normalize(),
|
||||
scale: self.scale * local.scale,
|
||||
}
|
||||
}
|
||||
|
||||
fn transform_point(self, point: [f32; 3]) -> Vec3 {
|
||||
self.translation + self.rotation * (self.scale * Vec3::from_array(point))
|
||||
}
|
||||
}
|
||||
|
||||
fn document_world_poses(
|
||||
document: &SceneDocument,
|
||||
) -> Result<std::collections::BTreeMap<String, WorldPose>, String> {
|
||||
let entities: std::collections::BTreeMap<&str, &SceneEntity> = document
|
||||
.entities
|
||||
.iter()
|
||||
.filter_map(|entity| Some((entity.actor_id.as_deref()?, entity)))
|
||||
.collect();
|
||||
let mut poses = std::collections::BTreeMap::new();
|
||||
let mut visiting = std::collections::BTreeSet::new();
|
||||
for actor_id in entities.keys().copied() {
|
||||
resolve_document_pose(actor_id, &entities, &mut poses, &mut visiting)?;
|
||||
}
|
||||
Ok(poses)
|
||||
}
|
||||
|
||||
fn resolve_document_pose(
|
||||
actor_id: &str,
|
||||
entities: &std::collections::BTreeMap<&str, &SceneEntity>,
|
||||
poses: &mut std::collections::BTreeMap<String, WorldPose>,
|
||||
visiting: &mut std::collections::BTreeSet<String>,
|
||||
) -> Result<WorldPose, String> {
|
||||
if let Some(pose) = poses.get(actor_id).copied() {
|
||||
return Ok(pose);
|
||||
}
|
||||
if !visiting.insert(actor_id.to_string()) {
|
||||
return Err(format!(
|
||||
"navigation hierarchy contains a cycle at actor {actor_id}"
|
||||
));
|
||||
}
|
||||
let entity = entities
|
||||
.get(actor_id)
|
||||
.ok_or_else(|| format!("navigation actor {actor_id} is missing from the scene document"))?;
|
||||
let local = component::<SerializedTransform>(entity, TRANSFORM_COMPONENT)?
|
||||
.map(WorldPose::from_serialized)
|
||||
.unwrap_or_else(WorldPose::identity);
|
||||
let world = match entity.parent_actor_id.as_deref() {
|
||||
Some(parent) => resolve_document_pose(parent, entities, poses, visiting)?.compose(local),
|
||||
None => local,
|
||||
};
|
||||
visiting.remove(actor_id);
|
||||
poses.insert(actor_id.to_string(), world);
|
||||
Ok(world)
|
||||
}
|
||||
|
||||
fn component<T: for<'de> Deserialize<'de>>(
|
||||
entity: &SceneEntity,
|
||||
type_name: &str,
|
||||
) -> Result<Option<T>, String> {
|
||||
entity
|
||||
.components
|
||||
.iter()
|
||||
.find(|component| component.type_name == type_name)
|
||||
.map(|component| {
|
||||
ron::from_str(&component.ron).map_err(|error| {
|
||||
format!(
|
||||
"actor {} has invalid {type_name}: {error}",
|
||||
entity.document_id
|
||||
)
|
||||
})
|
||||
})
|
||||
.transpose()
|
||||
}
|
||||
|
||||
fn rotated_aabb_half_extents(pose: WorldPose, local_half: [f32; 3]) -> [f32; 3] {
|
||||
let half = pose.scale.abs() * Vec3::from_array(local_half);
|
||||
let x = pose.rotation * Vec3::X;
|
||||
let y = pose.rotation * Vec3::Y;
|
||||
let z = pose.rotation * Vec3::Z;
|
||||
(x.abs() * half.x + y.abs() * half.y + z.abs() * half.z).to_array()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use nav_glam::Vec2;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn fixture() -> NavigationBakeInput {
|
||||
NavigationBakeInput {
|
||||
source_scene: "assets/levels/navigation_showcase.scn.ron".into(),
|
||||
bounds_actor_id: "bounds".into(),
|
||||
center: [0.0, 0.0, 0.0],
|
||||
half_extents: [5.0, 2.0, 5.0],
|
||||
agent: NavigationAgentProfile {
|
||||
min_region_size: 1,
|
||||
merge_region_size: 2,
|
||||
..Default::default()
|
||||
},
|
||||
geometry: Vec::new(),
|
||||
obstacles: vec![NavigationVolumeInput {
|
||||
actor_id: "center-obstacle".into(),
|
||||
center: [0.0, 0.5, 0.0],
|
||||
half_extents: [0.75, 1.0, 0.75],
|
||||
}],
|
||||
areas: Vec::new(),
|
||||
links: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deterministic_bake_routes_around_an_obstacle() {
|
||||
let input = fixture();
|
||||
let artifact = bake_navigation(&input).unwrap();
|
||||
assert_eq!(
|
||||
artifact.source_fingerprint,
|
||||
navigation_source_fingerprint(&input)
|
||||
);
|
||||
let mesh = artifact.runtime_mesh();
|
||||
let path = mesh.path(Vec2::new(-3.0, 0.0), Vec2::new(3.0, 0.0));
|
||||
assert!(path.is_some());
|
||||
assert!(path.unwrap().length > 6.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn artifact_round_trip_preserves_query_mesh() {
|
||||
let artifact = bake_navigation(&fixture()).unwrap();
|
||||
let text = ron::ser::to_string(&artifact).unwrap();
|
||||
let restored: NavigationBakeArtifact = ron::from_str(&text).unwrap();
|
||||
assert!(restored
|
||||
.runtime_mesh()
|
||||
.path(Vec2::new(-3.0, -3.0), Vec2::new(3.0, 3.0))
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn agent_change_marks_artifact_stale() {
|
||||
let input = fixture();
|
||||
let artifact = bake_navigation(&input).unwrap();
|
||||
let mut changed = input;
|
||||
changed.agent.radius += 0.1;
|
||||
assert!(artifact.is_stale_for(&changed));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authored_geometry_change_marks_artifact_stale() {
|
||||
let mut input = fixture();
|
||||
input.geometry.push(NavigationGeometryInput {
|
||||
actor_id: "walkable-floor".into(),
|
||||
triangles: vec![[[-2.0, 0.0, -2.0], [2.0, 0.0, 2.0], [2.0, 0.0, -2.0]]],
|
||||
});
|
||||
let artifact = bake_navigation(&input).unwrap();
|
||||
input.geometry[0].triangles[0][0][0] -= 0.25;
|
||||
assert!(artifact.is_stale_for(&input));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounds_filter_excludes_distant_authoring() {
|
||||
assert!(aabb_overlaps(
|
||||
[0.0, 0.0, 0.0],
|
||||
[2.0, 2.0, 2.0],
|
||||
[3.0, 0.0, 0.0],
|
||||
[1.0, 1.0, 1.0]
|
||||
));
|
||||
assert!(!aabb_overlaps(
|
||||
[0.0, 0.0, 0.0],
|
||||
[2.0, 2.0, 2.0],
|
||||
[3.01, 0.0, 0.0],
|
||||
[1.0, 1.0, 1.0]
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn committed_navigation_fixture_matches_its_bake_artifact() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
||||
let scene_path = root.join("assets/levels/navigation_authoring_showcase.scn.ron");
|
||||
let text = std::fs::read_to_string(scene_path).unwrap();
|
||||
let document = SceneDocument::from_ron_text(&text).unwrap();
|
||||
let job = navigation_bake_jobs_from_document(
|
||||
&document,
|
||||
"assets/levels/navigation_authoring_showcase.scn.ron",
|
||||
)
|
||||
.unwrap()
|
||||
.pop()
|
||||
.unwrap();
|
||||
let committed = read_navigation_artifact(&root.join(&job.artifact_path)).unwrap();
|
||||
assert!(!committed.is_stale_for(&job.input));
|
||||
assert_eq!(committed.bounds_actor_id, "navigation-bounds-main");
|
||||
assert_eq!(job.input.geometry.len(), 1);
|
||||
assert_eq!(job.input.geometry[0].actor_id, "navigation-floor-geometry");
|
||||
assert!(committed
|
||||
.runtime_mesh()
|
||||
.path(Vec2::new(-6.0, 0.0), Vec2::new(6.0, 0.0))
|
||||
.is_some());
|
||||
}
|
||||
}
|
||||
@ -11,10 +11,10 @@ use shared::{
|
||||
brush_math::{validate_brush, BrushDiagnosticSeverity},
|
||||
AnimationControllerDesc, AnimationDiagnosticSeverity, AnimationManifest, AudioListenerDesc,
|
||||
AudioSourceDesc, BrushDesc, ColliderDesc, ColliderShapeDesc, EditorAssetRef, MaterialAsset,
|
||||
MaterialDesc, MaterialOverride, ModelRef, PostProcessEffectAsset, PostProcessVolumeDesc,
|
||||
PrefabInstance, PrefabRef, ShaderSchemaAsset, StaticMeshRenderer,
|
||||
ANIMATION_MANIFEST_SCHEMA_VERSION, AUDIO_CLIP_SUB_ASSET_ID,
|
||||
COMPONENT_ANIMATION_CONTROLLER_DESC,
|
||||
MaterialDesc, MaterialInstanceAsset, MaterialOverride, ModelRef, PostProcessEffectAsset,
|
||||
PostProcessVolumeDesc, PrefabInstance, PrefabRef, RendererMaterialSet, ShaderSchemaAsset,
|
||||
SkinnedMeshRenderer, StaticMeshRenderer, ANIMATION_MANIFEST_SCHEMA_VERSION,
|
||||
AUDIO_CLIP_SUB_ASSET_ID, COMPONENT_ANIMATION_CONTROLLER_DESC, COMPONENT_SKINNED_MESH_RENDERER,
|
||||
};
|
||||
|
||||
use crate::document::{SceneComponentBlob, SceneDocument};
|
||||
@ -308,6 +308,8 @@ struct RegistryImportSettings {
|
||||
static_mesh_manifest_path: Option<String>,
|
||||
#[serde(default)]
|
||||
animation_manifest_path: Option<String>,
|
||||
#[serde(default)]
|
||||
default_animation_clip_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@ -325,6 +327,7 @@ struct RegistryAssetRecord {
|
||||
struct AnimationRegistryRecord {
|
||||
source_path: String,
|
||||
manifest_path: Option<String>,
|
||||
default_animation_clip_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@ -438,6 +441,10 @@ fn validate_asset_registry(
|
||||
.or_insert(AnimationRegistryRecord {
|
||||
source_path: record.path.clone(),
|
||||
manifest_path: record.import_settings.animation_manifest_path.clone(),
|
||||
default_animation_clip_id: record
|
||||
.import_settings
|
||||
.default_animation_clip_id
|
||||
.clone(),
|
||||
});
|
||||
}
|
||||
if record.kind_tag == "Model"
|
||||
@ -499,10 +506,22 @@ fn validate_material_assets(project_root: &Path, report: &mut ProjectValidationR
|
||||
report,
|
||||
) {
|
||||
let source = display_project_path(project_root, &path);
|
||||
let Some(asset) = parse_document::<MaterialAsset>(&path, &source, "material", report)
|
||||
else {
|
||||
let Ok(text) = std::fs::read_to_string(&path) else {
|
||||
continue;
|
||||
};
|
||||
if let Ok(asset) = ron::from_str::<MaterialAsset>(&text) {
|
||||
if asset.schema_version != shared::MATERIAL_ASSET_SCHEMA_VERSION {
|
||||
report.findings.push(ProjectValidationFinding {
|
||||
severity: ValidationSeverity::Error,
|
||||
code: "material.incompatible_schema".into(),
|
||||
source_path: source.clone(),
|
||||
owner_actor_id: None,
|
||||
reference: Some(asset.schema_version.to_string()),
|
||||
message: "material asset schema is not supported by this build".into(),
|
||||
repair: "Run `cargo upgrade-project --apply` and review the upgrade report."
|
||||
.into(),
|
||||
});
|
||||
}
|
||||
if let Some(shader) = asset.shader {
|
||||
add_reference(
|
||||
project_root,
|
||||
@ -513,7 +532,77 @@ fn validate_material_assets(project_root: &Path, report: &mut ProjectValidationR
|
||||
&shader,
|
||||
);
|
||||
}
|
||||
if let Some(shader) = asset.shader_ref {
|
||||
collect_asset_ref(
|
||||
project_root,
|
||||
report,
|
||||
&source,
|
||||
None,
|
||||
"shader_schema",
|
||||
&shader,
|
||||
);
|
||||
}
|
||||
collect_material(project_root, report, &source, None, &asset.material);
|
||||
continue;
|
||||
}
|
||||
if let Ok(instance) = ron::from_str::<MaterialInstanceAsset>(&text) {
|
||||
if instance.schema_version != shared::MATERIAL_INSTANCE_SCHEMA_VERSION {
|
||||
report.findings.push(ProjectValidationFinding {
|
||||
severity: ValidationSeverity::Error,
|
||||
code: "material_instance.incompatible_schema".into(),
|
||||
source_path: source.clone(),
|
||||
owner_actor_id: None,
|
||||
reference: Some(instance.schema_version.to_string()),
|
||||
message: "material-instance schema is not supported by this build".into(),
|
||||
repair: "Run `cargo upgrade-project --apply` and review the upgrade report."
|
||||
.into(),
|
||||
});
|
||||
}
|
||||
let nested_base = instance.base.0.sub_asset_id == "material:instance"
|
||||
|| instance.base.0.source_path.as_deref().is_some_and(|path| {
|
||||
let path = Path::new(path);
|
||||
let path = if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
project_root.join(path)
|
||||
};
|
||||
MaterialInstanceAsset::load_from_path(&path.to_string_lossy()).is_ok()
|
||||
});
|
||||
if nested_base {
|
||||
report.findings.push(ProjectValidationFinding {
|
||||
severity: ValidationSeverity::Error,
|
||||
code: "material_instance.nested_base".into(),
|
||||
source_path: source.clone(),
|
||||
owner_actor_id: None,
|
||||
reference: Some(instance.base.0.label.clone()),
|
||||
message: "material instances must directly inherit a Material asset".into(),
|
||||
repair: "Choose a Material asset as the instance base.".into(),
|
||||
});
|
||||
}
|
||||
collect_asset_ref(
|
||||
project_root,
|
||||
report,
|
||||
&source,
|
||||
None,
|
||||
"material",
|
||||
&instance.base.0,
|
||||
);
|
||||
for texture in instance.textures {
|
||||
if let Some(reference) = texture.texture {
|
||||
collect_asset_ref(project_root, report, &source, None, "texture", &reference);
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
report.findings.push(ProjectValidationFinding {
|
||||
severity: ValidationSeverity::Error,
|
||||
code: "material.invalid".into(),
|
||||
source_path: source,
|
||||
owner_actor_id: None,
|
||||
reference: None,
|
||||
message: "could not parse file as a Material or Material Instance asset".into(),
|
||||
repair: "Repair the document or restore it from the project-upgrade backup.".into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -530,8 +619,53 @@ fn validate_shader_assets(project_root: &Path, report: &mut ProjectValidationRep
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if asset.schema_version != shared::SURFACE_SHADER_SCHEMA_VERSION {
|
||||
report.findings.push(ProjectValidationFinding {
|
||||
severity: ValidationSeverity::Error,
|
||||
code: "surface_shader.incompatible_schema".into(),
|
||||
source_path: source.clone(),
|
||||
owner_actor_id: None,
|
||||
reference: Some(asset.schema_version.to_string()),
|
||||
message: "surface shader schema is not supported by this build".into(),
|
||||
repair: "Run `cargo upgrade-project --apply` and review the upgrade report.".into(),
|
||||
});
|
||||
}
|
||||
if let Some(wgsl) = asset.wgsl_path {
|
||||
add_reference(project_root, report, &source, None, "shader", &wgsl);
|
||||
if let Ok(code) = std::fs::read_to_string(project_root.join(&wgsl)) {
|
||||
if !code.contains("fn evaluate(")
|
||||
|| [
|
||||
"@group",
|
||||
"@binding",
|
||||
"@vertex",
|
||||
"@fragment",
|
||||
"@compute",
|
||||
"var<",
|
||||
"rayQuery",
|
||||
"dpdx",
|
||||
"dpdy",
|
||||
"fwidth",
|
||||
"discard",
|
||||
"Barrier",
|
||||
"subgroup",
|
||||
"textureStore",
|
||||
"atomic",
|
||||
]
|
||||
.iter()
|
||||
.any(|forbidden| code.contains(forbidden))
|
||||
{
|
||||
report.findings.push(ProjectValidationFinding {
|
||||
severity: ValidationSeverity::Error,
|
||||
code: "surface_shader.invalid_contract".into(),
|
||||
source_path: source.clone(),
|
||||
owner_actor_id: None,
|
||||
reference: Some(wgsl.clone()),
|
||||
message: "WGSL does not satisfy the Surface ABI evaluator contract".into(),
|
||||
repair: "Define only `fn evaluate(...) -> Surface` and remove resource bindings/entry points."
|
||||
.into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
for texture in asset.default_textures {
|
||||
if let Some(reference) = texture.texture {
|
||||
@ -574,6 +708,8 @@ struct StaticMeshSourceView {
|
||||
dependencies: Vec<String>,
|
||||
}
|
||||
|
||||
const STATIC_MESH_MANIFEST_SCHEMA_VERSION: u32 = 3;
|
||||
|
||||
fn validate_static_mesh_manifests(project_root: &Path, report: &mut ProjectValidationReport) {
|
||||
for path in ron_files(
|
||||
project_root,
|
||||
@ -590,7 +726,9 @@ fn validate_static_mesh_manifests(project_root: &Path, report: &mut ProjectValid
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
if manifest.schema_version != 1 || manifest.asset_id.trim().is_empty() {
|
||||
if manifest.schema_version != STATIC_MESH_MANIFEST_SCHEMA_VERSION
|
||||
|| manifest.asset_id.trim().is_empty()
|
||||
{
|
||||
report.findings.push(ProjectValidationFinding {
|
||||
severity: ValidationSeverity::Error,
|
||||
code: "manifest.incompatible_schema".into(),
|
||||
@ -732,6 +870,17 @@ fn validate_animation_manifests(
|
||||
repair: "Reimport the model so its UUID-keyed animation manifest matches the asset registry.".into(),
|
||||
});
|
||||
}
|
||||
if manifest.default_animation_clip_id != record.default_animation_clip_id {
|
||||
report.findings.push(ProjectValidationFinding {
|
||||
severity: ValidationSeverity::Error,
|
||||
code: "animation.default_clip_manifest_mismatch".into(),
|
||||
source_path: normalized.clone(),
|
||||
owner_actor_id: None,
|
||||
reference: record.default_animation_clip_id.clone(),
|
||||
message: "model import settings and generated animation manifest disagree on the default clip".into(),
|
||||
repair: "Reimport the model so its generated manifest records the current Default animation setting.".into(),
|
||||
});
|
||||
}
|
||||
if manifest.source.path != record.source_path {
|
||||
report.findings.push(ProjectValidationFinding {
|
||||
severity: ValidationSeverity::Error,
|
||||
@ -1001,6 +1150,26 @@ fn validate_animation_manifest_contract(
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(default_clip_id) = manifest.default_animation_clip_id.as_deref() {
|
||||
if (default_clip_id.trim().is_empty() || !clip_ids.contains(default_clip_id))
|
||||
&& !manifest
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|diagnostic| diagnostic.code == "animation.default_clip_missing")
|
||||
{
|
||||
report.findings.push(ProjectValidationFinding {
|
||||
severity: ValidationSeverity::Error,
|
||||
code: "animation.default_clip_missing".into(),
|
||||
source_path: source_path.into(),
|
||||
owner_actor_id: None,
|
||||
reference: Some(default_clip_id.to_string()),
|
||||
message: "animation manifest default clip does not resolve to an imported clip"
|
||||
.into(),
|
||||
repair: "Choose an existing Default animation in Model Import Settings, or select Imported rest pose, then reimport.".into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
match source_format.as_str() {
|
||||
"gltf" | "glb" if !manifest.runtime_supported => {
|
||||
report.findings.push(ProjectValidationFinding {
|
||||
@ -1219,6 +1388,8 @@ fn validate_document(
|
||||
return;
|
||||
};
|
||||
|
||||
validate_navigation_document(project_root, &source_path, &document, report);
|
||||
|
||||
if let Some(composition) = &document.composition {
|
||||
for subscene in &composition.subscenes {
|
||||
add_reference(
|
||||
@ -1279,6 +1450,131 @@ fn validate_document(
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_navigation_document(
|
||||
project_root: &Path,
|
||||
source_path: &str,
|
||||
document: &SceneDocument,
|
||||
report: &mut ProjectValidationReport,
|
||||
) {
|
||||
let jobs = match crate::navigation::navigation_bake_jobs_from_document(document, source_path) {
|
||||
Ok(jobs) => jobs,
|
||||
Err(error) => {
|
||||
report.findings.push(ProjectValidationFinding {
|
||||
severity: ValidationSeverity::Error,
|
||||
code: "navigation.authoring_invalid".into(),
|
||||
source_path: source_path.to_string(),
|
||||
owner_actor_id: None,
|
||||
reference: None,
|
||||
message: error,
|
||||
repair: "Repair the named navigation actor and rebake the scene.".into(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
for job in jobs {
|
||||
report.dependencies.push(ProjectDependency {
|
||||
owner_path: source_path.to_string(),
|
||||
owner_actor_id: Some(job.input.bounds_actor_id.clone()),
|
||||
kind: "navigation_artifact".into(),
|
||||
reference: job.artifact_path.clone(),
|
||||
});
|
||||
let Some(path) = resolve_reference(project_root, &job.artifact_path) else {
|
||||
push_navigation_finding(
|
||||
report,
|
||||
source_path,
|
||||
&job.input.bounds_actor_id,
|
||||
"navigation.artifact_unsafe_path",
|
||||
&job.artifact_path,
|
||||
"navigation artifact path escapes the project",
|
||||
"Choose an artifact path under assets/navigation/generated/.",
|
||||
);
|
||||
continue;
|
||||
};
|
||||
if !path.is_file() {
|
||||
push_navigation_finding(
|
||||
report,
|
||||
source_path,
|
||||
&job.input.bounds_actor_id,
|
||||
"navigation.artifact_missing",
|
||||
&job.artifact_path,
|
||||
"navigation bounds has no baked artifact",
|
||||
"Select the bounds actor and run Scene > Navigation > Bake Selected Bounds.",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
let artifact = match crate::navigation::read_navigation_artifact(&path) {
|
||||
Ok(artifact) => artifact,
|
||||
Err(error) => {
|
||||
push_navigation_finding(
|
||||
report,
|
||||
source_path,
|
||||
&job.input.bounds_actor_id,
|
||||
"navigation.artifact_invalid",
|
||||
&job.artifact_path,
|
||||
&error,
|
||||
"Delete the invalid generated artifact and rebake the bounds actor.",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if artifact.is_stale_for(&job.input) {
|
||||
push_navigation_finding(
|
||||
report,
|
||||
source_path,
|
||||
&job.input.bounds_actor_id,
|
||||
"navigation.artifact_stale",
|
||||
&job.artifact_path,
|
||||
"navigation artifact fingerprint does not match authored navigation data",
|
||||
"Rebake navigation after the authored geometry or agent-profile change.",
|
||||
);
|
||||
}
|
||||
|
||||
let mesh = artifact.runtime_mesh();
|
||||
for link in artifact.links.iter().filter(|link| link.enabled) {
|
||||
for (label, endpoint) in [("start", link.start), ("end", link.end)] {
|
||||
let point = nav_glam::Vec2::new(endpoint[0], endpoint[2]);
|
||||
let valid = mesh.get_closest_point(point).is_some_and(|closest| {
|
||||
closest.position().distance(point) <= artifact.agent.radius * 2.0
|
||||
});
|
||||
if !valid {
|
||||
push_navigation_finding(
|
||||
report,
|
||||
source_path,
|
||||
&link.actor_id,
|
||||
"navigation.link_unreachable",
|
||||
&job.artifact_path,
|
||||
&format!(
|
||||
"navigation link {label} endpoint is farther than two agent radii from the baked mesh"
|
||||
),
|
||||
"Move the endpoint onto a visible navigation polygon and rebake.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn push_navigation_finding(
|
||||
report: &mut ProjectValidationReport,
|
||||
source_path: &str,
|
||||
actor_id: &str,
|
||||
code: &str,
|
||||
reference: &str,
|
||||
message: &str,
|
||||
repair: &str,
|
||||
) {
|
||||
report.findings.push(ProjectValidationFinding {
|
||||
severity: ValidationSeverity::Error,
|
||||
code: code.into(),
|
||||
source_path: source_path.into(),
|
||||
owner_actor_id: Some(actor_id.into()),
|
||||
reference: Some(reference.into()),
|
||||
message: message.into(),
|
||||
repair: repair.into(),
|
||||
});
|
||||
}
|
||||
|
||||
fn collect_component_references(
|
||||
project_root: &Path,
|
||||
report: &mut ProjectValidationReport,
|
||||
@ -1321,51 +1617,36 @@ fn collect_component_references(
|
||||
}
|
||||
MODEL_REF => {
|
||||
let model = parse!(ModelRef);
|
||||
if model.asset_id.trim().is_empty() {
|
||||
add_reference(
|
||||
collect_model_source_reference(
|
||||
project_root,
|
||||
report,
|
||||
source_path,
|
||||
actor_id,
|
||||
"model",
|
||||
&model.asset_id,
|
||||
&model.path,
|
||||
"ModelRef",
|
||||
animation_catalog,
|
||||
);
|
||||
} else if let Some(record) = animation_catalog.registry_by_id.get(&model.asset_id) {
|
||||
add_reference(
|
||||
}
|
||||
COMPONENT_SKINNED_MESH_RENDERER => {
|
||||
let renderer = parse!(SkinnedMeshRenderer);
|
||||
collect_model_source_reference(
|
||||
project_root,
|
||||
report,
|
||||
source_path,
|
||||
actor_id,
|
||||
"model",
|
||||
&record.source_path,
|
||||
&renderer.asset_id,
|
||||
&renderer.path,
|
||||
"SkinnedMeshRenderer",
|
||||
animation_catalog,
|
||||
);
|
||||
collect_renderer_material_references(
|
||||
project_root,
|
||||
report,
|
||||
source_path,
|
||||
actor_id,
|
||||
&renderer.materials,
|
||||
);
|
||||
if normalized_project_reference(&model.path)
|
||||
!= normalized_project_reference(&record.source_path)
|
||||
{
|
||||
report.findings.push(ProjectValidationFinding {
|
||||
severity: ValidationSeverity::Warning,
|
||||
code: "model.cached_path_stale".into(),
|
||||
source_path: source_path.to_string(),
|
||||
owner_actor_id: actor_id.map(str::to_string),
|
||||
reference: Some(model.asset_id),
|
||||
message: format!(
|
||||
"ModelRef cached path `{}` differs from registered path `{}`",
|
||||
model.path, record.source_path
|
||||
),
|
||||
repair: "Open and save the scene to refresh the cached path hint; runtime resolution already uses the stable asset ID.".into(),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
report.findings.push(ProjectValidationFinding {
|
||||
severity: ValidationSeverity::Error,
|
||||
code: "model.asset_unresolved".into(),
|
||||
source_path: source_path.to_string(),
|
||||
owner_actor_id: actor_id.map(str::to_string),
|
||||
reference: Some(model.asset_id),
|
||||
message: "ModelRef asset ID is absent from the project registry".into(),
|
||||
repair: "Restore/reimport the owning model or reassign the model reference in the editor.".into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
MATERIAL_DESC => collect_material(
|
||||
project_root,
|
||||
@ -1390,7 +1671,33 @@ fn collect_component_references(
|
||||
}
|
||||
}
|
||||
STATIC_MESH_RENDERER => {
|
||||
for slot in parse!(StaticMeshRenderer).slots {
|
||||
let renderer = parse!(StaticMeshRenderer);
|
||||
collect_renderer_material_references(
|
||||
project_root,
|
||||
report,
|
||||
source_path,
|
||||
actor_id,
|
||||
&renderer.materials,
|
||||
);
|
||||
if let Some(slot) = renderer.slots.iter().find(|slot| {
|
||||
animation_asset_requires_skinned_renderer(&slot.mesh.asset_id, animation_catalog)
|
||||
}) {
|
||||
report.findings.push(ProjectValidationFinding {
|
||||
severity: ValidationSeverity::Error,
|
||||
code: "static_mesh.hierarchy_bound_geometry".into(),
|
||||
source_path: source_path.to_string(),
|
||||
owner_actor_id: actor_id.map(str::to_string),
|
||||
reference: Some(format!(
|
||||
"{}#{}",
|
||||
slot.mesh.asset_id, slot.mesh.sub_asset_id
|
||||
)),
|
||||
message:
|
||||
"StaticMeshRenderer references geometry from a skinned or animated model"
|
||||
.into(),
|
||||
repair: "Replace this actor by placing the source model through SkinnedMeshRenderer; static slots cannot retain its imported hierarchy.".into(),
|
||||
});
|
||||
}
|
||||
for slot in renderer.slots {
|
||||
collect_asset_ref(
|
||||
project_root,
|
||||
report,
|
||||
@ -1510,6 +1817,119 @@ fn collect_component_references(
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_renderer_material_references(
|
||||
project_root: &Path,
|
||||
report: &mut ProjectValidationReport,
|
||||
source_path: &str,
|
||||
actor_id: Option<&str>,
|
||||
materials: &RendererMaterialSet,
|
||||
) {
|
||||
for slot in &materials.slots {
|
||||
for reference in [slot.source_material.as_ref(), slot.material.as_ref()]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
collect_asset_ref(
|
||||
project_root,
|
||||
report,
|
||||
source_path,
|
||||
actor_id,
|
||||
"material",
|
||||
&reference.0,
|
||||
);
|
||||
}
|
||||
}
|
||||
for orphan in &materials.orphaned_assignments {
|
||||
collect_asset_ref(
|
||||
project_root,
|
||||
report,
|
||||
source_path,
|
||||
actor_id,
|
||||
"material",
|
||||
&orphan.material.0,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn animation_asset_requires_skinned_renderer(
|
||||
asset_id: &str,
|
||||
catalog: &AnimationValidationCatalog,
|
||||
) -> bool {
|
||||
let Some(record) = catalog.registry_by_id.get(asset_id) else {
|
||||
return false;
|
||||
};
|
||||
let Some(manifest_path) = record.manifest_path.as_deref() else {
|
||||
return false;
|
||||
};
|
||||
matches!(
|
||||
catalog
|
||||
.manifests_by_path
|
||||
.get(&normalized_project_reference(manifest_path)),
|
||||
Some(AnimationManifestLoad::Valid(manifest))
|
||||
if !manifest.skeletons.is_empty() || !manifest.clips.is_empty()
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn collect_model_source_reference(
|
||||
project_root: &Path,
|
||||
report: &mut ProjectValidationReport,
|
||||
source_path: &str,
|
||||
actor_id: Option<&str>,
|
||||
asset_id: &str,
|
||||
cached_path: &str,
|
||||
component_name: &str,
|
||||
animation_catalog: &AnimationValidationCatalog,
|
||||
) {
|
||||
if asset_id.trim().is_empty() {
|
||||
add_reference(
|
||||
project_root,
|
||||
report,
|
||||
source_path,
|
||||
actor_id,
|
||||
"model",
|
||||
cached_path,
|
||||
);
|
||||
} else if let Some(record) = animation_catalog.registry_by_id.get(asset_id) {
|
||||
add_reference(
|
||||
project_root,
|
||||
report,
|
||||
source_path,
|
||||
actor_id,
|
||||
"model",
|
||||
&record.source_path,
|
||||
);
|
||||
if normalized_project_reference(cached_path)
|
||||
!= normalized_project_reference(&record.source_path)
|
||||
{
|
||||
report.findings.push(ProjectValidationFinding {
|
||||
severity: ValidationSeverity::Warning,
|
||||
code: "model.cached_path_stale".into(),
|
||||
source_path: source_path.to_string(),
|
||||
owner_actor_id: actor_id.map(str::to_string),
|
||||
reference: Some(asset_id.to_string()),
|
||||
message: format!(
|
||||
"{component_name} cached path `{cached_path}` differs from registered path `{}`",
|
||||
record.source_path
|
||||
),
|
||||
repair: "Open and save the scene to refresh the cached path hint; runtime resolution already uses the stable asset ID.".into(),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
report.findings.push(ProjectValidationFinding {
|
||||
severity: ValidationSeverity::Error,
|
||||
code: "model.asset_unresolved".into(),
|
||||
source_path: source_path.to_string(),
|
||||
owner_actor_id: actor_id.map(str::to_string),
|
||||
reference: Some(asset_id.to_string()),
|
||||
message: format!("{component_name} asset ID is absent from the project registry"),
|
||||
repair:
|
||||
"Restore/reimport the owning model or reassign the model reference in the editor."
|
||||
.into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_animation_controller(
|
||||
project_root: &Path,
|
||||
report: &mut ProjectValidationReport,
|
||||
@ -2396,6 +2816,31 @@ mod tests {
|
||||
root
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn static_mesh_manifest_schema_v3_is_current() {
|
||||
let root = fixture_root();
|
||||
let source_path = "assets/models/static.glb";
|
||||
std::fs::create_dir_all(root.join("assets/models")).unwrap();
|
||||
std::fs::write(root.join(source_path), b"model").unwrap();
|
||||
let artifact_dir = root.join("assets/meshes/generated");
|
||||
std::fs::create_dir_all(&artifact_dir).unwrap();
|
||||
std::fs::write(
|
||||
artifact_dir.join("static-id.static_mesh.ron"),
|
||||
format!(
|
||||
"(schema_version:{STATIC_MESH_MANIFEST_SCHEMA_VERSION},asset_id:\"static-id\",source:(path:\"{source_path}\",dependencies:[]),warnings:[])"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let report = validate_project(&root);
|
||||
|
||||
assert!(!report.findings.iter().any(|finding| {
|
||||
finding.code == "manifest.incompatible_schema"
|
||||
&& finding.source_path.ends_with("static-id.static_mesh.ron")
|
||||
}));
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
fn write_manifest(root: &Path, version: u32) {
|
||||
std::fs::write(
|
||||
root.join("assets/project.ron"),
|
||||
@ -2423,6 +2868,7 @@ mod tests {
|
||||
schema_version: ANIMATION_MANIFEST_SCHEMA_VERSION,
|
||||
asset_id: ANIMATION_ASSET_ID.into(),
|
||||
label: "Hero".into(),
|
||||
default_animation_clip_id: None,
|
||||
source: AnimationManifestSource {
|
||||
path: source_path.into(),
|
||||
format: source_format.into(),
|
||||
@ -2497,6 +2943,23 @@ mod tests {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn write_animation_source_and_registry_default(
|
||||
root: &Path,
|
||||
source_path: &str,
|
||||
default_clip_id: &str,
|
||||
) {
|
||||
let source = root.join(source_path);
|
||||
std::fs::create_dir_all(source.parent().unwrap()).unwrap();
|
||||
std::fs::write(source, b"model").unwrap();
|
||||
std::fs::write(
|
||||
root.join("assets/.index/registry.ron"),
|
||||
format!(
|
||||
"[(id:(\"{ANIMATION_ASSET_ID}\"),path:\"{source_path}\",kind_tag:\"Model\",import_settings:(static_mesh_manifest_path:None,animation_manifest_path:Some(\"{ANIMATION_MANIFEST_PATH}\"),default_animation_clip_id:Some(\"{default_clip_id}\")),dependencies:[])]"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
fn write_animation_manifest(root: &Path, manifest: &AnimationManifest) {
|
||||
let path = root.join(ANIMATION_MANIFEST_PATH);
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
@ -2539,6 +3002,61 @@ mod tests {
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
fn write_navigation_scene(root: &Path, radius: f32, write_artifact: bool) {
|
||||
let mut bounds = shared::NavigationBounds::default();
|
||||
bounds.agent.radius = radius;
|
||||
bounds.agent.min_region_size = 1;
|
||||
bounds.agent.merge_region_size = 2;
|
||||
bounds.artifact_path = "assets/navigation/generated/main_humanoid.nav.ron".to_string();
|
||||
let bounds_ron = ron::to_string(&bounds).unwrap();
|
||||
let scene = format!(
|
||||
"(schema_version:2,resources:{{}},entities:{{1:(components:{{\"shared::components::ActorId\":(\"nav-bounds\"),\"shared::navigation::NavigationBounds\":{bounds_ron},}})}})"
|
||||
);
|
||||
std::fs::write(root.join("assets/levels/main.scn.ron"), &scene).unwrap();
|
||||
if write_artifact {
|
||||
let document = SceneDocument::from_ron_text(&scene).unwrap();
|
||||
let job = crate::navigation::navigation_bake_jobs_from_document(
|
||||
&document,
|
||||
"assets/levels/main.scn.ron",
|
||||
)
|
||||
.unwrap()
|
||||
.pop()
|
||||
.unwrap();
|
||||
let artifact = crate::navigation::bake_navigation(&job.input).unwrap();
|
||||
crate::navigation::write_navigation_artifact(&root.join(&job.artifact_path), &artifact)
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_navigation_artifact_blocks_release_with_bounds_owner() {
|
||||
let root = fixture_root();
|
||||
write_navigation_scene(&root, 0.35, false);
|
||||
|
||||
let report = validate_project(&root);
|
||||
let finding = report
|
||||
.findings
|
||||
.iter()
|
||||
.find(|finding| finding.code == "navigation.artifact_missing")
|
||||
.expect("missing artifact should be reported");
|
||||
assert_eq!(finding.owner_actor_id.as_deref(), Some("nav-bounds"));
|
||||
assert!(finding.repair.contains("Bake Selected Bounds"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navigation_agent_change_marks_bake_stale() {
|
||||
let root = fixture_root();
|
||||
write_navigation_scene(&root, 0.35, true);
|
||||
assert!(validate_project(&root).is_release_ready());
|
||||
|
||||
write_navigation_scene(&root, 0.5, false);
|
||||
let report = validate_project(&root);
|
||||
assert!(report
|
||||
.findings
|
||||
.iter()
|
||||
.any(|finding| finding.code == "navigation.artifact_stale"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_animation_controller_collects_stable_artifact_dependencies() {
|
||||
let root = fixture_root();
|
||||
@ -2565,6 +3083,39 @@ mod tests {
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn static_renderer_cannot_reference_skinned_or_animated_model_geometry() {
|
||||
let root = fixture_root();
|
||||
let source_path = "assets/models/hero.glb";
|
||||
let manifest = animation_fixture_manifest(source_path, "glb", "rig-signature", true);
|
||||
write_animation_source_and_registry(&root, source_path, Some(ANIMATION_MANIFEST_PATH));
|
||||
write_animation_manifest(&root, &manifest);
|
||||
let renderer = StaticMeshRenderer::single(shared::StaticMeshRendererEntry {
|
||||
mesh: EditorAssetRef::new(ANIMATION_ASSET_ID, "mesh:hero", "Hero"),
|
||||
..Default::default()
|
||||
});
|
||||
let renderer = ron::to_string(&renderer).unwrap();
|
||||
std::fs::write(
|
||||
root.join("assets/levels/main.scn.ron"),
|
||||
format!(
|
||||
"(schema_version:3,resources:{{}},entities:{{1:(components:{{\"shared::components::ActorId\":(\"static-hero\"),\"shared::components::StaticMeshRenderer\":{renderer},}})}})"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let report = validate_project(&root);
|
||||
|
||||
let finding = report
|
||||
.findings
|
||||
.iter()
|
||||
.find(|finding| finding.code == "static_mesh.hierarchy_bound_geometry")
|
||||
.expect("static use of hierarchy-bound geometry should be blocking");
|
||||
assert_eq!(finding.owner_actor_id.as_deref(), Some("static-hero"));
|
||||
assert_eq!(finding.severity, ValidationSeverity::Error);
|
||||
assert!(finding.repair.contains("SkinnedMeshRenderer"));
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn same_size_animation_source_change_invalidates_manifest_content_hash() {
|
||||
let root = fixture_root();
|
||||
@ -2669,13 +3220,37 @@ mod tests {
|
||||
&& finding.owner_actor_id.as_deref() == Some("animated-actor")
|
||||
})
|
||||
.expect("missing clip must identify the owning actor state");
|
||||
assert!(finding.reference.as_deref().is_some_and(
|
||||
|reference| reference.contains("AnimationControllerDesc.states[idle].clip")
|
||||
));
|
||||
assert!(finding.reference.as_deref().is_some_and(|reference| {
|
||||
reference.contains("AnimationControllerDesc.states[idle].clip")
|
||||
}));
|
||||
assert!(!finding.repair.trim().is_empty());
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_import_default_clip_blocks_the_generated_manifest() {
|
||||
let root = fixture_root();
|
||||
let source_path = "assets/models/hero.glb";
|
||||
let missing = animation_clip_sub_asset_id(9, "Removed");
|
||||
let mut manifest = animation_fixture_manifest(source_path, "glb", "rig-signature", true);
|
||||
manifest.default_animation_clip_id = Some(missing.clone());
|
||||
write_animation_source_and_registry_default(&root, source_path, &missing);
|
||||
write_animation_manifest(&root, &manifest);
|
||||
|
||||
let report = validate_project(&root);
|
||||
|
||||
let finding = report
|
||||
.findings
|
||||
.iter()
|
||||
.find(|finding| finding.code == "animation.default_clip_missing")
|
||||
.expect("a stale import default must be a blocking project diagnostic");
|
||||
assert_eq!(finding.severity, ValidationSeverity::Error);
|
||||
assert_eq!(finding.reference.as_deref(), Some(missing.as_str()));
|
||||
assert!(finding.repair.contains("Default animation"));
|
||||
assert!(!report.is_release_ready());
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_animation_state_contract_reports_each_actor_property() {
|
||||
let root = fixture_root();
|
||||
@ -2757,9 +3332,9 @@ mod tests {
|
||||
&& finding.owner_actor_id.as_deref() == Some("animated-actor")
|
||||
})
|
||||
.expect("signature mismatch must identify the owning actor state");
|
||||
assert!(finding.reference.as_deref().is_some_and(
|
||||
|reference| reference.contains("AnimationControllerDesc.states[idle].clip")
|
||||
));
|
||||
assert!(finding.reference.as_deref().is_some_and(|reference| {
|
||||
reference.contains("AnimationControllerDesc.states[idle].clip")
|
||||
}));
|
||||
assert!(finding.repair.contains("does not retarget"));
|
||||
assert!(!report.is_release_ready());
|
||||
std::fs::remove_dir_all(root).unwrap();
|
||||
|
||||
450
crates/scene/src/upgrade.rs
Normal file
450
crates/scene/src/upgrade.rs
Normal file
@ -0,0 +1,450 @@
|
||||
//! Explicit, transactional project content upgrade support.
|
||||
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use serde::Serialize;
|
||||
use shared::{
|
||||
AuthoringComponentStates, ComponentInstanceId, InspectorOrder, MaterialAsset,
|
||||
MaterialInstanceAsset, MaterialRef, RendererMaterialSlot, ShaderSchemaAsset,
|
||||
StaticMeshRenderer, COMPONENT_STATIC_MESH_RENDERER,
|
||||
};
|
||||
|
||||
use crate::document::{SceneComponentBlob, SceneDocument};
|
||||
|
||||
const INSPECTOR_ORDER_COMPONENT: &str = "shared::components::InspectorOrder";
|
||||
const AUTHORING_COMPONENT_STATES_COMPONENT: &str = "shared::components::AuthoringComponentStates";
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ProjectUpgradeChange {
|
||||
pub path: String,
|
||||
pub kind: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ProjectUpgradeReport {
|
||||
pub applied: bool,
|
||||
pub backup_path: Option<String>,
|
||||
pub changes: Vec<ProjectUpgradeChange>,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
struct PendingWrite {
|
||||
path: PathBuf,
|
||||
contents: String,
|
||||
kind: &'static str,
|
||||
}
|
||||
|
||||
pub fn upgrade_project(root: &Path, apply: bool) -> Result<ProjectUpgradeReport, String> {
|
||||
let assets = root.join("assets");
|
||||
let mut files = Vec::new();
|
||||
collect_files(&assets, &mut files)?;
|
||||
files.sort();
|
||||
|
||||
let mut pending = Vec::new();
|
||||
let mut warnings = Vec::new();
|
||||
for path in files {
|
||||
if path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.is_some_and(|name| name.starts_with('.'))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let relative = path.strip_prefix(root).unwrap_or(&path);
|
||||
let normalized = relative.to_string_lossy().replace('\\', "/");
|
||||
let Some(original) = fs::read_to_string(&path).ok() else {
|
||||
continue;
|
||||
};
|
||||
let upgraded = if normalized.ends_with(".scn.ron") || normalized.ends_with(".prefab.ron") {
|
||||
match canonical_scene_document(&original) {
|
||||
Ok(value) => Some((value, "scene-schema")),
|
||||
Err(error) => {
|
||||
warnings.push(format!("{normalized}: {error}"));
|
||||
None
|
||||
}
|
||||
}
|
||||
} else if normalized.contains("/materials/") && normalized.ends_with(".ron") {
|
||||
canonical_material_document(&path, &original)
|
||||
} else if normalized.contains("/shaders/") && normalized.ends_with(".shader.ron") {
|
||||
canonical_shader_document(&original)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let Some((contents, kind)) = upgraded else {
|
||||
continue;
|
||||
};
|
||||
if normalize_text(&contents) != normalize_text(&original) {
|
||||
pending.push(PendingWrite {
|
||||
path,
|
||||
contents,
|
||||
kind,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let changes = pending
|
||||
.iter()
|
||||
.map(|write| ProjectUpgradeChange {
|
||||
path: write
|
||||
.path
|
||||
.strip_prefix(root)
|
||||
.unwrap_or(&write.path)
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/"),
|
||||
kind: write.kind.to_string(),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if !apply || pending.is_empty() {
|
||||
return Ok(ProjectUpgradeReport {
|
||||
applied: false,
|
||||
backup_path: None,
|
||||
changes,
|
||||
warnings,
|
||||
});
|
||||
}
|
||||
|
||||
let timestamp = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map_err(|error| error.to_string())?
|
||||
.as_secs();
|
||||
let backup_root = root
|
||||
.join(".blacksite")
|
||||
.join("backups")
|
||||
.join(format!("material-component-v4-{timestamp}"));
|
||||
for write in &pending {
|
||||
let relative = write
|
||||
.path
|
||||
.strip_prefix(root)
|
||||
.map_err(|error| error.to_string())?;
|
||||
let backup = backup_root.join(relative);
|
||||
if let Some(parent) = backup.parent() {
|
||||
fs::create_dir_all(parent).map_err(|error| error.to_string())?;
|
||||
}
|
||||
fs::copy(&write.path, &backup).map_err(|error| {
|
||||
format!(
|
||||
"could not back up {} to {}: {error}",
|
||||
write.path.display(),
|
||||
backup.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
let mut staged = Vec::new();
|
||||
for (index, write) in pending.iter().enumerate() {
|
||||
let temporary = write
|
||||
.path
|
||||
.with_extension(format!("blacksite-upgrade-{index}.tmp"));
|
||||
fs::write(&temporary, &write.contents)
|
||||
.map_err(|error| format!("could not stage {}: {error}", write.path.display()))?;
|
||||
staged.push((temporary, write.path.clone()));
|
||||
}
|
||||
for (temporary, destination) in &staged {
|
||||
if let Err(error) = fs::rename(temporary, destination) {
|
||||
for change in &changes {
|
||||
let backup = backup_root.join(&change.path);
|
||||
let destination = root.join(&change.path);
|
||||
let _ = fs::copy(backup, destination);
|
||||
}
|
||||
return Err(format!(
|
||||
"project upgrade failed while replacing {} and was rolled back: {error}",
|
||||
destination.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ProjectUpgradeReport {
|
||||
applied: true,
|
||||
backup_path: Some(
|
||||
backup_root
|
||||
.strip_prefix(root)
|
||||
.unwrap_or(&backup_root)
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/"),
|
||||
),
|
||||
changes,
|
||||
warnings,
|
||||
})
|
||||
}
|
||||
|
||||
fn canonical_scene_document(text: &str) -> Result<String, String> {
|
||||
let mut document = SceneDocument::from_ron_text(text)?;
|
||||
for entity in &mut document.entities {
|
||||
let present_types = entity
|
||||
.components
|
||||
.iter()
|
||||
.filter(|component| shared::authoring_component_id(&component.type_name).is_some())
|
||||
.map(|component| component.type_name.clone())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if let Some(component) = entity
|
||||
.components
|
||||
.iter_mut()
|
||||
.find(|component| component.type_name == COMPONENT_STATIC_MESH_RENDERER)
|
||||
{
|
||||
let mut renderer: StaticMeshRenderer =
|
||||
ron::from_str(&component.ron).map_err(|error| {
|
||||
format!("could not upgrade StaticMeshRenderer component: {error}")
|
||||
})?;
|
||||
normalize_static_renderer(&mut renderer);
|
||||
*component =
|
||||
SceneComponentBlob::from_serializable(COMPONENT_STATIC_MESH_RENDERER, &renderer)?;
|
||||
}
|
||||
|
||||
let states_index = entity
|
||||
.components
|
||||
.iter()
|
||||
.position(|component| component.type_name == AUTHORING_COMPONENT_STATES_COMPONENT);
|
||||
let mut states = states_index
|
||||
.and_then(|index| {
|
||||
ron::from_str::<AuthoringComponentStates>(&entity.components[index].ron).ok()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
normalize_component_states(&mut states);
|
||||
|
||||
let mut migrated_legacy_state = false;
|
||||
if let Some(order_index) = entity
|
||||
.components
|
||||
.iter()
|
||||
.position(|component| component.type_name == INSPECTOR_ORDER_COMPONENT)
|
||||
{
|
||||
let mut order: InspectorOrder = ron::from_str(&entity.components[order_index].ron)
|
||||
.map_err(|error| format!("could not upgrade InspectorOrder component: {error}"))?;
|
||||
for legacy in std::mem::take(&mut order.component_states) {
|
||||
let key = if legacy.component_id.trim().is_empty() {
|
||||
legacy.type_name
|
||||
} else {
|
||||
legacy.component_id
|
||||
};
|
||||
if !key.trim().is_empty() {
|
||||
states.set_component_active(key, legacy.active);
|
||||
migrated_legacy_state = true;
|
||||
}
|
||||
}
|
||||
let present = present_types.iter().map(String::as_str).collect::<Vec<_>>();
|
||||
order.ensure_component_order(&present);
|
||||
entity.components[order_index] =
|
||||
SceneComponentBlob::from_serializable(INSPECTOR_ORDER_COMPONENT, &order)?;
|
||||
}
|
||||
if states_index.is_some() || migrated_legacy_state {
|
||||
let component = SceneComponentBlob::from_serializable(
|
||||
AUTHORING_COMPONENT_STATES_COMPONENT,
|
||||
&states,
|
||||
)?;
|
||||
if let Some(index) = states_index {
|
||||
entity.components[index] = component;
|
||||
} else {
|
||||
entity.components.push(component);
|
||||
}
|
||||
}
|
||||
}
|
||||
document.to_ron_text()
|
||||
}
|
||||
|
||||
fn normalize_static_renderer(renderer: &mut StaticMeshRenderer) {
|
||||
for (index, part) in renderer.slots.iter_mut().enumerate() {
|
||||
if part.id.is_empty() {
|
||||
let id = if part.mesh.sub_asset_id.trim().is_empty() {
|
||||
format!("draw:legacy:{index}")
|
||||
} else {
|
||||
part.mesh.sub_asset_id.clone()
|
||||
};
|
||||
part.id = ComponentInstanceId::new(id);
|
||||
}
|
||||
if part.material_slot_id.is_empty() {
|
||||
part.material_slot_id = ComponentInstanceId::new(format!("slot:{}", part.id.0));
|
||||
}
|
||||
let legacy_material = part.material.take().map(MaterialRef::new);
|
||||
if let Some(slot) = renderer.materials.slot_mut(&part.material_slot_id) {
|
||||
if slot.source_material.is_none() {
|
||||
slot.source_material = legacy_material;
|
||||
}
|
||||
if slot.name.trim().is_empty() {
|
||||
slot.name = part.name.clone();
|
||||
}
|
||||
} else {
|
||||
renderer.materials.slots.push(RendererMaterialSlot {
|
||||
id: part.material_slot_id.clone(),
|
||||
name: part.name.clone(),
|
||||
source_material: legacy_material,
|
||||
material: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_component_states(states: &mut AuthoringComponentStates) {
|
||||
let old = std::mem::take(&mut states.states);
|
||||
for state in old {
|
||||
let key = if state.component_id.trim().is_empty() {
|
||||
state.type_name
|
||||
} else {
|
||||
state.component_id
|
||||
};
|
||||
if !key.trim().is_empty() {
|
||||
states.set_component_active(key, state.active);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn canonical_material_document(path: &Path, text: &str) -> Option<(String, &'static str)> {
|
||||
if let Ok(asset) = ron::from_str::<MaterialAsset>(text) {
|
||||
return ron::ser::to_string_pretty(&asset, ron::ser::PrettyConfig::default())
|
||||
.ok()
|
||||
.map(|text| (text, "material-schema"));
|
||||
}
|
||||
if let Ok(instance) = ron::from_str::<MaterialInstanceAsset>(text) {
|
||||
return ron::ser::to_string_pretty(&instance, ron::ser::PrettyConfig::default())
|
||||
.ok()
|
||||
.map(|text| (text, "material-instance-schema"));
|
||||
}
|
||||
let _ = path;
|
||||
None
|
||||
}
|
||||
|
||||
fn canonical_shader_document(text: &str) -> Option<(String, &'static str)> {
|
||||
let schema = ron::from_str::<ShaderSchemaAsset>(text).ok()?;
|
||||
ron::ser::to_string_pretty(&schema, ron::ser::PrettyConfig::default())
|
||||
.ok()
|
||||
.map(|text| (text, "surface-shader-schema"))
|
||||
}
|
||||
|
||||
fn collect_files(path: &Path, output: &mut Vec<PathBuf>) -> Result<(), String> {
|
||||
if !path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
for entry in fs::read_dir(path).map_err(|error| error.to_string())? {
|
||||
let entry = entry.map_err(|error| error.to_string())?;
|
||||
let path = entry.path();
|
||||
if entry
|
||||
.file_type()
|
||||
.map_err(|error| error.to_string())?
|
||||
.is_dir()
|
||||
{
|
||||
collect_files(&path, output)?;
|
||||
} else {
|
||||
output.push(path);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn normalize_text(text: &str) -> String {
|
||||
text.trim().replace("\r\n", "\n")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use shared::{
|
||||
EditorAssetRef, InspectorComponentState, MeshRenderSlot,
|
||||
AUTHORING_COMPONENT_STATIC_MESH_RENDERER,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn dry_run_does_not_write_and_apply_creates_backup() {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"blacksite-upgrader-{}",
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_nanos()
|
||||
));
|
||||
let level = root.join("assets/levels/main.scn.ron");
|
||||
fs::create_dir_all(level.parent().unwrap()).unwrap();
|
||||
fs::write(&level, "(schema_version: 3, resources: {}, entities: {})").unwrap();
|
||||
|
||||
let dry_run = upgrade_project(&root, false).unwrap();
|
||||
assert!(!dry_run.applied);
|
||||
assert_eq!(dry_run.changes.len(), 1);
|
||||
assert!(fs::read_to_string(&level)
|
||||
.unwrap()
|
||||
.contains("schema_version: 3"));
|
||||
|
||||
let applied = upgrade_project(&root, true).unwrap();
|
||||
assert!(applied.applied);
|
||||
assert!(fs::read_to_string(&level)
|
||||
.unwrap()
|
||||
.contains("schema_version: 4"));
|
||||
assert!(root.join(applied.backup_path.unwrap()).exists());
|
||||
fs::remove_dir_all(root).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_upgrade_materializes_slots_and_separates_active_state() {
|
||||
let renderer = StaticMeshRenderer {
|
||||
slots: vec![MeshRenderSlot {
|
||||
id: ComponentInstanceId::new("draw:body"),
|
||||
name: "Body".into(),
|
||||
mesh: EditorAssetRef::new("model", "mesh:body", "Body"),
|
||||
material: Some(EditorAssetRef::new(
|
||||
"model",
|
||||
"material:body",
|
||||
"Body Material",
|
||||
)),
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
let order = InspectorOrder {
|
||||
component_type_names: vec![COMPONENT_STATIC_MESH_RENDERER.into()],
|
||||
component_states: vec![InspectorComponentState {
|
||||
type_name: COMPONENT_STATIC_MESH_RENDERER.into(),
|
||||
active: false,
|
||||
..Default::default()
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
let text = format!(
|
||||
"(schema_version: 3, resources: {{}}, entities: {{1: (components: {{\"{COMPONENT_STATIC_MESH_RENDERER}\": {}, \"{INSPECTOR_ORDER_COMPONENT}\": {}}})}})",
|
||||
ron::to_string(&renderer).unwrap(),
|
||||
ron::to_string(&order).unwrap(),
|
||||
);
|
||||
|
||||
let upgraded = canonical_scene_document(&text).unwrap();
|
||||
let document = SceneDocument::from_ron_text(&upgraded).unwrap();
|
||||
let components = &document.entities[0].components;
|
||||
let renderer: StaticMeshRenderer = ron::from_str(
|
||||
&components
|
||||
.iter()
|
||||
.find(|component| component.type_name == COMPONENT_STATIC_MESH_RENDERER)
|
||||
.unwrap()
|
||||
.ron,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(renderer.slots[0].material.is_none());
|
||||
assert_eq!(renderer.materials.slots.len(), 1);
|
||||
assert_eq!(
|
||||
renderer.materials.slots[0]
|
||||
.source_material
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.0
|
||||
.sub_asset_id,
|
||||
"material:body"
|
||||
);
|
||||
let order: InspectorOrder = ron::from_str(
|
||||
&components
|
||||
.iter()
|
||||
.find(|component| component.type_name == INSPECTOR_ORDER_COMPONENT)
|
||||
.unwrap()
|
||||
.ron,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(order.component_states.is_empty());
|
||||
assert_eq!(
|
||||
order.component_ids,
|
||||
vec![AUTHORING_COMPONENT_STATIC_MESH_RENDERER]
|
||||
);
|
||||
let states: AuthoringComponentStates = ron::from_str(
|
||||
&components
|
||||
.iter()
|
||||
.find(|component| component.type_name == AUTHORING_COMPONENT_STATES_COMPONENT)
|
||||
.unwrap()
|
||||
.ron,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!states.is_component_active(COMPONENT_STATIC_MESH_RENDERER));
|
||||
}
|
||||
}
|
||||
@ -9,14 +9,37 @@ use bevy::prelude::*;
|
||||
use crate::{
|
||||
animation_clip_source_index, animation_skeleton_source_index, brush_math::validate_brush,
|
||||
ActorKind, AnimationControllerDesc, AudioListenerDesc, AudioSourceDesc, BrushDesc, LevelObject,
|
||||
LightDesc, ModelRef, ObjectiveMarker, PlayerSpawn, PostProcessVolumeDesc, PrefabInstance,
|
||||
PrefabRef, Primitive, StaticMeshRenderer, TeamSpawn, TriggerVolume, WeaponSpawn,
|
||||
LightDesc, ModelRef, NavigationArea, NavigationBounds, NavigationLink, NavigationObstacle,
|
||||
ObjectiveMarker, PlayerSpawn, PostProcessVolumeDesc, PrefabInstance, PrefabRef, Primitive,
|
||||
SkinnedMeshRenderer, StaticMeshRenderer, TeamSpawn, TriggerVolume, WeaponSpawn,
|
||||
AUDIO_CLIP_SUB_ASSET_ID,
|
||||
};
|
||||
|
||||
/// One-shot deterministic kind from authoring components (scene migration only).
|
||||
/// Deterministic presentation/compatibility hint derived from authoring components.
|
||||
///
|
||||
/// Validation is component-driven; this priority only chooses an icon/default
|
||||
/// label when an actor composes several compatible behaviors (for example a
|
||||
/// rendered mesh with a light).
|
||||
pub fn infer_actor_kind(entity: EntityRef<'_>) -> Option<ActorKind> {
|
||||
entity.get::<LevelObject>()?;
|
||||
if entity.get::<PrefabRef>().is_some() || entity.get::<PrefabInstance>().is_some() {
|
||||
return Some(ActorKind::PrefabAnchor);
|
||||
}
|
||||
if entity.get::<SkinnedMeshRenderer>().is_some() {
|
||||
return Some(ActorKind::SkinnedMesh);
|
||||
}
|
||||
if entity.get::<BrushDesc>().is_some() {
|
||||
return Some(ActorKind::Brush);
|
||||
}
|
||||
if entity.get::<Primitive>().is_some() || entity.get::<StaticMeshRenderer>().is_some() {
|
||||
return Some(ActorKind::StaticMesh);
|
||||
}
|
||||
if entity.get::<ModelRef>().is_some() {
|
||||
return Some(ActorKind::ImportedModel);
|
||||
}
|
||||
if entity.get::<LightDesc>().is_some() {
|
||||
return Some(ActorKind::Light);
|
||||
}
|
||||
if entity.get::<PlayerSpawn>().is_some() {
|
||||
return Some(ActorKind::PlayerSpawn);
|
||||
}
|
||||
@ -35,27 +58,19 @@ pub fn infer_actor_kind(entity: EntityRef<'_>) -> Option<ActorKind> {
|
||||
if entity.get::<ObjectiveMarker>().is_some() {
|
||||
return Some(ActorKind::Objective);
|
||||
}
|
||||
if entity.get::<LightDesc>().is_some() {
|
||||
return Some(ActorKind::Light);
|
||||
}
|
||||
if entity.get::<BrushDesc>().is_some() {
|
||||
return Some(ActorKind::Brush);
|
||||
}
|
||||
if entity.get::<Primitive>().is_some() || entity.get::<StaticMeshRenderer>().is_some() {
|
||||
return Some(ActorKind::StaticMesh);
|
||||
}
|
||||
if entity.get::<ModelRef>().is_some() {
|
||||
return Some(ActorKind::ImportedModel);
|
||||
}
|
||||
if entity.get::<PrefabRef>().is_some() || entity.get::<PrefabInstance>().is_some() {
|
||||
return Some(ActorKind::PrefabAnchor);
|
||||
}
|
||||
if entity.get::<AudioSourceDesc>().is_some() {
|
||||
return Some(ActorKind::AudioSource);
|
||||
}
|
||||
if entity.get::<AudioListenerDesc>().is_some() {
|
||||
return Some(ActorKind::AudioListener);
|
||||
}
|
||||
if entity.get::<NavigationBounds>().is_some()
|
||||
|| entity.get::<NavigationObstacle>().is_some()
|
||||
|| entity.get::<NavigationArea>().is_some()
|
||||
|| entity.get::<NavigationLink>().is_some()
|
||||
{
|
||||
return Some(ActorKind::Navigation);
|
||||
}
|
||||
Some(ActorKind::Empty)
|
||||
}
|
||||
|
||||
@ -75,6 +90,13 @@ pub enum ActorValidationError {
|
||||
ImportedModelMissingModelRef,
|
||||
ImportedModelHasPrimitive,
|
||||
ImportedModelHasStaticMeshRenderer,
|
||||
SkinnedMeshMissingRenderer,
|
||||
SkinnedMeshInvalidRenderer,
|
||||
SkinnedMeshHasPrimitive,
|
||||
SkinnedMeshHasStaticMeshRenderer,
|
||||
SkinnedMeshHasModelRef,
|
||||
SkinnedMeshRendererActorKindMismatch,
|
||||
ConflictingGeometrySources,
|
||||
LightMissingLightDesc,
|
||||
LightHasPrimitive,
|
||||
LightHasModelRef,
|
||||
@ -93,7 +115,7 @@ pub enum ActorValidationError {
|
||||
AudioSourceMissingBus,
|
||||
AudioListenerMissingDesc,
|
||||
AudioListenerInvalidEarGap,
|
||||
AnimationControllerMissingModelRef,
|
||||
AnimationControllerMissingSkinnedMeshRenderer,
|
||||
AnimationControllerMissingSkeleton,
|
||||
AnimationControllerInvalidSkeletonReference,
|
||||
AnimationControllerEmptyStateId,
|
||||
@ -104,6 +126,7 @@ pub enum ActorValidationError {
|
||||
AnimationControllerInvalidCrossfade,
|
||||
AnimationControllerMissingDefaultState,
|
||||
AnimationControllerUnknownDefaultState,
|
||||
InvalidNavigation(String),
|
||||
}
|
||||
|
||||
fn is_finite_positive(v: f32) -> bool {
|
||||
@ -120,9 +143,10 @@ pub fn validate_actor(entity: EntityRef<'_>) -> Result<(), ActorValidationError>
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let Some(kind) = entity.get::<ActorKind>() else {
|
||||
let Some(_stored_kind) = entity.get::<ActorKind>() else {
|
||||
return Err(ActorValidationError::MissingActorKind);
|
||||
};
|
||||
let kind = infer_actor_kind(entity).unwrap_or(ActorKind::Empty);
|
||||
|
||||
if entity.get::<Transform>().is_none() {
|
||||
return Err(ActorValidationError::MissingTransform);
|
||||
@ -135,29 +159,20 @@ pub fn validate_actor(entity: EntityRef<'_>) -> Result<(), ActorValidationError>
|
||||
validate_audio_listener(listener)?;
|
||||
}
|
||||
if let Some(controller) = entity.get::<AnimationControllerDesc>() {
|
||||
if entity.get::<ModelRef>().is_none() {
|
||||
return Err(ActorValidationError::AnimationControllerMissingModelRef);
|
||||
if entity.get::<SkinnedMeshRenderer>().is_none() {
|
||||
return Err(ActorValidationError::AnimationControllerMissingSkinnedMeshRenderer);
|
||||
}
|
||||
validate_animation_controller(controller)?;
|
||||
}
|
||||
|
||||
match kind {
|
||||
ActorKind::Brush => {
|
||||
let Some(brush) = entity.get::<BrushDesc>() else {
|
||||
return Err(ActorValidationError::BrushMissingDesc);
|
||||
};
|
||||
if entity.get::<Primitive>().is_some() {
|
||||
return Err(ActorValidationError::BrushHasPrimitive);
|
||||
}
|
||||
if entity.get::<StaticMeshRenderer>().is_some() {
|
||||
return Err(ActorValidationError::BrushHasStaticMeshRenderer);
|
||||
}
|
||||
if entity.get::<LightDesc>().is_some() {
|
||||
return Err(ActorValidationError::BrushHasLight);
|
||||
}
|
||||
if entity.get::<ModelRef>().is_some() {
|
||||
return Err(ActorValidationError::BrushHasModelRef);
|
||||
let geometry_source_count = usize::from(entity.get::<Primitive>().is_some())
|
||||
+ usize::from(entity.get::<BrushDesc>().is_some())
|
||||
+ usize::from(entity.get::<StaticMeshRenderer>().is_some())
|
||||
+ usize::from(entity.get::<SkinnedMeshRenderer>().is_some())
|
||||
+ usize::from(entity.get::<ModelRef>().is_some());
|
||||
if geometry_source_count > 1 {
|
||||
return Err(ActorValidationError::ConflictingGeometrySources);
|
||||
}
|
||||
if let Some(brush) = entity.get::<BrushDesc>() {
|
||||
let report = validate_brush(brush);
|
||||
if !report.is_valid() {
|
||||
let message = report
|
||||
@ -171,6 +186,38 @@ pub fn validate_actor(entity: EntityRef<'_>) -> Result<(), ActorValidationError>
|
||||
return Err(ActorValidationError::InvalidBrushGeometry(message));
|
||||
}
|
||||
}
|
||||
if let Some(renderer) = entity.get::<SkinnedMeshRenderer>() {
|
||||
if renderer.path.trim().is_empty() {
|
||||
return Err(ActorValidationError::SkinnedMeshInvalidRenderer);
|
||||
}
|
||||
}
|
||||
if let Some(desc) = entity.get::<PostProcessVolumeDesc>() {
|
||||
validate_post_process_volume(desc)?;
|
||||
}
|
||||
if entity.get::<NavigationBounds>().is_some()
|
||||
|| entity.get::<NavigationObstacle>().is_some()
|
||||
|| entity.get::<NavigationArea>().is_some()
|
||||
|| entity.get::<NavigationLink>().is_some()
|
||||
{
|
||||
validate_navigation_actor(entity)?;
|
||||
}
|
||||
|
||||
match &kind {
|
||||
ActorKind::Brush => {
|
||||
let Some(brush) = entity.get::<BrushDesc>() else {
|
||||
return Err(ActorValidationError::BrushMissingDesc);
|
||||
};
|
||||
if entity.get::<Primitive>().is_some() {
|
||||
return Err(ActorValidationError::BrushHasPrimitive);
|
||||
}
|
||||
if entity.get::<StaticMeshRenderer>().is_some() {
|
||||
return Err(ActorValidationError::BrushHasStaticMeshRenderer);
|
||||
}
|
||||
if entity.get::<ModelRef>().is_some() {
|
||||
return Err(ActorValidationError::BrushHasModelRef);
|
||||
}
|
||||
let _ = brush;
|
||||
}
|
||||
ActorKind::StaticMesh => {
|
||||
let has_static_mesh_renderer = entity
|
||||
.get::<StaticMeshRenderer>()
|
||||
@ -178,13 +225,24 @@ pub fn validate_actor(entity: EntityRef<'_>) -> Result<(), ActorValidationError>
|
||||
if entity.get::<Primitive>().is_none() && !has_static_mesh_renderer {
|
||||
return Err(ActorValidationError::StaticMeshMissingPrimitive);
|
||||
}
|
||||
if entity.get::<LightDesc>().is_some() {
|
||||
return Err(ActorValidationError::StaticMeshHasLight);
|
||||
}
|
||||
if entity.get::<ModelRef>().is_some() {
|
||||
return Err(ActorValidationError::StaticMeshHasModelRef);
|
||||
}
|
||||
}
|
||||
ActorKind::SkinnedMesh => {
|
||||
let Some(_renderer) = entity.get::<SkinnedMeshRenderer>() else {
|
||||
return Err(ActorValidationError::SkinnedMeshMissingRenderer);
|
||||
};
|
||||
if entity.get::<Primitive>().is_some() {
|
||||
return Err(ActorValidationError::SkinnedMeshHasPrimitive);
|
||||
}
|
||||
if entity.get::<StaticMeshRenderer>().is_some() {
|
||||
return Err(ActorValidationError::SkinnedMeshHasStaticMeshRenderer);
|
||||
}
|
||||
if entity.get::<ModelRef>().is_some() {
|
||||
return Err(ActorValidationError::SkinnedMeshHasModelRef);
|
||||
}
|
||||
}
|
||||
ActorKind::ImportedModel => {
|
||||
if entity.get::<ModelRef>().is_none() {
|
||||
return Err(ActorValidationError::ImportedModelMissingModelRef);
|
||||
@ -200,40 +258,13 @@ pub fn validate_actor(entity: EntityRef<'_>) -> Result<(), ActorValidationError>
|
||||
if entity.get::<LightDesc>().is_none() {
|
||||
return Err(ActorValidationError::LightMissingLightDesc);
|
||||
}
|
||||
if entity.get::<Primitive>().is_some() {
|
||||
return Err(ActorValidationError::LightHasPrimitive);
|
||||
}
|
||||
if entity.get::<ModelRef>().is_some() {
|
||||
return Err(ActorValidationError::LightHasModelRef);
|
||||
}
|
||||
if entity.get::<StaticMeshRenderer>().is_some() {
|
||||
return Err(ActorValidationError::LightHasStaticMeshRenderer);
|
||||
}
|
||||
// Other compatible authoring components may be composed with a light.
|
||||
}
|
||||
ActorKind::PostProcessVolume => {
|
||||
let Some(desc) = entity.get::<PostProcessVolumeDesc>() else {
|
||||
return Err(ActorValidationError::PostProcessVolumeMissingDesc);
|
||||
};
|
||||
if !is_finite_positive(desc.half_extents.x)
|
||||
|| !is_finite_positive(desc.half_extents.y)
|
||||
|| !is_finite_positive(desc.half_extents.z)
|
||||
{
|
||||
return Err(ActorValidationError::PostProcessVolumeInvalidHalfExtents);
|
||||
}
|
||||
if !is_finite_non_negative(desc.blend_distance) {
|
||||
return Err(ActorValidationError::PostProcessVolumeInvalidBlendDistance);
|
||||
}
|
||||
let o = &desc.overrides;
|
||||
if let Some(v) = o.exposure_ev100 {
|
||||
if !v.is_finite() {
|
||||
return Err(ActorValidationError::PostProcessVolumeInvalidOverrideScalar);
|
||||
}
|
||||
}
|
||||
if let Some(v) = o.fog_density {
|
||||
if !v.is_finite() || v < 0.0 {
|
||||
return Err(ActorValidationError::PostProcessVolumeInvalidOverrideScalar);
|
||||
}
|
||||
}
|
||||
let _ = desc;
|
||||
}
|
||||
ActorKind::AudioSource => {
|
||||
if entity.get::<AudioSourceDesc>().is_none() {
|
||||
@ -245,6 +276,7 @@ pub fn validate_actor(entity: EntityRef<'_>) -> Result<(), ActorValidationError>
|
||||
return Err(ActorValidationError::AudioListenerMissingDesc);
|
||||
}
|
||||
}
|
||||
ActorKind::Navigation => {}
|
||||
ActorKind::Empty
|
||||
| ActorKind::PrefabAnchor
|
||||
| ActorKind::PlayerSpawn
|
||||
@ -257,6 +289,93 @@ pub fn validate_actor(entity: EntityRef<'_>) -> Result<(), ActorValidationError>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_post_process_volume(desc: &PostProcessVolumeDesc) -> Result<(), ActorValidationError> {
|
||||
if !is_finite_positive(desc.half_extents.x)
|
||||
|| !is_finite_positive(desc.half_extents.y)
|
||||
|| !is_finite_positive(desc.half_extents.z)
|
||||
{
|
||||
return Err(ActorValidationError::PostProcessVolumeInvalidHalfExtents);
|
||||
}
|
||||
if !is_finite_non_negative(desc.blend_distance) {
|
||||
return Err(ActorValidationError::PostProcessVolumeInvalidBlendDistance);
|
||||
}
|
||||
let overrides = &desc.overrides;
|
||||
if overrides
|
||||
.exposure_ev100
|
||||
.is_some_and(|value| !value.is_finite())
|
||||
|| overrides
|
||||
.fog_density
|
||||
.is_some_and(|value| !value.is_finite() || value < 0.0)
|
||||
{
|
||||
return Err(ActorValidationError::PostProcessVolumeInvalidOverrideScalar);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_navigation_actor(entity: EntityRef<'_>) -> Result<(), ActorValidationError> {
|
||||
let mut component_count = 0;
|
||||
if let Some(bounds) = entity.get::<NavigationBounds>() {
|
||||
component_count += 1;
|
||||
validate_vec3_positive(bounds.half_extents, "navigation bounds")?;
|
||||
bounds
|
||||
.agent
|
||||
.validate()
|
||||
.map_err(|error| ActorValidationError::InvalidNavigation(error.into()))?;
|
||||
if bounds.artifact_path.trim().is_empty() {
|
||||
return Err(ActorValidationError::InvalidNavigation(
|
||||
"navigation artifact path must not be empty".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(obstacle) = entity.get::<NavigationObstacle>() {
|
||||
component_count += 1;
|
||||
validate_vec3_positive(obstacle.half_extents, "navigation obstacle")?;
|
||||
}
|
||||
if let Some(area) = entity.get::<NavigationArea>() {
|
||||
component_count += 1;
|
||||
validate_vec3_positive(area.half_extents, "navigation area")?;
|
||||
if area.id.trim().is_empty() {
|
||||
return Err(ActorValidationError::InvalidNavigation(
|
||||
"navigation area ID must not be empty".into(),
|
||||
));
|
||||
}
|
||||
if !is_finite_positive(area.cost) {
|
||||
return Err(ActorValidationError::InvalidNavigation(
|
||||
"navigation area cost must be finite and greater than zero".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Some(link) = entity.get::<NavigationLink>() {
|
||||
component_count += 1;
|
||||
if !link.start.is_finite() || !link.end.is_finite() || link.start == link.end {
|
||||
return Err(ActorValidationError::InvalidNavigation(
|
||||
"navigation link endpoints must be finite and distinct".into(),
|
||||
));
|
||||
}
|
||||
if !is_finite_positive(link.cost) {
|
||||
return Err(ActorValidationError::InvalidNavigation(
|
||||
"navigation link cost must be finite and greater than zero".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if component_count != 1 {
|
||||
return Err(ActorValidationError::InvalidNavigation(
|
||||
"navigation actors must own exactly one bounds, obstacle, area, or link component"
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_vec3_positive(value: Vec3, label: &str) -> Result<(), ActorValidationError> {
|
||||
if !value.is_finite() || value.min_element() <= 0.0 {
|
||||
return Err(ActorValidationError::InvalidNavigation(format!(
|
||||
"{label} half extents must be finite and greater than zero"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_audio_source(source: &AudioSourceDesc) -> Result<(), ActorValidationError> {
|
||||
let Some(clip) = source.clip.as_ref() else {
|
||||
return Err(ActorValidationError::AudioSourceMissingClip);
|
||||
@ -423,7 +542,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_static_mesh_rejects_light() {
|
||||
fn validate_static_mesh_composes_with_light_and_uses_derived_hint() {
|
||||
let mut world = World::new();
|
||||
let e = level_entity(
|
||||
&mut world,
|
||||
@ -433,9 +552,10 @@ mod tests {
|
||||
LightDesc::default(),
|
||||
),
|
||||
);
|
||||
assert!(validate_actor(world.entity(e)).is_ok());
|
||||
assert_eq!(
|
||||
validate_actor(world.entity(e)),
|
||||
Err(ActorValidationError::StaticMeshHasLight)
|
||||
infer_actor_kind(world.entity(e)),
|
||||
Some(ActorKind::StaticMesh)
|
||||
);
|
||||
}
|
||||
|
||||
@ -459,7 +579,7 @@ mod tests {
|
||||
);
|
||||
assert_eq!(
|
||||
validate_actor(world.entity(e)),
|
||||
Err(ActorValidationError::BrushHasStaticMeshRenderer)
|
||||
Err(ActorValidationError::ConflictingGeometrySources)
|
||||
);
|
||||
}
|
||||
|
||||
@ -587,20 +707,17 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn animation_controller_requires_model_and_resolved_skeleton() {
|
||||
fn animation_controller_requires_skinned_renderer_and_resolved_skeleton() {
|
||||
let mut world = World::new();
|
||||
let entity = level_entity(
|
||||
&mut world,
|
||||
(ActorKind::ImportedModel, animation_controller()),
|
||||
);
|
||||
let entity = level_entity(&mut world, (ActorKind::SkinnedMesh, animation_controller()));
|
||||
assert_eq!(
|
||||
validate_actor(world.entity(entity)),
|
||||
Err(ActorValidationError::AnimationControllerMissingModelRef)
|
||||
Err(ActorValidationError::AnimationControllerMissingSkinnedMeshRenderer)
|
||||
);
|
||||
|
||||
world
|
||||
.entity_mut(entity)
|
||||
.insert(ModelRef::new("assets/models/animated.glb"));
|
||||
.insert(SkinnedMeshRenderer::new("assets/models/animated.glb"));
|
||||
let mut controller = animation_controller();
|
||||
controller.skeleton = None;
|
||||
world.entity_mut(entity).insert(controller);
|
||||
@ -627,8 +744,8 @@ mod tests {
|
||||
let entity = level_entity(
|
||||
&mut world,
|
||||
(
|
||||
ActorKind::ImportedModel,
|
||||
ModelRef::new("assets/models/animated.glb"),
|
||||
ActorKind::SkinnedMesh,
|
||||
SkinnedMeshRenderer::new("assets/models/animated.glb"),
|
||||
animation_controller(),
|
||||
),
|
||||
);
|
||||
@ -666,8 +783,8 @@ mod tests {
|
||||
let entity = level_entity(
|
||||
&mut world,
|
||||
(
|
||||
ActorKind::ImportedModel,
|
||||
ModelRef::new("assets/models/animated.glb"),
|
||||
ActorKind::SkinnedMesh,
|
||||
SkinnedMeshRenderer::new("assets/models/animated.glb"),
|
||||
animation_controller(),
|
||||
),
|
||||
);
|
||||
|
||||
@ -5,10 +5,11 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::EditorAssetRef;
|
||||
|
||||
pub const ANIMATION_MANIFEST_SCHEMA_VERSION: u32 = 2;
|
||||
pub const ANIMATION_MANIFEST_SCHEMA_VERSION: u32 = 3;
|
||||
pub const ANIMATION_ARTIFACT_DIR: &str = "assets/animations/generated";
|
||||
pub const ANIMATION_CLIP_SUB_ASSET_PREFIX: &str = "animation:clip:";
|
||||
pub const ANIMATION_SKELETON_SUB_ASSET_PREFIX: &str = "animation:skeleton:";
|
||||
pub const COMPONENT_SKINNED_MESH_RENDERER: &str = "shared::animation::SkinnedMeshRenderer";
|
||||
pub const COMPONENT_ANIMATION_CONTROLLER_DESC: &str = "shared::animation::AnimationControllerDesc";
|
||||
|
||||
const DEFAULT_CROSSFADE_SECONDS: f32 = 0.2;
|
||||
@ -75,6 +76,54 @@ impl AnimationSkeletonSignature {
|
||||
}
|
||||
}
|
||||
|
||||
/// Authoring renderer for rigged geometry that must retain its imported joint hierarchy.
|
||||
///
|
||||
/// Unlike [`crate::StaticMeshRenderer`], this component never flattens source primitives into
|
||||
/// independent mesh slots. Hydration instantiates the selected model scene so Bevy can preserve
|
||||
/// its `SkinnedMesh`, joint entities, inverse bind poses, and animation player bindings.
|
||||
#[derive(Component, Reflect, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[reflect(Component, Default, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct SkinnedMeshRenderer {
|
||||
/// Stable asset-registry UUID. Empty only for legacy or unresolved references.
|
||||
#[serde(default)]
|
||||
pub asset_id: String,
|
||||
/// Asset path relative to `assets/`, e.g. `models/character.glb`.
|
||||
pub path: String,
|
||||
/// Scene index whose hierarchy owns the skinned primitives and joints.
|
||||
#[serde(default)]
|
||||
pub scene_index: usize,
|
||||
/// Stable material slots for every draw binding in the imported animated hierarchy.
|
||||
#[serde(default)]
|
||||
pub materials: crate::RendererMaterialSet,
|
||||
}
|
||||
|
||||
impl SkinnedMeshRenderer {
|
||||
pub fn new(path: impl Into<String>) -> Self {
|
||||
Self {
|
||||
asset_id: String::new(),
|
||||
path: path.into(),
|
||||
scene_index: 0,
|
||||
materials: crate::RendererMaterialSet::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_asset_id(mut self, asset_id: impl Into<String>) -> Self {
|
||||
self.asset_id = asset_id.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Asset-server path for FBX `#SceneN` labels (see `bevy_ufbx`).
|
||||
pub fn fbx_scene_asset_path(path: &str, scene_index: usize) -> String {
|
||||
format!("{path}#Scene{scene_index}")
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SkinnedMeshRenderer {
|
||||
fn default() -> Self {
|
||||
Self::new(String::new())
|
||||
}
|
||||
}
|
||||
|
||||
/// Authored playback window inside an imported clip.
|
||||
#[derive(Reflect, Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
|
||||
@ -135,7 +184,7 @@ impl Default for AnimationStateDesc {
|
||||
}
|
||||
}
|
||||
|
||||
/// Scene-authored single-layer animation controller.
|
||||
/// Scene-authored single-layer animation controller for a sibling [`SkinnedMeshRenderer`].
|
||||
///
|
||||
/// Bevy graph, player, transition, and instantiated-world state are derived at runtime.
|
||||
#[derive(Component, Reflect, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
@ -191,6 +240,12 @@ pub struct AnimationManifest {
|
||||
pub schema_version: u32,
|
||||
pub asset_id: String,
|
||||
pub label: String,
|
||||
/// Explicit imported clip shown as the asset's edit-mode rest presentation.
|
||||
///
|
||||
/// This is a stable clip sub-asset ID. `None` deliberately preserves the imported node pose;
|
||||
/// importers and placement code must never guess the first clip.
|
||||
#[serde(default)]
|
||||
pub default_animation_clip_id: Option<String>,
|
||||
pub source: AnimationManifestSource,
|
||||
/// Whether this source format can hydrate the extracted animation data at runtime.
|
||||
pub runtime_supported: bool,
|
||||
@ -315,4 +370,26 @@ mod tests {
|
||||
assert_eq!(animation_skeleton_source_index(&skeleton), Some(3));
|
||||
assert_eq!(animation_clip_source_index(&skeleton), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn schema_v2_manifest_defaults_to_imported_rest_pose() {
|
||||
let legacy = r#"(
|
||||
schema_version: 2,
|
||||
asset_id: "model-id",
|
||||
label: "Legacy",
|
||||
source: (
|
||||
path: "assets/models/legacy.glb",
|
||||
format: "glb",
|
||||
fingerprint: (byte_len: 1, modified_unix_secs: 0, content_hash: "hash"),
|
||||
dependencies: [],
|
||||
),
|
||||
runtime_supported: true,
|
||||
skeletons: [],
|
||||
clips: [],
|
||||
diagnostics: [],
|
||||
)"#;
|
||||
|
||||
let manifest: AnimationManifest = ron::from_str(legacy).unwrap();
|
||||
assert!(manifest.default_animation_clip_id.is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@ -80,16 +80,39 @@ impl ComponentInstanceId {
|
||||
#[derive(Component, Reflect, Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[reflect(Component, Default, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct InspectorOrder {
|
||||
/// Stable component IDs used by current scenes.
|
||||
#[serde(default)]
|
||||
pub component_ids: Vec<String>,
|
||||
/// Legacy reflected type paths used by schema-v3 scenes.
|
||||
#[serde(default)]
|
||||
pub component_type_names: Vec<String>,
|
||||
/// Legacy schema-v3 storage. New authoring writes active state to
|
||||
/// [`AuthoringComponentStates`]; this field remains readable so old scenes can
|
||||
/// be upgraded without losing disabled components.
|
||||
#[serde(default)]
|
||||
pub component_states: Vec<InspectorComponentState>,
|
||||
}
|
||||
|
||||
/// Persisted active state for authoring components.
|
||||
///
|
||||
/// This is intentionally separate from [`InspectorOrder`]: hydration and runtime
|
||||
/// behavior must not depend on how the editor happens to arrange component cards.
|
||||
#[derive(Component, Reflect, Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[reflect(Component, Default, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AuthoringComponentStates {
|
||||
#[serde(default)]
|
||||
pub states: Vec<InspectorComponentState>,
|
||||
}
|
||||
|
||||
/// Per-component inspector metadata that must survive scene save/load.
|
||||
#[derive(Reflect, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct InspectorComponentState {
|
||||
/// Stable authoring component identity used by current scenes.
|
||||
#[serde(default)]
|
||||
pub component_id: String,
|
||||
/// Legacy schema-v3 reflected type path.
|
||||
#[serde(default)]
|
||||
pub type_name: String,
|
||||
#[serde(default = "default_true")]
|
||||
pub active: bool,
|
||||
@ -98,6 +121,7 @@ pub struct InspectorComponentState {
|
||||
impl Default for InspectorComponentState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
component_id: String::new(),
|
||||
type_name: String::new(),
|
||||
active: true,
|
||||
}
|
||||
@ -106,41 +130,64 @@ impl Default for InspectorComponentState {
|
||||
|
||||
impl InspectorOrder {
|
||||
pub fn is_component_active(&self, type_name: &str) -> bool {
|
||||
let component_id = authoring_component_key(type_name);
|
||||
self.component_states
|
||||
.iter()
|
||||
.find(|state| state.type_name == type_name)
|
||||
.find(|state| {
|
||||
state.component_id == component_id
|
||||
|| state.type_name == type_name
|
||||
|| state.type_name == component_id
|
||||
})
|
||||
.map(|state| state.active)
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
pub fn set_component_active(&mut self, type_name: impl Into<String>, active: bool) {
|
||||
let type_name = type_name.into();
|
||||
if let Some(state) = self
|
||||
.component_states
|
||||
.iter_mut()
|
||||
.find(|state| state.type_name == type_name)
|
||||
{
|
||||
let component_id = authoring_component_key(&type_name).to_string();
|
||||
if let Some(state) = self.component_states.iter_mut().find(|state| {
|
||||
state.component_id == component_id
|
||||
|| state.type_name == type_name
|
||||
|| state.type_name == component_id
|
||||
}) {
|
||||
state.component_id = component_id;
|
||||
state.type_name.clear();
|
||||
state.active = active;
|
||||
} else {
|
||||
self.component_states
|
||||
.push(InspectorComponentState { type_name, active });
|
||||
self.component_states.push(InspectorComponentState {
|
||||
component_id,
|
||||
type_name: String::new(),
|
||||
active,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ensure_component_order(&mut self, present: &[&str]) {
|
||||
for type_name in present {
|
||||
let component_id = authoring_component_key(type_name);
|
||||
if !self
|
||||
.component_type_names
|
||||
.component_ids
|
||||
.iter()
|
||||
.any(|existing| existing == type_name)
|
||||
.any(|existing| existing == component_id)
|
||||
{
|
||||
self.component_type_names.push((*type_name).to_string());
|
||||
self.component_ids.push(component_id.to_string());
|
||||
}
|
||||
}
|
||||
// Once the editor touches order, the stable-ID representation is
|
||||
// authoritative and legacy paths no longer need to be re-saved.
|
||||
self.component_type_names.clear();
|
||||
}
|
||||
|
||||
pub fn ordered_components<'a>(&self, present: &[&'a str]) -> Vec<&'a str> {
|
||||
let mut ordered = Vec::new();
|
||||
for component_id in &self.component_ids {
|
||||
if let Some(present_type) = present
|
||||
.iter()
|
||||
.find(|present| authoring_component_key(present) == component_id)
|
||||
{
|
||||
ordered.push(*present_type);
|
||||
}
|
||||
}
|
||||
for type_name in &self.component_type_names {
|
||||
if let Some(present_type) = present.iter().find(|present| **present == type_name) {
|
||||
ordered.push(*present_type);
|
||||
@ -157,29 +204,186 @@ impl InspectorOrder {
|
||||
pub fn move_component(&mut self, type_name: &str, offset: isize, present: &[&str]) -> bool {
|
||||
self.ensure_component_order(present);
|
||||
let Some(index) = self
|
||||
.component_type_names
|
||||
.component_ids
|
||||
.iter()
|
||||
.position(|existing| existing == type_name)
|
||||
.position(|existing| existing == authoring_component_key(type_name))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let target = (index as isize + offset)
|
||||
.clamp(0, self.component_type_names.len() as isize - 1) as usize;
|
||||
let target =
|
||||
(index as isize + offset).clamp(0, self.component_ids.len() as isize - 1) as usize;
|
||||
if target == index {
|
||||
return false;
|
||||
}
|
||||
let item = self.component_type_names.remove(index);
|
||||
self.component_type_names.insert(target, item);
|
||||
let item = self.component_ids.remove(index);
|
||||
self.component_ids.insert(target, item);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
impl AuthoringComponentStates {
|
||||
pub fn is_component_active(&self, type_name: &str) -> bool {
|
||||
let component_id = authoring_component_key(type_name);
|
||||
self.states
|
||||
.iter()
|
||||
.find(|state| {
|
||||
state.component_id == component_id
|
||||
|| state.type_name == type_name
|
||||
|| state.type_name == component_id
|
||||
})
|
||||
.map(|state| state.active)
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
pub fn set_component_active(&mut self, type_name: impl Into<String>, active: bool) {
|
||||
let type_name = type_name.into();
|
||||
let component_id = authoring_component_key(&type_name).to_string();
|
||||
if let Some(state) = self.states.iter_mut().find(|state| {
|
||||
state.component_id == component_id
|
||||
|| state.type_name == type_name
|
||||
|| state.type_name == component_id
|
||||
}) {
|
||||
state.component_id = component_id;
|
||||
state.type_name.clear();
|
||||
state.active = active;
|
||||
} else {
|
||||
self.states.push(InspectorComponentState {
|
||||
component_id,
|
||||
type_name: String::new(),
|
||||
active,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove_component(&mut self, type_name: &str) {
|
||||
let component_id = authoring_component_key(type_name);
|
||||
self.states.retain(|state| {
|
||||
state.component_id != component_id
|
||||
&& state.type_name != type_name
|
||||
&& state.type_name != component_id
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns whether a persisted authoring component is enabled.
|
||||
///
|
||||
/// `legacy_order` is consulted only when an older scene has not yet been migrated
|
||||
/// to [`AuthoringComponentStates`].
|
||||
pub fn authoring_component_active(
|
||||
states: Option<&AuthoringComponentStates>,
|
||||
legacy_order: Option<&InspectorOrder>,
|
||||
type_name: &str,
|
||||
) -> bool {
|
||||
states
|
||||
.map(|states| states.is_component_active(type_name))
|
||||
.or_else(|| legacy_order.map(|order| order.is_component_active(type_name)))
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
pub fn inspector_component_active(order: Option<&InspectorOrder>, type_name: &str) -> bool {
|
||||
order
|
||||
.map(|order| order.is_component_active(type_name))
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
// Stable authoring component identities. These are editor/protocol keys; reflected
|
||||
// Rust type paths remain serialization details and may change during refactors.
|
||||
pub const AUTHORING_COMPONENT_PRIMITIVE: &str = "render.primitive";
|
||||
pub const AUTHORING_COMPONENT_BRUSH: &str = "render.brush";
|
||||
pub const AUTHORING_COMPONENT_STATIC_MESH_RENDERER: &str = "render.static_mesh_renderer";
|
||||
pub const AUTHORING_COMPONENT_SKINNED_MESH_RENDERER: &str = "render.skinned_mesh_renderer";
|
||||
pub const AUTHORING_COMPONENT_MATERIAL: &str = "render.material";
|
||||
pub const AUTHORING_COMPONENT_LIGHT: &str = "render.light";
|
||||
pub const AUTHORING_COMPONENT_ANIMATION_CONTROLLER: &str = "animation.controller";
|
||||
pub const AUTHORING_COMPONENT_AUDIO_SOURCE: &str = "audio.source";
|
||||
pub const AUTHORING_COMPONENT_AUDIO_LISTENER: &str = "audio.listener";
|
||||
pub const AUTHORING_COMPONENT_RIGID_BODY: &str = "physics.rigid_body";
|
||||
pub const AUTHORING_COMPONENT_COLLIDER: &str = "physics.collider";
|
||||
pub const AUTHORING_COMPONENT_LEGACY_PHYSICS_BODY: &str = "physics.legacy_body";
|
||||
pub const AUTHORING_COMPONENT_PLAYER_SPAWN: &str = "gameplay.player_spawn";
|
||||
pub const AUTHORING_COMPONENT_WEAPON_SPAWN: &str = "gameplay.weapon_spawn";
|
||||
pub const AUTHORING_COMPONENT_TRIGGER_VOLUME: &str = "gameplay.trigger_volume";
|
||||
pub const AUTHORING_COMPONENT_TEAM_SPAWN: &str = "gameplay.team_spawn";
|
||||
pub const AUTHORING_COMPONENT_OBJECTIVE: &str = "gameplay.objective";
|
||||
pub const AUTHORING_COMPONENT_POST_PROCESS_VOLUME: &str = "render.post_process_volume";
|
||||
pub const AUTHORING_COMPONENT_NAVIGATION_BOUNDS: &str = "navigation.bounds";
|
||||
pub const AUTHORING_COMPONENT_NAVIGATION_OBSTACLE: &str = "navigation.obstacle";
|
||||
pub const AUTHORING_COMPONENT_NAVIGATION_AREA: &str = "navigation.area";
|
||||
pub const AUTHORING_COMPONENT_NAVIGATION_LINK: &str = "navigation.link";
|
||||
|
||||
/// Resolves either a stable authoring ID or a reflected Rust type path to its
|
||||
/// stable editor/protocol identity. Unknown extension keys are returned as
|
||||
/// `None` and remain usable as-is by generic state/order storage.
|
||||
pub fn authoring_component_id(key: &str) -> Option<&'static str> {
|
||||
match key {
|
||||
AUTHORING_COMPONENT_PRIMITIVE | COMPONENT_PRIMITIVE => Some(AUTHORING_COMPONENT_PRIMITIVE),
|
||||
AUTHORING_COMPONENT_BRUSH | COMPONENT_BRUSH_DESC => Some(AUTHORING_COMPONENT_BRUSH),
|
||||
AUTHORING_COMPONENT_STATIC_MESH_RENDERER | COMPONENT_STATIC_MESH_RENDERER => {
|
||||
Some(AUTHORING_COMPONENT_STATIC_MESH_RENDERER)
|
||||
}
|
||||
AUTHORING_COMPONENT_SKINNED_MESH_RENDERER | "shared::animation::SkinnedMeshRenderer" => {
|
||||
Some(AUTHORING_COMPONENT_SKINNED_MESH_RENDERER)
|
||||
}
|
||||
AUTHORING_COMPONENT_MATERIAL | COMPONENT_MATERIAL_DESC => {
|
||||
Some(AUTHORING_COMPONENT_MATERIAL)
|
||||
}
|
||||
AUTHORING_COMPONENT_LIGHT | COMPONENT_LIGHT_DESC => Some(AUTHORING_COMPONENT_LIGHT),
|
||||
AUTHORING_COMPONENT_ANIMATION_CONTROLLER | "shared::animation::AnimationControllerDesc" => {
|
||||
Some(AUTHORING_COMPONENT_ANIMATION_CONTROLLER)
|
||||
}
|
||||
AUTHORING_COMPONENT_AUDIO_SOURCE | COMPONENT_AUDIO_SOURCE_DESC => {
|
||||
Some(AUTHORING_COMPONENT_AUDIO_SOURCE)
|
||||
}
|
||||
AUTHORING_COMPONENT_AUDIO_LISTENER | COMPONENT_AUDIO_LISTENER_DESC => {
|
||||
Some(AUTHORING_COMPONENT_AUDIO_LISTENER)
|
||||
}
|
||||
AUTHORING_COMPONENT_RIGID_BODY | COMPONENT_RIGID_BODY_DESC => {
|
||||
Some(AUTHORING_COMPONENT_RIGID_BODY)
|
||||
}
|
||||
AUTHORING_COMPONENT_COLLIDER | COMPONENT_COLLIDER_DESC => {
|
||||
Some(AUTHORING_COMPONENT_COLLIDER)
|
||||
}
|
||||
AUTHORING_COMPONENT_LEGACY_PHYSICS_BODY | COMPONENT_PHYSICS_BODY => {
|
||||
Some(AUTHORING_COMPONENT_LEGACY_PHYSICS_BODY)
|
||||
}
|
||||
AUTHORING_COMPONENT_PLAYER_SPAWN | COMPONENT_PLAYER_SPAWN => {
|
||||
Some(AUTHORING_COMPONENT_PLAYER_SPAWN)
|
||||
}
|
||||
AUTHORING_COMPONENT_WEAPON_SPAWN | COMPONENT_WEAPON_SPAWN => {
|
||||
Some(AUTHORING_COMPONENT_WEAPON_SPAWN)
|
||||
}
|
||||
AUTHORING_COMPONENT_TRIGGER_VOLUME | COMPONENT_TRIGGER_VOLUME => {
|
||||
Some(AUTHORING_COMPONENT_TRIGGER_VOLUME)
|
||||
}
|
||||
AUTHORING_COMPONENT_TEAM_SPAWN | COMPONENT_TEAM_SPAWN => {
|
||||
Some(AUTHORING_COMPONENT_TEAM_SPAWN)
|
||||
}
|
||||
AUTHORING_COMPONENT_OBJECTIVE | COMPONENT_OBJECTIVE_MARKER => {
|
||||
Some(AUTHORING_COMPONENT_OBJECTIVE)
|
||||
}
|
||||
AUTHORING_COMPONENT_POST_PROCESS_VOLUME | COMPONENT_POST_PROCESS_VOLUME => {
|
||||
Some(AUTHORING_COMPONENT_POST_PROCESS_VOLUME)
|
||||
}
|
||||
AUTHORING_COMPONENT_NAVIGATION_BOUNDS | "shared::navigation::NavigationBounds" => {
|
||||
Some(AUTHORING_COMPONENT_NAVIGATION_BOUNDS)
|
||||
}
|
||||
AUTHORING_COMPONENT_NAVIGATION_OBSTACLE | "shared::navigation::NavigationObstacle" => {
|
||||
Some(AUTHORING_COMPONENT_NAVIGATION_OBSTACLE)
|
||||
}
|
||||
AUTHORING_COMPONENT_NAVIGATION_AREA | "shared::navigation::NavigationArea" => {
|
||||
Some(AUTHORING_COMPONENT_NAVIGATION_AREA)
|
||||
}
|
||||
AUTHORING_COMPONENT_NAVIGATION_LINK | "shared::navigation::NavigationLink" => {
|
||||
Some(AUTHORING_COMPONENT_NAVIGATION_LINK)
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn authoring_component_key(key: &str) -> &str {
|
||||
authoring_component_id(key).unwrap_or(key)
|
||||
}
|
||||
|
||||
pub const COMPONENT_PRIMITIVE: &str = "shared::components::Primitive";
|
||||
pub const COMPONENT_BRUSH_DESC: &str = "shared::components::BrushDesc";
|
||||
pub const COMPONENT_STATIC_MESH_RENDERER: &str = "shared::components::StaticMeshRenderer";
|
||||
@ -700,6 +904,11 @@ pub struct MeshRenderSlot {
|
||||
pub id: ComponentInstanceId,
|
||||
pub name: String,
|
||||
pub mesh: EditorAssetRef,
|
||||
/// Stable renderer material slot used by this draw part.
|
||||
#[serde(default)]
|
||||
pub material_slot_id: ComponentInstanceId,
|
||||
/// Legacy inline imported-material reference. Schema-v4 migration moves this into the
|
||||
/// renderer's shared `RendererMaterialSet`.
|
||||
#[serde(default)]
|
||||
pub material: Option<EditorAssetRef>,
|
||||
#[serde(default)]
|
||||
@ -718,6 +927,7 @@ impl Default for MeshRenderSlot {
|
||||
id: ComponentInstanceId::default(),
|
||||
name: "Mesh".into(),
|
||||
mesh: EditorAssetRef::default(),
|
||||
material_slot_id: ComponentInstanceId::default(),
|
||||
material: None,
|
||||
local_transform: Transform::default(),
|
||||
visible: true,
|
||||
@ -730,6 +940,9 @@ impl Default for MeshRenderSlot {
|
||||
/// Backward-compatible alias for old code paths while scenes migrate to slots.
|
||||
pub type StaticMeshRendererEntry = MeshRenderSlot;
|
||||
|
||||
/// Public renderer-foundation name for one static draw part.
|
||||
pub type StaticMeshPart = MeshRenderSlot;
|
||||
|
||||
/// Reflectable static mesh renderer. Hydration turns entries into child `Mesh3d`
|
||||
/// entities and source/default materials.
|
||||
#[derive(Component, Reflect, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
@ -737,15 +950,48 @@ pub type StaticMeshRendererEntry = MeshRenderSlot;
|
||||
pub struct StaticMeshRenderer {
|
||||
#[serde(default, alias = "entries")]
|
||||
pub slots: Vec<MeshRenderSlot>,
|
||||
#[serde(default)]
|
||||
pub materials: crate::RendererMaterialSet,
|
||||
}
|
||||
|
||||
impl StaticMeshRenderer {
|
||||
pub fn empty() -> Self {
|
||||
Self { slots: Vec::new() }
|
||||
Self {
|
||||
slots: Vec::new(),
|
||||
materials: crate::RendererMaterialSet::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn single(slot: MeshRenderSlot) -> Self {
|
||||
Self { slots: vec![slot] }
|
||||
pub fn single(mut slot: MeshRenderSlot) -> Self {
|
||||
if slot.material_slot_id.0.trim().is_empty() {
|
||||
slot.material_slot_id = ComponentInstanceId::new(format!("slot:{}", slot.id.0));
|
||||
}
|
||||
let source_material = slot.material.clone().map(crate::MaterialRef::new);
|
||||
let materials = crate::RendererMaterialSet {
|
||||
slots: vec![crate::RendererMaterialSlot {
|
||||
id: slot.material_slot_id.clone(),
|
||||
name: slot.name.clone(),
|
||||
source_material,
|
||||
material: None,
|
||||
}],
|
||||
orphaned_assignments: Vec::new(),
|
||||
};
|
||||
Self {
|
||||
slots: vec![slot],
|
||||
materials,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn material_slot_for_part(
|
||||
&self,
|
||||
part: &MeshRenderSlot,
|
||||
) -> Option<&crate::RendererMaterialSlot> {
|
||||
let slot_id = if part.material_slot_id.0.trim().is_empty() {
|
||||
&part.id
|
||||
} else {
|
||||
&part.material_slot_id
|
||||
};
|
||||
self.materials.slot(slot_id)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1147,6 +1393,7 @@ pub enum ActorKind {
|
||||
Empty,
|
||||
Brush,
|
||||
StaticMesh,
|
||||
SkinnedMesh,
|
||||
ImportedModel,
|
||||
Light,
|
||||
PrefabAnchor,
|
||||
@ -1158,6 +1405,7 @@ pub enum ActorKind {
|
||||
Objective,
|
||||
AudioSource,
|
||||
AudioListener,
|
||||
Navigation,
|
||||
}
|
||||
|
||||
/// Inspector upper bound for point/spot [`LightDesc::intensity`] (lumens).
|
||||
|
||||
@ -9,9 +9,10 @@ use bevy::mesh::{Indices, PrimitiveTopology};
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::{
|
||||
asset_server_path, brush_math::validate_brush, inspector_component_active, BrushDesc,
|
||||
BrushFaceDesc, ColliderDesc, InspectorOrder, LevelObject, MaterialAsset, MaterialDesc,
|
||||
COMPONENT_BRUSH_DESC, COMPONENT_COLLIDER_DESC, COMPONENT_MATERIAL_DESC,
|
||||
asset_server_path, authoring_component_active, brush_math::validate_brush,
|
||||
AuthoringComponentStates, BrushDesc, BrushFaceDesc, ColliderDesc, InspectorOrder, LevelObject,
|
||||
MaterialAsset, MaterialDesc, COMPONENT_BRUSH_DESC, COMPONENT_COLLIDER_DESC,
|
||||
COMPONENT_MATERIAL_DESC,
|
||||
};
|
||||
|
||||
use super::materials::material_from_desc;
|
||||
@ -33,6 +34,7 @@ pub fn hydrate_brushes(
|
||||
&BrushDesc,
|
||||
Option<&MaterialDesc>,
|
||||
Option<&ColliderDesc>,
|
||||
Option<&AuthoringComponentStates>,
|
||||
Option<&InspectorOrder>,
|
||||
),
|
||||
(
|
||||
@ -42,6 +44,7 @@ pub fn hydrate_brushes(
|
||||
Changed<BrushDesc>,
|
||||
Changed<MaterialDesc>,
|
||||
Changed<ColliderDesc>,
|
||||
Changed<AuthoringComponentStates>,
|
||||
Changed<InspectorOrder>,
|
||||
)>,
|
||||
),
|
||||
@ -49,9 +52,9 @@ pub fn hydrate_brushes(
|
||||
children: Query<&Children>,
|
||||
generated_brushes: Query<(), With<HydratedBrushMesh>>,
|
||||
) {
|
||||
for (entity, brush, material, collider, order) in &brushes {
|
||||
for (entity, brush, material, collider, states, legacy_order) in &brushes {
|
||||
despawn_brush_mesh(&mut commands, entity, &children, &generated_brushes);
|
||||
if !inspector_component_active(order, COMPONENT_BRUSH_DESC) {
|
||||
if !authoring_component_active(states, legacy_order, COMPONENT_BRUSH_DESC) {
|
||||
continue;
|
||||
}
|
||||
spawn_brush_mesh(
|
||||
@ -61,8 +64,12 @@ pub fn hydrate_brushes(
|
||||
&mut materials,
|
||||
entity,
|
||||
brush,
|
||||
material.filter(|_| inspector_component_active(order, COMPONENT_MATERIAL_DESC)),
|
||||
collider.filter(|_| inspector_component_active(order, COMPONENT_COLLIDER_DESC)),
|
||||
material.filter(|_| {
|
||||
authoring_component_active(states, legacy_order, COMPONENT_MATERIAL_DESC)
|
||||
}),
|
||||
collider.filter(|_| {
|
||||
authoring_component_active(states, legacy_order, COMPONENT_COLLIDER_DESC)
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -5,8 +5,8 @@ use bevy::prelude::*;
|
||||
use settings::{ActiveCameraRenderProfile, GiPath, RenderingCapabilities, RenderingSettings};
|
||||
|
||||
use crate::{
|
||||
inspector_component_active, AuthoringLightKind, InspectorOrder, LevelObject, LightDesc,
|
||||
COMPONENT_LIGHT_DESC,
|
||||
authoring_component_active, AuthoringComponentStates, AuthoringLightKind, InspectorOrder,
|
||||
LevelObject, LightDesc, COMPONENT_LIGHT_DESC,
|
||||
};
|
||||
|
||||
/// Legacy directional lux below this is treated as invalid (see ADR 0011).
|
||||
@ -31,21 +31,36 @@ pub fn hydrate_lights(
|
||||
settings: Res<settings::ProjectSettings>,
|
||||
profile: Option<Res<ActiveCameraRenderProfile>>,
|
||||
caps: Option<Res<RenderingCapabilities>>,
|
||||
lights: Query<(Entity, Ref<LightDesc>, Option<Ref<InspectorOrder>>), With<LevelObject>>,
|
||||
lights: Query<
|
||||
(
|
||||
Entity,
|
||||
Ref<LightDesc>,
|
||||
Option<Ref<AuthoringComponentStates>>,
|
||||
Option<Ref<InspectorOrder>>,
|
||||
),
|
||||
With<LevelObject>,
|
||||
>,
|
||||
) {
|
||||
let rendering = &settings.rendering;
|
||||
let render_stack_changed = profile.as_ref().is_some_and(|profile| profile.is_changed())
|
||||
|| caps.as_ref().is_some_and(|caps| caps.is_changed());
|
||||
let profile = profile.as_deref();
|
||||
let caps = caps.as_deref();
|
||||
for (entity, light, order) in &lights {
|
||||
let order_changed = order.as_ref().is_some_and(|order| order.is_changed());
|
||||
if !light.is_changed() && !order_changed && !render_stack_changed {
|
||||
for (entity, light, states, legacy_order) in &lights {
|
||||
let state_changed = states.as_ref().is_some_and(|states| states.is_changed());
|
||||
let legacy_changed = legacy_order
|
||||
.as_ref()
|
||||
.is_some_and(|order| order.is_changed());
|
||||
if !light.is_changed() && !state_changed && !legacy_changed && !render_stack_changed {
|
||||
continue;
|
||||
}
|
||||
let mut entity_commands = commands.entity(entity);
|
||||
strip_light_components(&mut entity_commands);
|
||||
if !inspector_component_active(order.as_deref(), COMPONENT_LIGHT_DESC) {
|
||||
if !authoring_component_active(
|
||||
states.as_deref(),
|
||||
legacy_order.as_deref(),
|
||||
COMPONENT_LIGHT_DESC,
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if local_light_disabled_in_solari(&light, profile, caps) {
|
||||
@ -86,7 +101,15 @@ pub fn reconcile_missing_runtime_lights(
|
||||
settings: Res<settings::ProjectSettings>,
|
||||
profile: Option<Res<ActiveCameraRenderProfile>>,
|
||||
caps: Option<Res<RenderingCapabilities>>,
|
||||
lights: Query<(Entity, &LightDesc, Option<&InspectorOrder>), With<LevelObject>>,
|
||||
lights: Query<
|
||||
(
|
||||
Entity,
|
||||
&LightDesc,
|
||||
Option<&AuthoringComponentStates>,
|
||||
Option<&InspectorOrder>,
|
||||
),
|
||||
With<LevelObject>,
|
||||
>,
|
||||
points: Query<(), With<PointLight>>,
|
||||
spots: Query<(), With<SpotLight>>,
|
||||
directionals: Query<(), With<DirectionalLight>>,
|
||||
@ -94,8 +117,8 @@ pub fn reconcile_missing_runtime_lights(
|
||||
let rendering = &settings.rendering;
|
||||
let profile = profile.as_deref();
|
||||
let caps = caps.as_deref();
|
||||
for (entity, light, order) in &lights {
|
||||
if !inspector_component_active(order, COMPONENT_LIGHT_DESC) {
|
||||
for (entity, light, states, legacy_order) in &lights {
|
||||
if !authoring_component_active(states, legacy_order, COMPONENT_LIGHT_DESC) {
|
||||
continue;
|
||||
}
|
||||
if local_light_disabled_in_solari(light, profile, caps) {
|
||||
|
||||
@ -3,8 +3,8 @@
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::{
|
||||
asset_server_path, inspector_component_active, InspectorOrder, LevelObject, MaterialDesc,
|
||||
MaterialShaderKind, Primitive, COMPONENT_MATERIAL_DESC,
|
||||
asset_server_path, authoring_component_active, AuthoringComponentStates, InspectorOrder,
|
||||
LevelObject, MaterialDesc, MaterialShaderKind, Primitive, COMPONENT_MATERIAL_DESC,
|
||||
};
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
@ -13,7 +13,12 @@ pub fn hydrate_materials(
|
||||
asset_server: Res<AssetServer>,
|
||||
mut materials: ResMut<Assets<StandardMaterial>>,
|
||||
targets: Query<
|
||||
(Entity, &MaterialDesc, Option<&InspectorOrder>),
|
||||
(
|
||||
Entity,
|
||||
&MaterialDesc,
|
||||
Option<&AuthoringComponentStates>,
|
||||
Option<&InspectorOrder>,
|
||||
),
|
||||
(
|
||||
With<LevelObject>,
|
||||
With<Mesh3d>,
|
||||
@ -21,13 +26,14 @@ pub fn hydrate_materials(
|
||||
Changed<MaterialDesc>,
|
||||
Added<Mesh3d>,
|
||||
Changed<Primitive>,
|
||||
Changed<AuthoringComponentStates>,
|
||||
Changed<InspectorOrder>,
|
||||
)>,
|
||||
),
|
||||
>,
|
||||
) {
|
||||
for (entity, desc, order) in &targets {
|
||||
if !inspector_component_active(order, COMPONENT_MATERIAL_DESC) {
|
||||
for (entity, desc, states, legacy_order) in &targets {
|
||||
if !authoring_component_active(states, legacy_order, COMPONENT_MATERIAL_DESC) {
|
||||
commands
|
||||
.entity(entity)
|
||||
.remove::<MeshMaterial3d<StandardMaterial>>();
|
||||
|
||||
@ -7,6 +7,7 @@ mod models;
|
||||
mod physics;
|
||||
mod prefabs;
|
||||
mod primitives;
|
||||
mod skinned_meshes;
|
||||
mod static_meshes;
|
||||
pub mod strip;
|
||||
mod visibility;
|
||||
@ -27,6 +28,11 @@ use physics::hydrate_physics;
|
||||
use prefabs::{hydrate_prefabs, tag_hydrated_prefab_members};
|
||||
pub use prefabs::{HydratedPrefabMember, HydratedPrefabReady, PrefabHydrationBlocked};
|
||||
use primitives::{hydrate_primitives, primitive_mesh};
|
||||
pub use skinned_meshes::HydratedSkinnedMeshRoot;
|
||||
use skinned_meshes::{
|
||||
apply_skinned_renderer_material_changes, cleanup_removed_skinned_mesh_renderers,
|
||||
hydrate_skinned_mesh_renderers, mark_skinned_materials_ready,
|
||||
};
|
||||
use static_meshes::{
|
||||
hydrate_static_mesh_renderers, spawn_static_mesh_parts, HydratedStaticMeshPart,
|
||||
StaticMeshArtifactCache,
|
||||
@ -37,10 +43,10 @@ use visibility::{
|
||||
};
|
||||
|
||||
use crate::{
|
||||
inspector_component_active, BrushDesc, ColliderDesc, EditorVisibility, InspectorOrder,
|
||||
LevelObject, MaterialDesc, MaterialOverride, Primitive, StaticMeshRenderer,
|
||||
COMPONENT_BRUSH_DESC, COMPONENT_COLLIDER_DESC, COMPONENT_LIGHT_DESC, COMPONENT_MATERIAL_DESC,
|
||||
COMPONENT_PRIMITIVE, COMPONENT_STATIC_MESH_RENDERER,
|
||||
authoring_component_active, AuthoringComponentStates, BrushDesc, ColliderDesc,
|
||||
EditorVisibility, InspectorOrder, LevelObject, MaterialDesc, MaterialOverride, Primitive,
|
||||
StaticMeshRenderer, COMPONENT_BRUSH_DESC, COMPONENT_COLLIDER_DESC, COMPONENT_LIGHT_DESC,
|
||||
COMPONENT_MATERIAL_DESC, COMPONENT_PRIMITIVE, COMPONENT_STATIC_MESH_RENDERER,
|
||||
};
|
||||
|
||||
/// Registers hydration systems in deterministic order.
|
||||
@ -50,6 +56,7 @@ impl Plugin for HydrationPlugin {
|
||||
fn build(&self, app: &mut App) {
|
||||
app.init_resource::<StaticMeshArtifactCache>()
|
||||
.add_observer(tag_hydrated_prefab_members)
|
||||
.add_observer(mark_skinned_materials_ready)
|
||||
.add_observer(crate::prefab_overrides::apply_prefab_overrides_on_ready)
|
||||
.add_systems(
|
||||
Update,
|
||||
@ -60,6 +67,9 @@ impl Plugin for HydrationPlugin {
|
||||
hydrate_lights,
|
||||
reconcile_missing_runtime_lights,
|
||||
hydrate_static_mesh_renderers,
|
||||
hydrate_skinned_mesh_renderers,
|
||||
apply_skinned_renderer_material_changes,
|
||||
cleanup_removed_skinned_mesh_renderers,
|
||||
hydrate_models,
|
||||
hydrate_prefabs,
|
||||
hydrate_physics,
|
||||
@ -89,17 +99,31 @@ pub fn flush_level_object_hydration(world: &mut World) {
|
||||
}
|
||||
|
||||
let primitives: Vec<(Entity, Primitive)> = world
|
||||
.query_filtered::<(Entity, &Primitive, Option<&InspectorOrder>), With<LevelObject>>()
|
||||
.query_filtered::<(
|
||||
Entity,
|
||||
&Primitive,
|
||||
Option<&AuthoringComponentStates>,
|
||||
Option<&InspectorOrder>,
|
||||
), With<LevelObject>>()
|
||||
.iter(world)
|
||||
.filter(|(_, _, order)| inspector_component_active(*order, COMPONENT_PRIMITIVE))
|
||||
.map(|(entity, primitive, _)| (entity, primitive.clone()))
|
||||
.filter(|(_, _, states, order)| {
|
||||
authoring_component_active(*states, *order, COMPONENT_PRIMITIVE)
|
||||
})
|
||||
.map(|(entity, primitive, _, _)| (entity, primitive.clone()))
|
||||
.collect();
|
||||
|
||||
let material_targets: Vec<(Entity, MaterialDesc)> = world
|
||||
.query_filtered::<(Entity, &MaterialDesc, Option<&InspectorOrder>), With<LevelObject>>()
|
||||
.query_filtered::<(
|
||||
Entity,
|
||||
&MaterialDesc,
|
||||
Option<&AuthoringComponentStates>,
|
||||
Option<&InspectorOrder>,
|
||||
), With<LevelObject>>()
|
||||
.iter(world)
|
||||
.filter(|(_, _, order)| inspector_component_active(*order, COMPONENT_MATERIAL_DESC))
|
||||
.map(|(entity, desc, _)| (entity, desc.clone()))
|
||||
.filter(|(_, _, states, order)| {
|
||||
authoring_component_active(*states, *order, COMPONENT_MATERIAL_DESC)
|
||||
})
|
||||
.map(|(entity, desc, _, _)| (entity, desc.clone()))
|
||||
.collect();
|
||||
|
||||
let brush_entities_for_cleanup: Vec<Entity> = world
|
||||
@ -113,31 +137,33 @@ pub fn flush_level_object_hydration(world: &mut World) {
|
||||
BrushDesc,
|
||||
Option<MaterialDesc>,
|
||||
Option<ColliderDesc>,
|
||||
Option<InspectorOrder>,
|
||||
)> = world
|
||||
.query_filtered::<(
|
||||
Entity,
|
||||
&BrushDesc,
|
||||
Option<&MaterialDesc>,
|
||||
Option<&ColliderDesc>,
|
||||
Option<&AuthoringComponentStates>,
|
||||
Option<&InspectorOrder>,
|
||||
), With<LevelObject>>()
|
||||
.iter(world)
|
||||
.map(|(entity, brush, material, collider, order)| {
|
||||
.filter_map(|(entity, brush, material, collider, states, order)| {
|
||||
authoring_component_active(states, order, COMPONENT_BRUSH_DESC).then(|| {
|
||||
(
|
||||
entity,
|
||||
brush.clone(),
|
||||
material
|
||||
.filter(|_| inspector_component_active(order, COMPONENT_MATERIAL_DESC))
|
||||
.filter(|_| {
|
||||
authoring_component_active(states, order, COMPONENT_MATERIAL_DESC)
|
||||
})
|
||||
.cloned(),
|
||||
collider
|
||||
.filter(|_| inspector_component_active(order, COMPONENT_COLLIDER_DESC))
|
||||
.filter(|_| {
|
||||
authoring_component_active(states, order, COMPONENT_COLLIDER_DESC)
|
||||
})
|
||||
.cloned(),
|
||||
order.cloned(),
|
||||
)
|
||||
})
|
||||
.filter(|(_, _, _, _, order)| {
|
||||
inspector_component_active(order.as_ref(), COMPONENT_BRUSH_DESC)
|
||||
})
|
||||
.collect();
|
||||
|
||||
@ -153,7 +179,6 @@ pub fn flush_level_object_hydration(world: &mut World) {
|
||||
Option<MaterialDesc>,
|
||||
Option<MaterialOverride>,
|
||||
Option<ColliderDesc>,
|
||||
Option<InspectorOrder>,
|
||||
)> = world
|
||||
.query_filtered::<(
|
||||
Entity,
|
||||
@ -161,28 +186,41 @@ pub fn flush_level_object_hydration(world: &mut World) {
|
||||
Option<&MaterialDesc>,
|
||||
Option<&MaterialOverride>,
|
||||
Option<&ColliderDesc>,
|
||||
Option<&AuthoringComponentStates>,
|
||||
Option<&InspectorOrder>,
|
||||
), With<LevelObject>>()
|
||||
.iter(world)
|
||||
.map(
|
||||
|(entity, renderer, material, material_override, collider, order)| {
|
||||
.filter_map(
|
||||
|(entity, renderer, material, material_override, collider, states, order)| {
|
||||
authoring_component_active(states, order, COMPONENT_STATIC_MESH_RENDERER).then(
|
||||
|| {
|
||||
(
|
||||
entity,
|
||||
renderer.clone(),
|
||||
material
|
||||
.filter(|_| inspector_component_active(order, COMPONENT_MATERIAL_DESC))
|
||||
.filter(|_| {
|
||||
authoring_component_active(
|
||||
states,
|
||||
order,
|
||||
COMPONENT_MATERIAL_DESC,
|
||||
)
|
||||
})
|
||||
.cloned(),
|
||||
material_override.cloned(),
|
||||
collider
|
||||
.filter(|_| inspector_component_active(order, COMPONENT_COLLIDER_DESC))
|
||||
.filter(|_| {
|
||||
authoring_component_active(
|
||||
states,
|
||||
order,
|
||||
COMPONENT_COLLIDER_DESC,
|
||||
)
|
||||
})
|
||||
.cloned(),
|
||||
order.cloned(),
|
||||
)
|
||||
},
|
||||
)
|
||||
.filter(|(_, _, _, _, _, order)| {
|
||||
inspector_component_active(order.as_ref(), COMPONENT_STATIC_MESH_RENDERER)
|
||||
})
|
||||
},
|
||||
)
|
||||
.collect();
|
||||
|
||||
let mut generated_static_mesh_parts = Vec::new();
|
||||
@ -225,13 +263,18 @@ pub fn flush_level_object_hydration(world: &mut World) {
|
||||
.cloned();
|
||||
|
||||
let lights: Vec<(Entity, crate::LightDesc)> = world
|
||||
.query_filtered::<(Entity, &crate::LightDesc, Option<&InspectorOrder>), With<LevelObject>>()
|
||||
.query_filtered::<(
|
||||
Entity,
|
||||
&crate::LightDesc,
|
||||
Option<&AuthoringComponentStates>,
|
||||
Option<&InspectorOrder>,
|
||||
), With<LevelObject>>()
|
||||
.iter(world)
|
||||
.filter(|(_, light, order)| {
|
||||
inspector_component_active(*order, COMPONENT_LIGHT_DESC)
|
||||
.filter(|(_, light, states, order)| {
|
||||
authoring_component_active(*states, *order, COMPONENT_LIGHT_DESC)
|
||||
&& !lights::local_light_disabled_in_solari(light, profile.as_ref(), caps.as_ref())
|
||||
})
|
||||
.map(|(entity, light, _)| (entity, light.clone()))
|
||||
.map(|(entity, light, _, _)| (entity, light.clone()))
|
||||
.collect();
|
||||
|
||||
let mut state: SystemState<(
|
||||
@ -261,7 +304,7 @@ pub fn flush_level_object_hydration(world: &mut World) {
|
||||
commands.entity(child).despawn();
|
||||
}
|
||||
|
||||
for (entity, brush, material, collider, _) in &brush_targets {
|
||||
for (entity, brush, material, collider) in &brush_targets {
|
||||
spawn_brush_mesh(
|
||||
&mut commands,
|
||||
&asset_server,
|
||||
@ -278,7 +321,7 @@ pub fn flush_level_object_hydration(world: &mut World) {
|
||||
commands.entity(child).despawn();
|
||||
}
|
||||
|
||||
for (entity, renderer, material, material_override, collider, _) in &static_mesh_renderers {
|
||||
for (entity, renderer, material, material_override, collider) in &static_mesh_renderers {
|
||||
spawn_static_mesh_parts(
|
||||
&mut commands,
|
||||
&asset_server,
|
||||
|
||||
@ -4,23 +4,37 @@ use avian3d::prelude::*;
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::{
|
||||
inspector_component_active, AuthoringRigidBody, ColliderDesc, ColliderShapeDesc,
|
||||
InspectorOrder, LevelObject, PhysicsBody, RigidBodyDesc, COMPONENT_COLLIDER_DESC,
|
||||
COMPONENT_PHYSICS_BODY, COMPONENT_RIGID_BODY_DESC,
|
||||
authoring_component_active, AuthoringComponentStates, AuthoringRigidBody, ColliderDesc,
|
||||
ColliderShapeDesc, InspectorOrder, LevelObject, PhysicsBody, RigidBodyDesc,
|
||||
COMPONENT_COLLIDER_DESC, COMPONENT_PHYSICS_BODY, COMPONENT_RIGID_BODY_DESC,
|
||||
};
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub fn hydrate_physics(
|
||||
mut commands: Commands,
|
||||
physics: Query<
|
||||
(Entity, &PhysicsBody, Option<&InspectorOrder>),
|
||||
(
|
||||
Entity,
|
||||
&PhysicsBody,
|
||||
Option<&AuthoringComponentStates>,
|
||||
Option<&InspectorOrder>,
|
||||
),
|
||||
(
|
||||
With<LevelObject>,
|
||||
Or<(Changed<PhysicsBody>, Changed<InspectorOrder>)>,
|
||||
Or<(
|
||||
Changed<PhysicsBody>,
|
||||
Changed<AuthoringComponentStates>,
|
||||
Changed<InspectorOrder>,
|
||||
)>,
|
||||
),
|
||||
>,
|
||||
new_physics: Query<
|
||||
(Entity, &PhysicsBody, Option<&InspectorOrder>),
|
||||
(
|
||||
Entity,
|
||||
&PhysicsBody,
|
||||
Option<&AuthoringComponentStates>,
|
||||
Option<&InspectorOrder>,
|
||||
),
|
||||
(With<LevelObject>, Added<PhysicsBody>),
|
||||
>,
|
||||
split_physics: Query<
|
||||
@ -28,6 +42,7 @@ pub fn hydrate_physics(
|
||||
Entity,
|
||||
Option<&RigidBodyDesc>,
|
||||
Option<&ColliderDesc>,
|
||||
Option<&AuthoringComponentStates>,
|
||||
Option<&InspectorOrder>,
|
||||
),
|
||||
(
|
||||
@ -37,13 +52,14 @@ pub fn hydrate_physics(
|
||||
Added<RigidBodyDesc>,
|
||||
Changed<ColliderDesc>,
|
||||
Added<ColliderDesc>,
|
||||
Changed<AuthoringComponentStates>,
|
||||
Changed<InspectorOrder>,
|
||||
)>,
|
||||
),
|
||||
>,
|
||||
) {
|
||||
for (entity, body, order) in new_physics.iter().chain(physics.iter()) {
|
||||
if !inspector_component_active(order, COMPONENT_PHYSICS_BODY) {
|
||||
for (entity, body, states, legacy_order) in new_physics.iter().chain(physics.iter()) {
|
||||
if !authoring_component_active(states, legacy_order, COMPONENT_PHYSICS_BODY) {
|
||||
commands
|
||||
.entity(entity)
|
||||
.remove::<RigidBody>()
|
||||
@ -63,12 +79,12 @@ pub fn hydrate_physics(
|
||||
.insert((rigid_body, body.collider.clone()));
|
||||
}
|
||||
|
||||
for (entity, rigid_body, collider, order) in &split_physics {
|
||||
for (entity, rigid_body, collider, states, legacy_order) in &split_physics {
|
||||
let mut entity_commands = commands.entity(entity);
|
||||
entity_commands.remove::<RigidBody>().remove::<Collider>();
|
||||
|
||||
if let Some(rigid_body) =
|
||||
rigid_body.filter(|_| inspector_component_active(order, COMPONENT_RIGID_BODY_DESC))
|
||||
if let Some(rigid_body) = rigid_body
|
||||
.filter(|_| authoring_component_active(states, legacy_order, COMPONENT_RIGID_BODY_DESC))
|
||||
{
|
||||
entity_commands.insert(rigid_body_from_desc(rigid_body.body));
|
||||
}
|
||||
@ -76,7 +92,7 @@ pub fn hydrate_physics(
|
||||
let Some(collider) = collider else {
|
||||
continue;
|
||||
};
|
||||
if !inspector_component_active(order, COMPONENT_COLLIDER_DESC) {
|
||||
if !authoring_component_active(states, legacy_order, COMPONENT_COLLIDER_DESC) {
|
||||
continue;
|
||||
}
|
||||
if !collider.enabled || matches!(collider.shape, ColliderShapeDesc::StaticMesh { .. }) {
|
||||
|
||||
@ -3,8 +3,8 @@
|
||||
use bevy::prelude::*;
|
||||
|
||||
use crate::{
|
||||
inspector_component_active, InspectorOrder, LevelObject, Primitive, PrimitiveShape,
|
||||
COMPONENT_PRIMITIVE,
|
||||
authoring_component_active, AuthoringComponentStates, InspectorOrder, LevelObject, Primitive,
|
||||
PrimitiveShape, COMPONENT_PRIMITIVE,
|
||||
};
|
||||
|
||||
pub(crate) fn primitive_mesh(primitive: &Primitive) -> Mesh {
|
||||
@ -32,20 +32,26 @@ pub(crate) fn primitive_mesh(primitive: &Primitive) -> Mesh {
|
||||
pub fn hydrate_primitives(
|
||||
mut commands: Commands,
|
||||
primitives: Query<
|
||||
(Entity, &Primitive, Option<&InspectorOrder>),
|
||||
(
|
||||
Entity,
|
||||
&Primitive,
|
||||
Option<&AuthoringComponentStates>,
|
||||
Option<&InspectorOrder>,
|
||||
),
|
||||
(
|
||||
With<LevelObject>,
|
||||
Or<(
|
||||
Added<Primitive>,
|
||||
Changed<Primitive>,
|
||||
Changed<AuthoringComponentStates>,
|
||||
Changed<InspectorOrder>,
|
||||
)>,
|
||||
),
|
||||
>,
|
||||
mut meshes: ResMut<Assets<Mesh>>,
|
||||
) {
|
||||
for (entity, primitive, order) in &primitives {
|
||||
if !inspector_component_active(order, COMPONENT_PRIMITIVE) {
|
||||
for (entity, primitive, states, legacy_order) in &primitives {
|
||||
if !authoring_component_active(states, legacy_order, COMPONENT_PRIMITIVE) {
|
||||
commands
|
||||
.entity(entity)
|
||||
.remove::<Mesh3d>()
|
||||
|
||||
473
crates/shared/src/hydration/skinned_meshes.rs
Normal file
473
crates/shared/src/hydration/skinned_meshes.rs
Normal file
@ -0,0 +1,473 @@
|
||||
//! Dedicated scene hydration for rigged and skinned model geometry.
|
||||
|
||||
use std::fs;
|
||||
|
||||
use bevy::gltf::GltfAssetLabel;
|
||||
use bevy::prelude::WorldAssetRoot;
|
||||
use bevy::prelude::*;
|
||||
use bevy::world_serialization::WorldInstanceReady;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{
|
||||
asset_server_path, authoring_component_active, AuthoringComponentStates,
|
||||
HydratedRendererMaterialBinding, InspectorOrder, LevelObject, SkinnedMeshRenderer,
|
||||
ANIMATION_ARTIFACT_DIR, COMPONENT_SKINNED_MESH_RENDERER,
|
||||
};
|
||||
|
||||
use super::static_meshes::{
|
||||
renderer_error_material, resolve_renderer_material_handle, StaticMeshArtifactCache,
|
||||
};
|
||||
|
||||
/// Runtime hierarchy root generated for one authored [`SkinnedMeshRenderer`].
|
||||
///
|
||||
/// The loaded world contains Bevy's actual skinned-mesh bindings and joints. Keeping this marker
|
||||
/// distinct from `HydratedModelRoot` prevents animation systems from binding to generic imported
|
||||
/// scene instances.
|
||||
#[derive(Component, Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct HydratedSkinnedMeshRoot {
|
||||
pub owner: Entity,
|
||||
}
|
||||
|
||||
#[derive(Component, Debug, Clone, PartialEq, Eq)]
|
||||
pub(super) struct HydratedSkinnedMeshSource {
|
||||
asset_id: String,
|
||||
path: String,
|
||||
scene_index: usize,
|
||||
}
|
||||
|
||||
impl From<&SkinnedMeshRenderer> for HydratedSkinnedMeshSource {
|
||||
fn from(renderer: &SkinnedMeshRenderer) -> Self {
|
||||
Self {
|
||||
asset_id: renderer.asset_id.clone(),
|
||||
path: renderer.path.clone(),
|
||||
scene_index: renderer.scene_index,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Component, Default)]
|
||||
pub(super) struct SkinnedMaterialsDirty;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AnimationArtifactLookup {
|
||||
source: AnimationSourceLookup,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AnimationSourceLookup {
|
||||
path: String,
|
||||
}
|
||||
|
||||
pub fn skinned_mesh_manifest_path(asset_id: &str) -> String {
|
||||
format!("{ANIMATION_ARTIFACT_DIR}/{asset_id}.animation.ron")
|
||||
}
|
||||
|
||||
/// Resolves the current model source from the animation artifact rather than the static-mesh
|
||||
/// artifact. The authored cached path remains the fallback when a legacy/unindexed asset has no
|
||||
/// generated animation metadata.
|
||||
pub fn skinned_mesh_source_path(asset_id: &str) -> Result<String, String> {
|
||||
let path = skinned_mesh_manifest_path(asset_id);
|
||||
let text = fs::read_to_string(&path)
|
||||
.map_err(|error| format!("could not read skinned mesh artifact {path}: {error}"))?;
|
||||
let artifact: AnimationArtifactLookup = ron::from_str(&text)
|
||||
.map_err(|error| format!("could not parse skinned mesh artifact {path}: {error}"))?;
|
||||
Ok(artifact.source.path)
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
pub(super) fn hydrate_skinned_mesh_renderers(
|
||||
mut commands: Commands,
|
||||
asset_server: Res<AssetServer>,
|
||||
renderers: Query<
|
||||
(
|
||||
Entity,
|
||||
&SkinnedMeshRenderer,
|
||||
Option<&AuthoringComponentStates>,
|
||||
Option<&InspectorOrder>,
|
||||
),
|
||||
(
|
||||
With<LevelObject>,
|
||||
Or<(
|
||||
Added<SkinnedMeshRenderer>,
|
||||
Changed<SkinnedMeshRenderer>,
|
||||
Changed<AuthoringComponentStates>,
|
||||
Changed<InspectorOrder>,
|
||||
)>,
|
||||
),
|
||||
>,
|
||||
generated_roots: Query<(
|
||||
Entity,
|
||||
&HydratedSkinnedMeshRoot,
|
||||
Option<&HydratedSkinnedMeshSource>,
|
||||
)>,
|
||||
) {
|
||||
for (entity, renderer, states, legacy_order) in &renderers {
|
||||
if !authoring_component_active(states, legacy_order, COMPONENT_SKINNED_MESH_RENDERER) {
|
||||
for (root, generated, _) in &generated_roots {
|
||||
if generated.owner == entity {
|
||||
commands.entity(root).despawn();
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let requested_source = HydratedSkinnedMeshSource::from(renderer);
|
||||
let mut matching_root = None;
|
||||
for (root, generated, source) in &generated_roots {
|
||||
if generated.owner == entity {
|
||||
if source == Some(&requested_source) {
|
||||
matching_root = Some(root);
|
||||
} else {
|
||||
commands.entity(root).despawn();
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(root) = matching_root {
|
||||
commands.entity(root).insert(SkinnedMaterialsDirty);
|
||||
continue;
|
||||
}
|
||||
|
||||
let resolved_path = if renderer.asset_id.trim().is_empty() {
|
||||
renderer.path.clone()
|
||||
} else {
|
||||
match skinned_mesh_source_path(&renderer.asset_id) {
|
||||
Ok(path) => path,
|
||||
Err(error) => {
|
||||
warn!(
|
||||
"SkinnedMeshRenderer {entity} could not resolve stable asset {}: {error}; falling back to cached path {}",
|
||||
renderer.asset_id, renderer.path
|
||||
);
|
||||
renderer.path.clone()
|
||||
}
|
||||
}
|
||||
};
|
||||
let path = asset_server_path(&resolved_path);
|
||||
let handle = if path.to_ascii_lowercase().ends_with(".fbx") {
|
||||
asset_server.load(SkinnedMeshRenderer::fbx_scene_asset_path(
|
||||
&path,
|
||||
renderer.scene_index,
|
||||
))
|
||||
} else {
|
||||
asset_server.load(GltfAssetLabel::Scene(renderer.scene_index).from_asset(path))
|
||||
};
|
||||
commands.spawn((
|
||||
Name::new("Hydrated Skinned Mesh Root"),
|
||||
HydratedSkinnedMeshRoot { owner: entity },
|
||||
requested_source,
|
||||
WorldAssetRoot(handle),
|
||||
ChildOf(entity),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Defers material application until the imported world has produced its actual draw entities.
|
||||
pub(super) fn mark_skinned_materials_ready(
|
||||
ready: On<WorldInstanceReady>,
|
||||
roots: Query<(), With<HydratedSkinnedMeshRoot>>,
|
||||
mut commands: Commands,
|
||||
) {
|
||||
if roots.get(ready.entity).is_ok() {
|
||||
commands.entity(ready.entity).insert(SkinnedMaterialsDirty);
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(super) fn apply_skinned_renderer_material_changes(
|
||||
mut commands: Commands,
|
||||
asset_server: Res<AssetServer>,
|
||||
mut materials: ResMut<Assets<StandardMaterial>>,
|
||||
mut artifact_cache: ResMut<StaticMeshArtifactCache>,
|
||||
dirty_roots: Query<(Entity, &HydratedSkinnedMeshRoot), With<SkinnedMaterialsDirty>>,
|
||||
renderers: Query<&SkinnedMeshRenderer>,
|
||||
children: Query<&Children>,
|
||||
parents: Query<&ChildOf>,
|
||||
names: Query<&Name>,
|
||||
meshes: Query<(Entity, &Mesh3d)>,
|
||||
) {
|
||||
for (root_entity, root) in &dirty_roots {
|
||||
let Ok(renderer) = renderers.get(root.owner) else {
|
||||
commands
|
||||
.entity(root_entity)
|
||||
.remove::<SkinnedMaterialsDirty>();
|
||||
continue;
|
||||
};
|
||||
let descendants = collect_descendants(root_entity, &children);
|
||||
let mut candidates = Vec::new();
|
||||
for entity in descendants {
|
||||
let Ok((_, mesh)) = meshes.get(entity) else {
|
||||
continue;
|
||||
};
|
||||
let label = asset_server
|
||||
.get_path(mesh.id())
|
||||
.and_then(|path| path.label().map(str::to_string));
|
||||
let hierarchy_path = hierarchy_path(entity, root_entity, &parents, &names);
|
||||
candidates.push((entity, label, hierarchy_path));
|
||||
}
|
||||
|
||||
let mut used = std::collections::HashSet::new();
|
||||
for slot in &renderer.materials.slots {
|
||||
let Some(binding) = artifact_cache.resolve_draw_binding(&renderer.asset_id, &slot.id)
|
||||
else {
|
||||
warn!(
|
||||
"Skinned material slot '{}' has no generated draw binding for asset {}",
|
||||
slot.id.0, renderer.asset_id
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let matched = candidates
|
||||
.iter()
|
||||
.filter(|(entity, label, _)| {
|
||||
!used.contains(entity) && label.as_deref() == Some(binding.mesh_label.as_str())
|
||||
})
|
||||
.find(|(_, _, path)| {
|
||||
binding
|
||||
.source_node
|
||||
.as_deref()
|
||||
.is_none_or(|source| path.ends_with(source))
|
||||
})
|
||||
.or_else(|| {
|
||||
candidates.iter().find(|(entity, label, _)| {
|
||||
!used.contains(entity)
|
||||
&& label.as_deref() == Some(binding.mesh_label.as_str())
|
||||
})
|
||||
});
|
||||
let Some((entity, _, _)) = matched else {
|
||||
warn!(
|
||||
"Skinned material slot '{}' could not find hydrated mesh {} ({:?})",
|
||||
slot.id.0, binding.mesh_label, binding.source_node
|
||||
);
|
||||
continue;
|
||||
};
|
||||
used.insert(*entity);
|
||||
let effective = slot.effective_material().cloned();
|
||||
let Some(reference) = effective.as_ref() else {
|
||||
commands
|
||||
.entity(*entity)
|
||||
.insert(HydratedRendererMaterialBinding {
|
||||
owner: root.owner,
|
||||
slot_id: slot.id.clone(),
|
||||
effective_material: None,
|
||||
});
|
||||
continue;
|
||||
};
|
||||
let handle = resolve_renderer_material_handle(
|
||||
&asset_server,
|
||||
&mut materials,
|
||||
&mut artifact_cache,
|
||||
reference,
|
||||
)
|
||||
.unwrap_or_else(|| renderer_error_material(&mut materials));
|
||||
commands.entity(*entity).insert((
|
||||
MeshMaterial3d(handle),
|
||||
HydratedRendererMaterialBinding {
|
||||
owner: root.owner,
|
||||
slot_id: slot.id.clone(),
|
||||
effective_material: effective,
|
||||
},
|
||||
));
|
||||
}
|
||||
commands
|
||||
.entity(root_entity)
|
||||
.remove::<SkinnedMaterialsDirty>();
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_descendants(root: Entity, children: &Query<&Children>) -> Vec<Entity> {
|
||||
let mut result = Vec::new();
|
||||
let mut pending = vec![root];
|
||||
while let Some(entity) = pending.pop() {
|
||||
if entity != root {
|
||||
result.push(entity);
|
||||
}
|
||||
if let Ok(entity_children) = children.get(entity) {
|
||||
pending.extend(entity_children.iter());
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn hierarchy_path(
|
||||
mut entity: Entity,
|
||||
root: Entity,
|
||||
parents: &Query<&ChildOf>,
|
||||
names: &Query<&Name>,
|
||||
) -> String {
|
||||
let mut segments = Vec::new();
|
||||
while entity != root {
|
||||
if let Ok(name) = names.get(entity) {
|
||||
segments.push(name.as_str().to_string());
|
||||
}
|
||||
let Ok(parent) = parents.get(entity) else {
|
||||
break;
|
||||
};
|
||||
entity = parent.parent();
|
||||
}
|
||||
segments.reverse();
|
||||
segments.join("/")
|
||||
}
|
||||
|
||||
/// Removes generated roots if the authoring renderer is removed without another renderer change.
|
||||
pub(super) fn cleanup_removed_skinned_mesh_renderers(
|
||||
mut commands: Commands,
|
||||
renderers: Query<
|
||||
(Option<&AuthoringComponentStates>, Option<&InspectorOrder>),
|
||||
With<SkinnedMeshRenderer>,
|
||||
>,
|
||||
roots: Query<(Entity, &HydratedSkinnedMeshRoot)>,
|
||||
) {
|
||||
for (root_entity, root) in &roots {
|
||||
let active = renderers
|
||||
.get(root.owner)
|
||||
.is_ok_and(|(states, legacy_order)| {
|
||||
authoring_component_active(states, legacy_order, COMPONENT_SKINNED_MESH_RENDERER)
|
||||
});
|
||||
if !active {
|
||||
commands.entity(root_entity).despawn();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use bevy::asset::{AssetApp, AssetPlugin};
|
||||
use bevy::prelude::WorldAsset;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
struct TestManifest(PathBuf);
|
||||
|
||||
impl Drop for TestManifest {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_file(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn write_test_manifest(asset_id: &str, source_path: &str) -> TestManifest {
|
||||
let path = PathBuf::from(skinned_mesh_manifest_path(asset_id));
|
||||
std::fs::create_dir_all(path.parent().expect("manifest has a parent directory"))
|
||||
.expect("generated animation directory should be writable");
|
||||
std::fs::write(&path, format!(r#"(source: (path: "{source_path}"))"#))
|
||||
.expect("test animation manifest should be writable");
|
||||
TestManifest(path)
|
||||
}
|
||||
|
||||
fn hydration_app() -> App {
|
||||
let mut app = App::new();
|
||||
app.add_plugins((MinimalPlugins, AssetPlugin::default()))
|
||||
.init_asset::<WorldAsset>()
|
||||
.add_systems(
|
||||
Update,
|
||||
(
|
||||
hydrate_skinned_mesh_renderers,
|
||||
cleanup_removed_skinned_mesh_renderers,
|
||||
)
|
||||
.chain(),
|
||||
);
|
||||
app
|
||||
}
|
||||
|
||||
fn generated_roots(world: &mut World, owner: Entity) -> Vec<Entity> {
|
||||
world
|
||||
.query::<(Entity, &HydratedSkinnedMeshRoot)>()
|
||||
.iter(world)
|
||||
.filter_map(|(entity, root)| (root.owner == owner).then_some(entity))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renderer_hydrates_a_dedicated_root_and_preserves_authored_children() {
|
||||
let mut app = hydration_app();
|
||||
let owner = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
LevelObject,
|
||||
SkinnedMeshRenderer::new("assets/models/character.glb"),
|
||||
))
|
||||
.id();
|
||||
let authored_child = app
|
||||
.world_mut()
|
||||
.spawn((Name::new("Socket"), ChildOf(owner)))
|
||||
.id();
|
||||
|
||||
app.update();
|
||||
|
||||
assert_eq!(generated_roots(app.world_mut(), owner).len(), 1);
|
||||
assert!(app.world().get_entity(authored_child).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stable_asset_id_uses_animation_artifact_source() {
|
||||
const ASSET_ID: &str = "fe1ea62f-9ca8-40c6-a747-a1f40567b8c6";
|
||||
let _manifest = write_test_manifest(ASSET_ID, "assets/models/relocated/hero.glb");
|
||||
let mut app = hydration_app();
|
||||
let owner = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
LevelObject,
|
||||
SkinnedMeshRenderer::new("assets/models/stale.glb").with_asset_id(ASSET_ID),
|
||||
))
|
||||
.id();
|
||||
|
||||
app.update();
|
||||
|
||||
let root_entity = generated_roots(app.world_mut(), owner)[0];
|
||||
let root = app.world().get::<WorldAssetRoot>(root_entity).unwrap();
|
||||
let loaded_path = app
|
||||
.world()
|
||||
.resource::<AssetServer>()
|
||||
.get_path(root.0.id())
|
||||
.unwrap();
|
||||
assert_eq!(loaded_path.path(), Path::new("models/relocated/hero.glb"));
|
||||
assert_eq!(loaded_path.label(), Some("Scene0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn removing_renderer_despawns_only_its_generated_root() {
|
||||
let mut app = hydration_app();
|
||||
let owner = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
LevelObject,
|
||||
SkinnedMeshRenderer::new("assets/models/character.glb"),
|
||||
))
|
||||
.id();
|
||||
app.update();
|
||||
let root = generated_roots(app.world_mut(), owner)[0];
|
||||
|
||||
app.world_mut()
|
||||
.entity_mut(owner)
|
||||
.remove::<SkinnedMeshRenderer>();
|
||||
app.update();
|
||||
|
||||
assert!(app.world().get_entity(root).is_err());
|
||||
assert!(app.world().get_entity(owner).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabling_renderer_removes_hydrated_root_and_reenable_restores_it() {
|
||||
let mut app = hydration_app();
|
||||
let owner = app
|
||||
.world_mut()
|
||||
.spawn((
|
||||
LevelObject,
|
||||
SkinnedMeshRenderer::new("assets/models/character.glb"),
|
||||
AuthoringComponentStates::default(),
|
||||
))
|
||||
.id();
|
||||
app.update();
|
||||
assert_eq!(generated_roots(app.world_mut(), owner).len(), 1);
|
||||
|
||||
app.world_mut()
|
||||
.get_mut::<AuthoringComponentStates>(owner)
|
||||
.unwrap()
|
||||
.set_component_active(COMPONENT_SKINNED_MESH_RENDERER, false);
|
||||
app.update();
|
||||
assert!(generated_roots(app.world_mut(), owner).is_empty());
|
||||
|
||||
app.world_mut()
|
||||
.get_mut::<AuthoringComponentStates>(owner)
|
||||
.unwrap()
|
||||
.set_component_active(COMPONENT_SKINNED_MESH_RENDERER, true);
|
||||
app.update();
|
||||
assert_eq!(generated_roots(app.world_mut(), owner).len(), 1);
|
||||
}
|
||||
}
|
||||
@ -3,13 +3,16 @@
|
||||
use avian3d::prelude::ColliderConstructor;
|
||||
use bevy::light::{NotShadowCaster, NotShadowReceiver};
|
||||
use bevy::prelude::*;
|
||||
use bevy::render::render_resource::Face;
|
||||
use serde::Deserialize;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use crate::{
|
||||
asset_server_path, inspector_component_active, standard_material_asset_path, ColliderDesc,
|
||||
ColliderShapeDesc, EditorAssetRef, InspectorOrder, LevelObject, MaterialDesc, MaterialOverride,
|
||||
StaticMeshRenderer, StaticMeshRendererEntry, COMPONENT_COLLIDER_DESC, COMPONENT_MATERIAL_DESC,
|
||||
asset_server_path, authoring_component_active, standard_material_asset_path,
|
||||
AuthoringComponentStates, ColliderDesc, ColliderShapeDesc, EditorAssetRef,
|
||||
HydratedRendererMaterialBinding, InspectorOrder, LevelObject, MaterialAlphaMode, MaterialAsset,
|
||||
MaterialDesc, MaterialInstanceAsset, MaterialOverride, MaterialRef, StaticMeshRenderer,
|
||||
StaticMeshRendererEntry, COMPONENT_COLLIDER_DESC, COMPONENT_MATERIAL_DESC,
|
||||
COMPONENT_STATIC_MESH_RENDERER,
|
||||
};
|
||||
|
||||
@ -35,16 +38,39 @@ struct ResolvedStaticMeshMaterial {
|
||||
material_label: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct ResolvedRendererDraw {
|
||||
pub mesh_label: String,
|
||||
pub source_node: Option<String>,
|
||||
}
|
||||
|
||||
/// Cache for generated static mesh artifact lookups.
|
||||
#[derive(Resource, Default)]
|
||||
pub struct StaticMeshArtifactCache {
|
||||
parts: HashMap<(String, String), ResolvedStaticMeshPart>,
|
||||
materials: HashMap<(String, String), ResolvedStaticMeshMaterial>,
|
||||
draw_bindings: HashMap<(String, String), ResolvedRendererDraw>,
|
||||
loaded_assets: HashSet<String>,
|
||||
failed_assets: HashSet<String>,
|
||||
project_materials: HashMap<MaterialRef, Handle<StandardMaterial>>,
|
||||
}
|
||||
|
||||
impl StaticMeshArtifactCache {
|
||||
pub(crate) fn resolve_draw_binding(
|
||||
&mut self,
|
||||
asset_id: &str,
|
||||
slot_id: &crate::ComponentInstanceId,
|
||||
) -> Option<ResolvedRendererDraw> {
|
||||
let key = (asset_id.to_string(), slot_id.0.clone());
|
||||
if let Some(binding) = self.draw_bindings.get(&key) {
|
||||
return Some(binding.clone());
|
||||
}
|
||||
if !self.loaded_assets.contains(asset_id) && !self.failed_assets.contains(asset_id) {
|
||||
self.load_artifact(asset_id);
|
||||
}
|
||||
self.draw_bindings.get(&key).cloned()
|
||||
}
|
||||
|
||||
fn resolve(&mut self, mesh: &EditorAssetRef) -> Option<ResolvedStaticMeshPart> {
|
||||
if !mesh.is_resolved() {
|
||||
return None;
|
||||
@ -95,17 +121,18 @@ impl StaticMeshArtifactCache {
|
||||
} else {
|
||||
part.id.clone()
|
||||
};
|
||||
self.parts.insert(
|
||||
(asset_id.to_string(), part_id),
|
||||
ResolvedStaticMeshPart {
|
||||
source_path: manifest.source.path.clone(),
|
||||
let slot_id = format!("slot:{part_id}");
|
||||
self.draw_bindings.insert(
|
||||
(asset_id.to_string(), slot_id),
|
||||
ResolvedRendererDraw {
|
||||
mesh_label: part.mesh_label.clone(),
|
||||
material_label: part.material_label.clone(),
|
||||
source_node: part.source_node.clone(),
|
||||
},
|
||||
);
|
||||
if let Some(material_label) = part.material_label {
|
||||
if let Some(material_label) = part.material_label.clone() {
|
||||
let material_id = part
|
||||
.material_id
|
||||
.clone()
|
||||
.filter(|id| !id.trim().is_empty())
|
||||
.unwrap_or_else(|| material_id_from_label(&material_label));
|
||||
self.materials
|
||||
@ -115,6 +142,24 @@ impl StaticMeshArtifactCache {
|
||||
material_label,
|
||||
});
|
||||
}
|
||||
if part.skinned
|
||||
|| manifest.metadata.animation_count > 0
|
||||
|| manifest.metadata.skin_count > 0
|
||||
{
|
||||
warn!(
|
||||
"Static mesh artifact part {} requires an animated/skinned hierarchy and was excluded from StaticMeshRenderer hydration",
|
||||
part.mesh_label
|
||||
);
|
||||
continue;
|
||||
}
|
||||
self.parts.insert(
|
||||
(asset_id.to_string(), part_id),
|
||||
ResolvedStaticMeshPart {
|
||||
source_path: manifest.source.path.clone(),
|
||||
mesh_label: part.mesh_label.clone(),
|
||||
material_label: part.material_label.clone(),
|
||||
},
|
||||
);
|
||||
}
|
||||
self.loaded_assets.insert(asset_id.to_string());
|
||||
}
|
||||
@ -130,9 +175,19 @@ impl StaticMeshArtifactCache {
|
||||
struct StaticMeshResolveManifest {
|
||||
source: StaticMeshResolveSource,
|
||||
#[serde(default)]
|
||||
metadata: StaticMeshResolveMetadata,
|
||||
#[serde(default)]
|
||||
parts: Vec<StaticMeshResolvePart>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct StaticMeshResolveMetadata {
|
||||
#[serde(default)]
|
||||
animation_count: usize,
|
||||
#[serde(default)]
|
||||
skin_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct StaticMeshResolveSource {
|
||||
path: String,
|
||||
@ -147,6 +202,10 @@ struct StaticMeshResolvePart {
|
||||
material_id: Option<String>,
|
||||
#[serde(default)]
|
||||
material_label: Option<String>,
|
||||
#[serde(default)]
|
||||
source_node: Option<String>,
|
||||
#[serde(default)]
|
||||
skinned: bool,
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
@ -162,6 +221,7 @@ pub fn hydrate_static_mesh_renderers(
|
||||
Option<&MaterialDesc>,
|
||||
Option<&MaterialOverride>,
|
||||
Option<&ColliderDesc>,
|
||||
Option<&AuthoringComponentStates>,
|
||||
Option<&InspectorOrder>,
|
||||
),
|
||||
(
|
||||
@ -172,6 +232,7 @@ pub fn hydrate_static_mesh_renderers(
|
||||
Changed<MaterialDesc>,
|
||||
Changed<MaterialOverride>,
|
||||
Changed<ColliderDesc>,
|
||||
Changed<AuthoringComponentStates>,
|
||||
Changed<InspectorOrder>,
|
||||
)>,
|
||||
),
|
||||
@ -179,15 +240,17 @@ pub fn hydrate_static_mesh_renderers(
|
||||
children: Query<&Children>,
|
||||
generated_parts: Query<(), With<HydratedStaticMeshPart>>,
|
||||
) {
|
||||
for (entity, renderer, parent_material, material_override, collider, order) in &renderers {
|
||||
for (entity, renderer, parent_material, material_override, collider, states, order) in
|
||||
&renderers
|
||||
{
|
||||
despawn_static_mesh_parts(&mut commands, entity, &children, &generated_parts);
|
||||
if !inspector_component_active(order, COMPONENT_STATIC_MESH_RENDERER) {
|
||||
if !authoring_component_active(states, order, COMPONENT_STATIC_MESH_RENDERER) {
|
||||
continue;
|
||||
}
|
||||
let parent_material =
|
||||
parent_material.filter(|_| inspector_component_active(order, COMPONENT_MATERIAL_DESC));
|
||||
let parent_material = parent_material
|
||||
.filter(|_| authoring_component_active(states, order, COMPONENT_MATERIAL_DESC));
|
||||
let collider =
|
||||
collider.filter(|_| inspector_component_active(order, COMPONENT_COLLIDER_DESC));
|
||||
collider.filter(|_| authoring_component_active(states, order, COMPONENT_COLLIDER_DESC));
|
||||
spawn_static_mesh_parts(
|
||||
&mut commands,
|
||||
&asset_server,
|
||||
@ -237,6 +300,7 @@ pub fn spawn_static_mesh_parts(
|
||||
asset_server,
|
||||
materials,
|
||||
artifact_cache,
|
||||
renderer,
|
||||
entry,
|
||||
&resolved,
|
||||
parent_material,
|
||||
@ -253,6 +317,11 @@ pub fn spawn_static_mesh_parts(
|
||||
Name::new(name),
|
||||
Mesh3d(mesh_handle),
|
||||
MeshMaterial3d(material_handle),
|
||||
HydratedRendererMaterialBinding {
|
||||
owner: parent,
|
||||
slot_id: effective_material_slot_id(entry),
|
||||
effective_material: effective_material_ref(renderer, entry).cloned(),
|
||||
},
|
||||
entry.local_transform,
|
||||
Visibility::Visible,
|
||||
ChildOf(parent),
|
||||
@ -296,6 +365,7 @@ fn material_for_entry(
|
||||
asset_server: &AssetServer,
|
||||
materials: &mut Assets<StandardMaterial>,
|
||||
artifact_cache: &mut StaticMeshArtifactCache,
|
||||
renderer: &StaticMeshRenderer,
|
||||
entry: &StaticMeshRendererEntry,
|
||||
resolved: &ResolvedStaticMeshPart,
|
||||
parent_material: Option<&MaterialDesc>,
|
||||
@ -315,8 +385,13 @@ fn material_for_entry(
|
||||
return materials.add(material_from_desc(asset_server, override_material));
|
||||
}
|
||||
|
||||
if let Some(material_ref) = entry.material.as_ref() {
|
||||
if let Some(resolved_material) = artifact_cache.resolve_material(material_ref) {
|
||||
if let Some(material_ref) = effective_material_ref(renderer, entry) {
|
||||
if let Some(handle) =
|
||||
resolve_shared_material(asset_server, materials, artifact_cache, material_ref)
|
||||
{
|
||||
return handle;
|
||||
}
|
||||
if let Some(resolved_material) = artifact_cache.resolve_material(&material_ref.0) {
|
||||
let source_path = asset_server_path(&resolved_material.source_path);
|
||||
return asset_server.load(standard_material_asset_path(
|
||||
&source_path,
|
||||
@ -325,16 +400,121 @@ fn material_for_entry(
|
||||
}
|
||||
debug!(
|
||||
"Static mesh material ref unresolved: asset_id='{}' material='{}' label='{}'",
|
||||
material_ref.asset_id, material_ref.sub_asset_id, material_ref.label
|
||||
material_ref.0.asset_id, material_ref.0.sub_asset_id, material_ref.0.label
|
||||
);
|
||||
}
|
||||
|
||||
// Read-only compatibility path for schema-v1/v2 scenes. The explicit project upgrader moves
|
||||
// this reference into `RendererMaterialSet` and clears the legacy draw-part field.
|
||||
if let Some(material_ref) = entry.material.as_ref() {
|
||||
let material_ref = MaterialRef::new(material_ref.clone());
|
||||
if let Some(handle) =
|
||||
resolve_shared_material(asset_server, materials, artifact_cache, &material_ref)
|
||||
{
|
||||
return handle;
|
||||
}
|
||||
if let Some(resolved_material) = artifact_cache.resolve_material(&material_ref.0) {
|
||||
let source_path = asset_server_path(&resolved_material.source_path);
|
||||
return asset_server.load(standard_material_asset_path(
|
||||
&source_path,
|
||||
&resolved_material.material_label,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(label) = resolved.material_label.as_deref() {
|
||||
let source_path = asset_server_path(&resolved.source_path);
|
||||
return asset_server.load(standard_material_asset_path(&source_path, label));
|
||||
}
|
||||
|
||||
materials.add(StandardMaterial::default())
|
||||
materials.add(error_material())
|
||||
}
|
||||
|
||||
fn effective_material_slot_id(entry: &StaticMeshRendererEntry) -> crate::ComponentInstanceId {
|
||||
if entry.material_slot_id.0.trim().is_empty() {
|
||||
entry.id.clone()
|
||||
} else {
|
||||
entry.material_slot_id.clone()
|
||||
}
|
||||
}
|
||||
|
||||
fn effective_material_ref<'a>(
|
||||
renderer: &'a StaticMeshRenderer,
|
||||
entry: &'a StaticMeshRendererEntry,
|
||||
) -> Option<&'a MaterialRef> {
|
||||
let slot_id = effective_material_slot_id(entry);
|
||||
renderer.materials.effective_material(&slot_id)
|
||||
}
|
||||
|
||||
fn resolve_shared_material(
|
||||
asset_server: &AssetServer,
|
||||
materials: &mut Assets<StandardMaterial>,
|
||||
cache: &mut StaticMeshArtifactCache,
|
||||
reference: &MaterialRef,
|
||||
) -> Option<Handle<StandardMaterial>> {
|
||||
if let Some(handle) = cache.project_materials.get(reference) {
|
||||
return Some(handle.clone());
|
||||
}
|
||||
let path = reference.0.source_path.as_deref()?;
|
||||
let desc_and_state = MaterialAsset::load_from_path(path)
|
||||
.map(|asset| (asset.material, asset.render_state))
|
||||
.or_else(|_| {
|
||||
let instance = MaterialInstanceAsset::load_from_path(path)?;
|
||||
let base_path = instance.base.0.source_path.as_deref().ok_or_else(|| {
|
||||
format!(
|
||||
"material instance {} has no loadable base path",
|
||||
instance.label
|
||||
)
|
||||
})?;
|
||||
let base = MaterialAsset::load_from_path(base_path)?;
|
||||
let mut desc = base.material;
|
||||
instance.apply_to(&mut desc);
|
||||
Ok::<_, String>((desc, base.render_state))
|
||||
})
|
||||
.ok()?;
|
||||
let mut material = material_from_desc(asset_server, &desc_and_state.0);
|
||||
material.alpha_mode = match desc_and_state.1.alpha_mode {
|
||||
MaterialAlphaMode::Opaque => AlphaMode::Opaque,
|
||||
MaterialAlphaMode::Cutout => AlphaMode::Mask(desc_and_state.1.alpha_cutoff),
|
||||
};
|
||||
material.cull_mode = (!desc_and_state.1.double_sided).then_some(Face::Back);
|
||||
let handle = materials.add(material);
|
||||
cache
|
||||
.project_materials
|
||||
.insert(reference.clone(), handle.clone());
|
||||
Some(handle)
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_renderer_material_handle(
|
||||
asset_server: &AssetServer,
|
||||
materials: &mut Assets<StandardMaterial>,
|
||||
cache: &mut StaticMeshArtifactCache,
|
||||
reference: &MaterialRef,
|
||||
) -> Option<Handle<StandardMaterial>> {
|
||||
if let Some(handle) = resolve_shared_material(asset_server, materials, cache, reference) {
|
||||
return Some(handle);
|
||||
}
|
||||
let resolved = cache.resolve_material(&reference.0)?;
|
||||
let source_path = asset_server_path(&resolved.source_path);
|
||||
Some(asset_server.load(standard_material_asset_path(
|
||||
&source_path,
|
||||
&resolved.material_label,
|
||||
)))
|
||||
}
|
||||
|
||||
pub(crate) fn renderer_error_material(
|
||||
materials: &mut Assets<StandardMaterial>,
|
||||
) -> Handle<StandardMaterial> {
|
||||
materials.add(error_material())
|
||||
}
|
||||
|
||||
fn error_material() -> StandardMaterial {
|
||||
StandardMaterial {
|
||||
base_color: Color::srgb(1.0, 0.0, 1.0),
|
||||
perceptual_roughness: 0.35,
|
||||
metallic: 0.0,
|
||||
..default()
|
||||
}
|
||||
}
|
||||
|
||||
fn labeled_asset_path(path: &str, label: &str) -> String {
|
||||
@ -392,3 +572,58 @@ fn stable_sub_asset_slug(label: &str) -> String {
|
||||
}
|
||||
slug.trim_matches('_').to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
struct TestManifest(PathBuf);
|
||||
|
||||
impl Drop for TestManifest {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_file(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn write_manifest(asset_id: &str, animation_count: usize, skinned: bool) -> TestManifest {
|
||||
let path = PathBuf::from(static_mesh_manifest_path(asset_id));
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(
|
||||
&path,
|
||||
format!(
|
||||
r#"(
|
||||
source: (path: "assets/models/test.glb"),
|
||||
metadata: (animation_count: {animation_count}),
|
||||
parts: [(
|
||||
id: "mesh:test",
|
||||
mesh_label: "Mesh0/Primitive0",
|
||||
skinned: {skinned},
|
||||
)],
|
||||
)"#
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
TestManifest(path)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_refuses_skin_bound_parts() {
|
||||
const ASSET_ID: &str = "7cd29974-1503-4084-b4a0-7483d0a57d7d";
|
||||
let _manifest = write_manifest(ASSET_ID, 0, true);
|
||||
let mut cache = StaticMeshArtifactCache::default();
|
||||
let reference = EditorAssetRef::new(ASSET_ID, "mesh:test", "Test");
|
||||
|
||||
assert!(cache.resolve(&reference).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_refuses_geometry_from_animated_sources() {
|
||||
const ASSET_ID: &str = "7f6fcf6a-e978-4dc6-8738-16d52fc3ed03";
|
||||
let _manifest = write_manifest(ASSET_ID, 1, false);
|
||||
let mut cache = StaticMeshArtifactCache::default();
|
||||
let reference = EditorAssetRef::new(ASSET_ID, "mesh:test", "Test");
|
||||
|
||||
assert!(cache.resolve(&reference).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@ -10,8 +10,10 @@ pub mod brush_math;
|
||||
mod components;
|
||||
mod hydration;
|
||||
mod material_asset;
|
||||
mod navigation;
|
||||
mod post_process_effect_asset;
|
||||
mod prefab_overrides;
|
||||
mod renderer_material;
|
||||
mod rendering_profile_asset;
|
||||
|
||||
pub use actor::{infer_actor_kind, validate_actor, ActorValidationError};
|
||||
@ -20,17 +22,21 @@ pub use components::*;
|
||||
pub use hydration::{
|
||||
cascade_config_from_rendering, flush_level_object_hydration, material_from_desc,
|
||||
strip_hydrated, strip_hydrated_entity, HydratedModelRoot, HydratedPrefabMember,
|
||||
HydratedPrefabReady, HydrationPlugin, PrefabHydrationBlocked,
|
||||
HydratedPrefabReady, HydratedSkinnedMeshRoot, HydrationPlugin, PrefabHydrationBlocked,
|
||||
};
|
||||
pub use material_asset::{
|
||||
MaterialAsset, ShaderPropertyDesc, ShaderPropertyType, ShaderSchemaAsset,
|
||||
MaterialAlphaMode, MaterialAsset, MaterialInstanceAsset, MaterialRenderState,
|
||||
ShaderPropertyDesc, ShaderPropertyType, ShaderSchemaAsset, MATERIAL_ASSET_SCHEMA_VERSION,
|
||||
MATERIAL_INSTANCE_SCHEMA_VERSION, SURFACE_SHADER_SCHEMA_VERSION,
|
||||
};
|
||||
pub use navigation::*;
|
||||
pub use post_process_effect_asset::{PostProcessEffectAsset, PostProcessEffectKind};
|
||||
pub use prefab_overrides::{
|
||||
apply_prefab_overrides_on_ready, decode_prefab_overrides, encode_prefab_overrides,
|
||||
PrefabActorPath, PrefabComponentOverride, PrefabOverrides, PrefabPropertyOverride,
|
||||
PrefabStructuralOverride, PREFAB_OVERRIDE_FORMAT_VERSION,
|
||||
};
|
||||
pub use renderer_material::*;
|
||||
pub use rendering_profile_asset::RenderingProfileAsset;
|
||||
|
||||
/// Converts catalog-style paths (`assets/models/foo.fbx`) to Bevy AssetServer paths.
|
||||
@ -73,14 +79,25 @@ impl Plugin for SharedTypesPlugin {
|
||||
.register_type::<SubsceneReference>()
|
||||
.register_type::<HierarchySiblingIndex>()
|
||||
.register_type::<InspectorOrder>()
|
||||
.register_type::<AuthoringComponentStates>()
|
||||
.register_type::<InspectorComponentState>()
|
||||
.register_type::<ComponentInstanceId>()
|
||||
.register_type::<EditorAssetRef>()
|
||||
.register_type::<MaterialRef>()
|
||||
.register_type::<RendererMaterialSlot>()
|
||||
.register_type::<RendererMaterialSet>()
|
||||
.register_type::<OrphanedMaterialAssignment>()
|
||||
.register_type::<AnimationSkeletonSignature>()
|
||||
.register_type::<SkinnedMeshRenderer>()
|
||||
.register_type::<AnimationPlaybackRange>()
|
||||
.register_type::<AnimationEventDesc>()
|
||||
.register_type::<AnimationStateDesc>()
|
||||
.register_type::<AnimationControllerDesc>()
|
||||
.register_type::<NavigationAgentProfile>()
|
||||
.register_type::<NavigationBounds>()
|
||||
.register_type::<NavigationObstacle>()
|
||||
.register_type::<NavigationArea>()
|
||||
.register_type::<NavigationLink>()
|
||||
.register_type::<AudioSourceDesc>()
|
||||
.register_type::<AudioListenerDesc>()
|
||||
.register_type::<AudioAttenuationDesc>()
|
||||
@ -127,13 +144,37 @@ impl Plugin for SharedTypesPlugin {
|
||||
.register_type::<PostProcessVolumeOverrides>()
|
||||
.register_type::<TeamSpawn>()
|
||||
.register_type::<ObjectiveMarker>()
|
||||
.add_systems(Update, migrate_legacy_authoring_component_states)
|
||||
.add_plugins(HydrationPlugin);
|
||||
}
|
||||
}
|
||||
|
||||
fn migrate_legacy_authoring_component_states(
|
||||
mut commands: Commands,
|
||||
mut legacy: Query<
|
||||
(Entity, &mut InspectorOrder),
|
||||
(With<LevelObject>, Without<AuthoringComponentStates>),
|
||||
>,
|
||||
) {
|
||||
for (entity, mut order) in &mut legacy {
|
||||
let mut states = std::mem::take(&mut order.component_states);
|
||||
for state in &mut states {
|
||||
if state.component_id.is_empty() {
|
||||
if let Some(component_id) = authoring_component_id(&state.type_name) {
|
||||
state.component_id = component_id.to_string();
|
||||
state.type_name.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
commands
|
||||
.entity(entity)
|
||||
.insert(AuthoringComponentStates { states });
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::standard_material_asset_path;
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn gltf_standard_material_paths_use_pbr_conversion_label() {
|
||||
@ -162,4 +203,68 @@ mod tests {
|
||||
"models/robot.glb#Material3/std"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_inspector_active_state_moves_to_authoring_component_states() {
|
||||
let mut app = App::new();
|
||||
app.add_systems(Update, migrate_legacy_authoring_component_states);
|
||||
let mut order = InspectorOrder::default();
|
||||
order.component_states.push(InspectorComponentState {
|
||||
component_id: String::new(),
|
||||
type_name: COMPONENT_LIGHT_DESC.to_string(),
|
||||
active: false,
|
||||
});
|
||||
let entity = app.world_mut().spawn((LevelObject, order)).id();
|
||||
|
||||
app.update();
|
||||
|
||||
let states = app.world().get::<AuthoringComponentStates>(entity).unwrap();
|
||||
assert!(!states.is_component_active(COMPONENT_LIGHT_DESC));
|
||||
assert_eq!(states.states[0].component_id, AUTHORING_COMPONENT_LIGHT);
|
||||
assert!(states.states[0].type_name.is_empty());
|
||||
assert!(app
|
||||
.world()
|
||||
.get::<InspectorOrder>(entity)
|
||||
.unwrap()
|
||||
.component_states
|
||||
.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_active_state_takes_precedence_over_legacy_order() {
|
||||
let mut legacy = InspectorOrder::default();
|
||||
legacy.set_component_active(COMPONENT_LIGHT_DESC, false);
|
||||
let mut states = AuthoringComponentStates::default();
|
||||
states.set_component_active(COMPONENT_LIGHT_DESC, true);
|
||||
|
||||
assert!(authoring_component_active(
|
||||
Some(&states),
|
||||
Some(&legacy),
|
||||
COMPONENT_LIGHT_DESC
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inspector_order_migrates_legacy_type_paths_to_stable_ids_on_edit() {
|
||||
let present = [COMPONENT_LIGHT_DESC, COMPONENT_PRIMITIVE];
|
||||
let mut order = InspectorOrder {
|
||||
component_type_names: present.iter().map(|value| value.to_string()).collect(),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert_eq!(order.ordered_components(&present), present);
|
||||
assert!(order.move_component(COMPONENT_PRIMITIVE, -1, &present));
|
||||
assert!(order.component_type_names.is_empty());
|
||||
assert_eq!(
|
||||
order.component_ids,
|
||||
vec![
|
||||
AUTHORING_COMPONENT_PRIMITIVE.to_string(),
|
||||
AUTHORING_COMPONENT_LIGHT.to_string()
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
order.ordered_components(&present),
|
||||
vec![COMPONENT_PRIMITIVE, COMPONENT_LIGHT_DESC]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,15 +1,73 @@
|
||||
//! Serializable material asset files under `assets/materials/`.
|
||||
//! Versioned material, material-instance, and surface-shader asset documents.
|
||||
|
||||
use bevy::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{MaterialDesc, MaterialParameter, MaterialShaderKind, MaterialTextureBinding};
|
||||
use crate::{
|
||||
EditorAssetRef, MaterialDesc, MaterialParameter, MaterialRef, MaterialShaderKind,
|
||||
MaterialTextureBinding,
|
||||
};
|
||||
|
||||
pub const MATERIAL_ASSET_SCHEMA_VERSION: u32 = 1;
|
||||
pub const MATERIAL_INSTANCE_SCHEMA_VERSION: u32 = 1;
|
||||
pub const SURFACE_SHADER_SCHEMA_VERSION: u32 = 1;
|
||||
|
||||
const fn default_material_schema_version() -> u32 {
|
||||
MATERIAL_ASSET_SCHEMA_VERSION
|
||||
}
|
||||
|
||||
const fn default_material_instance_schema_version() -> u32 {
|
||||
MATERIAL_INSTANCE_SCHEMA_VERSION
|
||||
}
|
||||
|
||||
const fn default_surface_shader_schema_version() -> u32 {
|
||||
SURFACE_SHADER_SCHEMA_VERSION
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum MaterialAlphaMode {
|
||||
#[default]
|
||||
Opaque,
|
||||
Cutout,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
|
||||
pub struct MaterialRenderState {
|
||||
#[serde(default)]
|
||||
pub alpha_mode: MaterialAlphaMode,
|
||||
#[serde(default = "default_alpha_cutoff")]
|
||||
pub alpha_cutoff: f32,
|
||||
#[serde(default)]
|
||||
pub double_sided: bool,
|
||||
}
|
||||
|
||||
const fn default_alpha_cutoff() -> f32 {
|
||||
0.5
|
||||
}
|
||||
|
||||
impl Default for MaterialRenderState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
alpha_mode: MaterialAlphaMode::Opaque,
|
||||
alpha_cutoff: default_alpha_cutoff(),
|
||||
double_sided: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// RON material asset: label plus authoring [`MaterialDesc`].
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Asset, TypePath, Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MaterialAsset {
|
||||
#[serde(default = "default_material_schema_version")]
|
||||
pub schema_version: u32,
|
||||
pub label: String,
|
||||
/// Legacy path/label retained for v0 compatibility. New assets use `shader_ref`.
|
||||
#[serde(default)]
|
||||
pub shader: Option<String>,
|
||||
#[serde(default)]
|
||||
pub shader_ref: Option<EditorAssetRef>,
|
||||
#[serde(default)]
|
||||
pub render_state: MaterialRenderState,
|
||||
pub material: MaterialDesc,
|
||||
}
|
||||
|
||||
@ -21,6 +79,81 @@ impl MaterialAsset {
|
||||
}
|
||||
}
|
||||
|
||||
/// Explicit reusable overrides over one project Material asset.
|
||||
#[derive(Asset, TypePath, Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct MaterialInstanceAsset {
|
||||
#[serde(default = "default_material_instance_schema_version")]
|
||||
pub schema_version: u32,
|
||||
pub label: String,
|
||||
pub base: MaterialRef,
|
||||
#[serde(default)]
|
||||
pub parameters: Vec<MaterialParameter>,
|
||||
#[serde(default)]
|
||||
pub textures: Vec<MaterialTextureBinding>,
|
||||
}
|
||||
|
||||
impl MaterialInstanceAsset {
|
||||
pub fn load_from_path(catalog_path: &str) -> Result<Self, String> {
|
||||
let text = std::fs::read_to_string(catalog_path)
|
||||
.map_err(|err| format!("could not read {catalog_path}: {err}"))?;
|
||||
ron::from_str(&text)
|
||||
.map_err(|err| format!("invalid material-instance RON in {catalog_path}: {err}"))
|
||||
}
|
||||
|
||||
/// Applies this instance's sparse overrides without mutating the shared base asset.
|
||||
pub fn apply_to(&self, material: &mut MaterialDesc) {
|
||||
for parameter in &self.parameters {
|
||||
match (parameter.name.as_str(), ¶meter.value) {
|
||||
("base_color", crate::MaterialParameterValue::Color(value)) => {
|
||||
material.base_color = *value;
|
||||
}
|
||||
("metallic", crate::MaterialParameterValue::Float(value)) => {
|
||||
material.metallic = *value;
|
||||
}
|
||||
("roughness", crate::MaterialParameterValue::Float(value)) => {
|
||||
material.roughness = *value;
|
||||
}
|
||||
("emissive_color", crate::MaterialParameterValue::Color(value)) => {
|
||||
material.emissive_color = *value;
|
||||
}
|
||||
("emissive_intensity", crate::MaterialParameterValue::Float(value)) => {
|
||||
material.emissive_intensity = *value;
|
||||
}
|
||||
_ => upsert_parameter(&mut material.parameters, parameter.clone()),
|
||||
}
|
||||
}
|
||||
for texture in &self.textures {
|
||||
let path = texture
|
||||
.texture
|
||||
.as_ref()
|
||||
.and_then(|reference| reference.source_path.clone());
|
||||
match texture.name.as_str() {
|
||||
"base_color_texture" => material.base_color_texture = path,
|
||||
"emissive_texture" => material.emissive_texture = path,
|
||||
"normal_map_texture" => material.normal_map_texture = path,
|
||||
"metallic_roughness_texture" => material.metallic_roughness_texture = path,
|
||||
_ => upsert_texture(&mut material.textures, texture.clone()),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn upsert_parameter(values: &mut Vec<MaterialParameter>, value: MaterialParameter) {
|
||||
if let Some(existing) = values.iter_mut().find(|entry| entry.name == value.name) {
|
||||
*existing = value;
|
||||
} else {
|
||||
values.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
fn upsert_texture(values: &mut Vec<MaterialTextureBinding>, value: MaterialTextureBinding) {
|
||||
if let Some(existing) = values.iter_mut().find(|entry| entry.name == value.name) {
|
||||
*existing = value;
|
||||
} else {
|
||||
values.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum ShaderPropertyType {
|
||||
Bool,
|
||||
@ -42,8 +175,10 @@ pub struct ShaderPropertyDesc {
|
||||
}
|
||||
|
||||
/// RON shader schema describing material inspector parameters.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[derive(Asset, TypePath, Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct ShaderSchemaAsset {
|
||||
#[serde(default = "default_surface_shader_schema_version")]
|
||||
pub schema_version: u32,
|
||||
pub label: String,
|
||||
pub kind: MaterialShaderKind,
|
||||
#[serde(default)]
|
||||
@ -56,6 +191,78 @@ pub struct ShaderSchemaAsset {
|
||||
pub default_textures: Vec<MaterialTextureBinding>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn legacy_material_defaults_to_current_schema_and_opaque() {
|
||||
let parsed: MaterialAsset = ron::from_str(
|
||||
r#"(label: "Legacy", material: (base_color: (r: 1.0, g: 1.0, b: 1.0, a: 1.0), metallic: 0.0, roughness: 0.5, base_color_texture: None, normal_map_texture: None, metallic_roughness_texture: None))"#,
|
||||
)
|
||||
.expect("legacy material should remain readable");
|
||||
|
||||
assert_eq!(parsed.schema_version, MATERIAL_ASSET_SCHEMA_VERSION);
|
||||
assert_eq!(parsed.render_state.alpha_mode, MaterialAlphaMode::Opaque);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn material_instance_requires_an_explicit_base_ref() {
|
||||
let instance = MaterialInstanceAsset {
|
||||
schema_version: MATERIAL_INSTANCE_SCHEMA_VERSION,
|
||||
label: "Red Variant".into(),
|
||||
base: MaterialRef::new(EditorAssetRef::new(
|
||||
"material-id",
|
||||
"material:source",
|
||||
"Base",
|
||||
)),
|
||||
parameters: Vec::new(),
|
||||
textures: Vec::new(),
|
||||
};
|
||||
|
||||
assert!(instance.base.is_resolved());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn material_instance_applies_standard_and_custom_overrides() {
|
||||
let mut material = MaterialDesc::default();
|
||||
let instance = MaterialInstanceAsset {
|
||||
schema_version: MATERIAL_INSTANCE_SCHEMA_VERSION,
|
||||
label: "Variant".into(),
|
||||
base: MaterialRef::default(),
|
||||
parameters: vec![
|
||||
MaterialParameter {
|
||||
name: "roughness".into(),
|
||||
value: crate::MaterialParameterValue::Float(0.2),
|
||||
},
|
||||
MaterialParameter {
|
||||
name: "edge_width".into(),
|
||||
value: crate::MaterialParameterValue::Float(3.0),
|
||||
},
|
||||
],
|
||||
textures: vec![MaterialTextureBinding {
|
||||
name: "base_color_texture".into(),
|
||||
texture: Some(
|
||||
EditorAssetRef::new("texture", "texture:source", "Grid")
|
||||
.with_source_path("assets/textures/grid.png"),
|
||||
),
|
||||
}],
|
||||
};
|
||||
|
||||
instance.apply_to(&mut material);
|
||||
|
||||
assert_eq!(material.roughness, 0.2);
|
||||
assert_eq!(
|
||||
material.base_color_texture.as_deref(),
|
||||
Some("assets/textures/grid.png")
|
||||
);
|
||||
assert!(material
|
||||
.parameters
|
||||
.iter()
|
||||
.any(|value| value.name == "edge_width"));
|
||||
}
|
||||
}
|
||||
|
||||
impl ShaderSchemaAsset {
|
||||
pub fn load_from_path(catalog_path: &str) -> Result<Self, String> {
|
||||
let text = std::fs::read_to_string(catalog_path)
|
||||
|
||||
181
crates/shared/src/navigation.rs
Normal file
181
crates/shared/src/navigation.rs
Normal file
@ -0,0 +1,181 @@
|
||||
use bevy::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const COMPONENT_NAVIGATION_BOUNDS: &str = "shared::navigation::NavigationBounds";
|
||||
pub const COMPONENT_NAVIGATION_OBSTACLE: &str = "shared::navigation::NavigationObstacle";
|
||||
pub const COMPONENT_NAVIGATION_AREA: &str = "shared::navigation::NavigationArea";
|
||||
pub const COMPONENT_NAVIGATION_LINK: &str = "shared::navigation::NavigationLink";
|
||||
|
||||
/// Agent dimensions and deterministic Rerecast bake settings.
|
||||
#[derive(Reflect, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct NavigationAgentProfile {
|
||||
pub id: String,
|
||||
pub radius: f32,
|
||||
pub height: f32,
|
||||
pub max_climb: f32,
|
||||
pub max_slope_deg: f32,
|
||||
pub cell_size_fraction: f32,
|
||||
pub cell_height_fraction: f32,
|
||||
pub min_region_size: u16,
|
||||
pub merge_region_size: u16,
|
||||
}
|
||||
|
||||
impl Default for NavigationAgentProfile {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
id: "humanoid".into(),
|
||||
radius: 0.35,
|
||||
height: 1.8,
|
||||
max_climb: 0.45,
|
||||
max_slope_deg: 45.0,
|
||||
cell_size_fraction: 3.0,
|
||||
cell_height_fraction: 6.0,
|
||||
min_region_size: 4,
|
||||
merge_region_size: 12,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl NavigationAgentProfile {
|
||||
pub fn validate(&self) -> Result<(), &'static str> {
|
||||
if self.id.trim().is_empty() {
|
||||
return Err("agent profile ID must not be empty");
|
||||
}
|
||||
if !self.radius.is_finite() || self.radius <= 0.0 {
|
||||
return Err("agent radius must be finite and greater than zero");
|
||||
}
|
||||
if !self.height.is_finite() || self.height <= self.radius * 2.0 {
|
||||
return Err("agent height must be finite and greater than twice its radius");
|
||||
}
|
||||
if !self.max_climb.is_finite() || self.max_climb < 0.0 || self.max_climb >= self.height {
|
||||
return Err("maximum climb must be finite, non-negative, and below agent height");
|
||||
}
|
||||
if !self.max_slope_deg.is_finite() || !(0.0..90.0).contains(&self.max_slope_deg) {
|
||||
return Err("maximum slope must be finite and in [0, 90) degrees");
|
||||
}
|
||||
if !self.cell_size_fraction.is_finite() || self.cell_size_fraction <= 0.0 {
|
||||
return Err("cell size fraction must be finite and greater than zero");
|
||||
}
|
||||
if !self.cell_height_fraction.is_finite() || self.cell_height_fraction <= 0.0 {
|
||||
return Err("cell height fraction must be finite and greater than zero");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Axis-aligned world region baked for one agent profile.
|
||||
#[derive(Component, Reflect, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[reflect(Component, Default, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct NavigationBounds {
|
||||
pub half_extents: Vec3,
|
||||
pub agent: NavigationAgentProfile,
|
||||
pub artifact_path: String,
|
||||
#[serde(default)]
|
||||
pub auto_bake: bool,
|
||||
}
|
||||
|
||||
impl Default for NavigationBounds {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
half_extents: Vec3::new(20.0, 4.0, 20.0),
|
||||
agent: NavigationAgentProfile::default(),
|
||||
artifact_path: "assets/navigation/generated/main_humanoid.nav.ron".into(),
|
||||
auto_bake: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Axis-aligned volume carved from every overlapping navigation bake.
|
||||
#[derive(Component, Reflect, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[reflect(Component, Default, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct NavigationObstacle {
|
||||
pub half_extents: Vec3,
|
||||
#[serde(default)]
|
||||
pub dynamic: bool,
|
||||
}
|
||||
|
||||
impl Default for NavigationObstacle {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
half_extents: Vec3::splat(0.5),
|
||||
dynamic: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Named navigation area volume retained in the bake artifact.
|
||||
#[derive(Component, Reflect, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[reflect(Component, Default, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct NavigationArea {
|
||||
pub id: String,
|
||||
pub half_extents: Vec3,
|
||||
pub cost: f32,
|
||||
#[serde(default = "default_true")]
|
||||
pub walkable: bool,
|
||||
}
|
||||
|
||||
impl Default for NavigationArea {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
id: "default".into(),
|
||||
half_extents: Vec3::splat(1.0),
|
||||
cost: 1.0,
|
||||
walkable: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Explicit off-mesh connection. Endpoints are local to the owning actor.
|
||||
#[derive(Component, Reflect, Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[reflect(Component, Default, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct NavigationLink {
|
||||
pub start: Vec3,
|
||||
pub end: Vec3,
|
||||
#[serde(default = "default_true")]
|
||||
pub bidirectional: bool,
|
||||
#[serde(default = "default_one")]
|
||||
pub cost: f32,
|
||||
#[serde(default = "default_true")]
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
impl Default for NavigationLink {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
start: Vec3::ZERO,
|
||||
end: Vec3::new(0.0, 0.0, 2.0),
|
||||
bidirectional: true,
|
||||
cost: 1.0,
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_one() -> f32 {
|
||||
1.0
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::NavigationAgentProfile;
|
||||
|
||||
#[test]
|
||||
fn default_agent_profile_is_valid() {
|
||||
assert_eq!(NavigationAgentProfile::default().validate(), Ok(()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_agent_dimensions_are_actionable() {
|
||||
let mut profile = NavigationAgentProfile::default();
|
||||
profile.height = profile.radius;
|
||||
assert_eq!(
|
||||
profile.validate(),
|
||||
Err("agent height must be finite and greater than twice its radius")
|
||||
);
|
||||
}
|
||||
}
|
||||
155
crates/shared/src/renderer_material.rs
Normal file
155
crates/shared/src/renderer_material.rs
Normal file
@ -0,0 +1,155 @@
|
||||
//! Shared material references and renderer-slot authoring contracts.
|
||||
|
||||
use bevy::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{ComponentInstanceId, EditorAssetRef, MaterialParameter, MaterialTextureBinding};
|
||||
|
||||
/// A stable reference to a project material, material instance, or imported material subasset.
|
||||
///
|
||||
/// The registry UUID/subasset ID are authoritative. `EditorAssetRef::source_path` is only a
|
||||
/// load-time cache and may be repaired when an asset moves.
|
||||
#[derive(Reflect, Default, Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct MaterialRef(pub EditorAssetRef);
|
||||
|
||||
impl MaterialRef {
|
||||
pub fn new(asset: EditorAssetRef) -> Self {
|
||||
Self(asset)
|
||||
}
|
||||
|
||||
pub fn is_resolved(&self) -> bool {
|
||||
self.0.is_resolved()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<EditorAssetRef> for MaterialRef {
|
||||
fn from(value: EditorAssetRef) -> Self {
|
||||
Self(value)
|
||||
}
|
||||
}
|
||||
|
||||
/// One stable, named material assignment exposed by a renderer.
|
||||
#[derive(Reflect, Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RendererMaterialSlot {
|
||||
pub id: ComponentInstanceId,
|
||||
pub name: String,
|
||||
/// Read-only imported default. Clearing `material` returns to this reference.
|
||||
#[serde(default)]
|
||||
pub source_material: Option<MaterialRef>,
|
||||
/// Explicit shared Material or Material Instance assignment.
|
||||
#[serde(default)]
|
||||
pub material: Option<MaterialRef>,
|
||||
}
|
||||
|
||||
impl RendererMaterialSlot {
|
||||
pub fn effective_material(&self) -> Option<&MaterialRef> {
|
||||
self.material.as_ref().or(self.source_material.as_ref())
|
||||
}
|
||||
}
|
||||
|
||||
/// Assignment retained after a source reimport removes or renames its draw slot.
|
||||
#[derive(Reflect, Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct OrphanedMaterialAssignment {
|
||||
pub slot_id: ComponentInstanceId,
|
||||
#[serde(default)]
|
||||
pub last_known_name: String,
|
||||
pub material: MaterialRef,
|
||||
}
|
||||
|
||||
/// Material slots shared by static and skinned renderer authoring components.
|
||||
#[derive(Reflect, Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[reflect(Default, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RendererMaterialSet {
|
||||
#[serde(default)]
|
||||
pub slots: Vec<RendererMaterialSlot>,
|
||||
#[serde(default)]
|
||||
pub orphaned_assignments: Vec<OrphanedMaterialAssignment>,
|
||||
}
|
||||
|
||||
impl RendererMaterialSet {
|
||||
pub fn slot(&self, id: &ComponentInstanceId) -> Option<&RendererMaterialSlot> {
|
||||
self.slots.iter().find(|slot| &slot.id == id)
|
||||
}
|
||||
|
||||
pub fn slot_mut(&mut self, id: &ComponentInstanceId) -> Option<&mut RendererMaterialSlot> {
|
||||
self.slots.iter_mut().find(|slot| &slot.id == id)
|
||||
}
|
||||
|
||||
pub fn effective_material(&self, id: &ComponentInstanceId) -> Option<&MaterialRef> {
|
||||
self.slot(id)
|
||||
.and_then(RendererMaterialSlot::effective_material)
|
||||
}
|
||||
}
|
||||
|
||||
/// Runtime-only property overrides analogous to a material property block.
|
||||
///
|
||||
/// Blocks never alter shared assets and are deliberately excluded from scene/prefab persistence.
|
||||
#[derive(Reflect, Default, Debug, Clone, PartialEq)]
|
||||
#[reflect(Default, Debug, PartialEq)]
|
||||
pub struct MaterialPropertyBlock {
|
||||
pub slot_id: ComponentInstanceId,
|
||||
#[reflect(ignore)]
|
||||
pub parameters: Vec<MaterialParameter>,
|
||||
#[reflect(ignore)]
|
||||
pub textures: Vec<MaterialTextureBinding>,
|
||||
}
|
||||
|
||||
#[derive(Component, Reflect, Default, Debug, Clone, PartialEq)]
|
||||
#[reflect(Component, Default, Debug, PartialEq)]
|
||||
pub struct MaterialPropertyBlocks {
|
||||
pub slots: Vec<MaterialPropertyBlock>,
|
||||
}
|
||||
|
||||
/// Runtime ownership/binding marker placed on every hydrated renderer draw entity.
|
||||
#[derive(Component, Reflect, Debug, Clone, PartialEq, Eq)]
|
||||
#[reflect(Component, Debug, PartialEq)]
|
||||
pub struct HydratedRendererMaterialBinding {
|
||||
pub owner: Entity,
|
||||
pub slot_id: ComponentInstanceId,
|
||||
pub effective_material: Option<MaterialRef>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn material_ref(id: &str) -> MaterialRef {
|
||||
MaterialRef::new(EditorAssetRef::new(id, "material:source", id))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_assignment_precedes_imported_source() {
|
||||
let source = material_ref("source");
|
||||
let assigned = material_ref("assigned");
|
||||
let mut slot = RendererMaterialSlot {
|
||||
id: ComponentInstanceId::new("slot:body"),
|
||||
name: "Body".into(),
|
||||
source_material: Some(source.clone()),
|
||||
material: Some(assigned.clone()),
|
||||
};
|
||||
|
||||
assert_eq!(slot.effective_material(), Some(&assigned));
|
||||
slot.material = None;
|
||||
assert_eq!(slot.effective_material(), Some(&source));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_slot_does_not_fall_back_by_display_name() {
|
||||
let set = RendererMaterialSet {
|
||||
slots: vec![RendererMaterialSlot {
|
||||
id: ComponentInstanceId::new("slot:body"),
|
||||
name: "Body".into(),
|
||||
source_material: Some(material_ref("source")),
|
||||
material: None,
|
||||
}],
|
||||
orphaned_assignments: vec![],
|
||||
};
|
||||
|
||||
assert!(set
|
||||
.effective_material(&ComponentInstanceId::new("slot:renamed"))
|
||||
.is_none());
|
||||
}
|
||||
}
|
||||
@ -46,6 +46,11 @@ Immutable-style log of significant decisions. Add a new numbered ADR when changi
|
||||
| [0029](adr/0029-validation-gated-build-profiles-and-packaging.md) | Shared validation-gated build profiles, runtime packaging, and metadata |
|
||||
| [0030](adr/0030-audio-authoring-and-bus-schema.md) | Serializable audio sources/listeners, stable clip references, and project bus graph |
|
||||
| [0031](adr/0031-animation-authoring-runtime-contract.md) | glTF animation artifacts, stable controller states, runtime hydration, and exact-signature retargeting |
|
||||
| [0032](adr/0032-versioned-navigation-bake-and-runtime-query.md) | Rerecast bake artifacts, Polyanya runtime queries, and editor/runtime ownership |
|
||||
| [0033](adr/0033-dedicated-skinned-mesh-renderer.md) | Dedicated skinned renderer, static/skinned import boundary, and scene migration |
|
||||
| [0034](adr/0034-registry-driven-authoring-components.md) | Stable component IDs, reflected lifecycle/history, composition, and independent active state |
|
||||
| [0035](adr/0035-shared-material-assets-and-renderer-slots.md) | Shared Material/Material Instance assets, stable renderer slots, and runtime-only property blocks |
|
||||
| [0036](adr/0036-surface-abi-and-solari-parity.md) | Constrained Surface ABI v1, raster/Solari evaluator parity, and deformed-geometry boundary |
|
||||
|
||||
## Editor framework
|
||||
|
||||
@ -67,6 +72,10 @@ Immutable-style log of significant decisions. Add a new numbered ADR when changi
|
||||
| [editor/prefab-authoring.md](editor/prefab-authoring.md) | Linked instances, scoped overrides/source Apply, conflicts, variants, unpacking, and local conversion |
|
||||
| [editor/audio-authoring.md](editor/audio-authoring.md) | Audio clips, source/listener authoring, audition, buses, runtime parity, and validation |
|
||||
| [editor/animation-authoring.md](editor/animation-authoring.md) | glTF rigs/clips, controller states, preview, runtime requests, stable moves, and validation |
|
||||
| [editor/navigation-authoring.md](editor/navigation-authoring.md) | Bounds/obstacles/areas/links, deterministic bake, overlay, path preview, and runtime queries |
|
||||
| [editor/extensibility.md](editor/extensibility.md) | Static authoring component registration, lifecycle, composition, and history contract |
|
||||
| [editor/material-system.md](editor/material-system.md) | Shared material assets and instances, renderer-slot assignment, Surface evaluators, migration, and diagnostics |
|
||||
| [editor/evaluations/material-renderer-foundation/](editor/evaluations/material-renderer-foundation/) | Live screenshots and acceptance results for the renderer/material/component foundation |
|
||||
|
||||
## Working plans (not canonical long-term)
|
||||
|
||||
@ -83,6 +92,8 @@ Detailed milestone and feature plans live in [`.cursor/plans/`](../.cursor/plans
|
||||
| `jackdaw_feature_roadmap_*.plan.md` | Jackdaw-inspired production roadmap; tracked in Gitea as `BS-JD-*` issues |
|
||||
| `blacksite_production_readiness_*.plan.md` | M6-M7 reliability, content production, shipping, and acceptance gates |
|
||||
| `animation_authoring_*.plan.md` | M7 glTF skeletal animation import, preview, controller, and runtime acceptance |
|
||||
| `navigation_authoring_*.plan.md` | M7 navigation bounds, deterministic bake, diagnostics, preview, and runtime query API |
|
||||
| `renderer_material_component_foundation_*.plan.md` | Renderer/material slots, skinned pose lifecycle, Surface ABI, and component foundation |
|
||||
|
||||
## Crate responsibilities (quick reference)
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user