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,18 +1,16 @@
|
||||
(schema_version: 2,
|
||||
resources: {},
|
||||
entities: {
|
||||
4294969001: (
|
||||
components: {
|
||||
"bevy_ecs::name::Name": "Audition Tone Source",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
(schema_version: 4,resources: {
|
||||
},
|
||||
entities: {
|
||||
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": (
|
||||
"shared::components::ActorId": ("audio-showcase-source"),
|
||||
"shared::components::ActorKind": AudioSource,
|
||||
"shared::components::AudioSourceDesc": (
|
||||
clip: Some((
|
||||
asset_id: "c0a7d10e-53c1-4de0-b0dc-34a66f44ba77",
|
||||
sub_asset_id: "audio:source",
|
||||
@ -32,25 +30,23 @@
|
||||
),
|
||||
bus: "sfx",
|
||||
),
|
||||
},
|
||||
),
|
||||
4294969002: (
|
||||
components: {
|
||||
"bevy_ecs::name::Name": "Showcase Listener",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
"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": (
|
||||
"shared::components::ActorId": ("audio-showcase-listener"),
|
||||
"shared::components::ActorKind": AudioListener,
|
||||
"shared::components::AudioListenerDesc": (
|
||||
enabled: true,
|
||||
priority: 100,
|
||||
ear_gap: 0.2,
|
||||
),
|
||||
},
|
||||
),
|
||||
},
|
||||
)
|
||||
"shared::components::LevelObject": (),
|
||||
}),
|
||||
},
|
||||
)
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
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,17 +1,16 @@
|
||||
(schema_version: 2,
|
||||
resources: {},
|
||||
entities: {
|
||||
4294968001: (
|
||||
components: {
|
||||
"bevy_ecs::name::Name": "Fog Volume",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
(schema_version: 4,resources: {
|
||||
},
|
||||
entities: {
|
||||
4294968001: (components: {
|
||||
"bevy_ecs::name::Name": "Fog Volume",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (0.0, 2.0, 0.0),
|
||||
rotation: (0.0, 0.0, 0.0, 1.0),
|
||||
scale: (1.0, 1.0, 1.0),
|
||||
),
|
||||
"shared::components::LevelObject": (),
|
||||
"shared::components::ActorKind": PostProcessVolume,
|
||||
"shared::components::PostProcessVolumeDesc": (
|
||||
"shared::components::ActorKind": PostProcessVolume,
|
||||
"shared::components::LevelObject": (),
|
||||
"shared::components::PostProcessVolumeDesc": (
|
||||
half_extents: (6.0, 3.0, 6.0),
|
||||
priority: 0,
|
||||
blend_distance: 2.0,
|
||||
@ -23,19 +22,17 @@
|
||||
profile: None,
|
||||
label: Some("Foggy courtyard"),
|
||||
),
|
||||
},
|
||||
),
|
||||
4294968002: (
|
||||
components: {
|
||||
"bevy_ecs::name::Name": "Dark Exposure Volume",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
}),
|
||||
4294968002: (components: {
|
||||
"bevy_ecs::name::Name": "Dark Exposure Volume",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (14.0, 2.0, 0.0),
|
||||
rotation: (0.0, 0.0, 0.0, 1.0),
|
||||
scale: (1.0, 1.0, 1.0),
|
||||
),
|
||||
"shared::components::LevelObject": (),
|
||||
"shared::components::ActorKind": PostProcessVolume,
|
||||
"shared::components::PostProcessVolumeDesc": (
|
||||
"shared::components::ActorKind": PostProcessVolume,
|
||||
"shared::components::LevelObject": (),
|
||||
"shared::components::PostProcessVolumeDesc": (
|
||||
half_extents: (4.0, 2.5, 4.0),
|
||||
priority: 5,
|
||||
blend_distance: 1.5,
|
||||
@ -47,19 +44,17 @@
|
||||
profile: Some("assets/rendering_profiles/cave_dark.ron"),
|
||||
label: Some("Cave mouth"),
|
||||
),
|
||||
},
|
||||
),
|
||||
4294968003: (
|
||||
components: {
|
||||
"bevy_ecs::name::Name": "Vignette FX Volume",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
}),
|
||||
4294968003: (components: {
|
||||
"bevy_ecs::name::Name": "Vignette FX Volume",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
translation: (-12.0, 2.0, 0.0),
|
||||
rotation: (0.0, 0.0, 0.0, 1.0),
|
||||
scale: (1.0, 1.0, 1.0),
|
||||
),
|
||||
"shared::components::LevelObject": (),
|
||||
"shared::components::ActorKind": PostProcessVolume,
|
||||
"shared::components::PostProcessVolumeDesc": (
|
||||
"shared::components::ActorKind": PostProcessVolume,
|
||||
"shared::components::LevelObject": (),
|
||||
"shared::components::PostProcessVolumeDesc": (
|
||||
half_extents: (5.0, 2.0, 5.0),
|
||||
priority: 2,
|
||||
blend_distance: 2.0,
|
||||
@ -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,19 +1,17 @@
|
||||
(schema_version: 2,
|
||||
resources: {},
|
||||
entities: {
|
||||
1: (
|
||||
components: {
|
||||
"bevy_ecs::name::Name": "Example Base Mesh",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
(schema_version: 4,resources: {
|
||||
},
|
||||
entities: {
|
||||
1: (components: {
|
||||
"bevy_ecs::name::Name": "Example Base Mesh",
|
||||
"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": ("base-mesh"),
|
||||
"shared::components::ActorKind": Empty,
|
||||
"shared::components::EditorVisibility": (visible: true),
|
||||
"shared::components::LevelObject": (),
|
||||
},
|
||||
),
|
||||
},
|
||||
)
|
||||
"shared::components::ActorId": ("base-mesh"),
|
||||
"shared::components::ActorKind": Empty,
|
||||
"shared::components::EditorVisibility": (visible: true),
|
||||
"shared::components::LevelObject": (),
|
||||
}),
|
||||
},
|
||||
)
|
||||
@ -1,23 +1,21 @@
|
||||
(schema_version: 2,
|
||||
resources: {},
|
||||
entities: {
|
||||
1: (
|
||||
components: {
|
||||
"bevy_ecs::name::Name": "Example Base Link",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
(schema_version: 4,resources: {
|
||||
},
|
||||
entities: {
|
||||
1: (components: {
|
||||
"bevy_ecs::name::Name": "Example Base 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": ("base-link"),
|
||||
"shared::components::ActorKind": PrefabAnchor,
|
||||
"shared::components::LevelObject": (),
|
||||
"shared::components::PrefabInstance": (
|
||||
"shared::components::ActorId": ("base-link"),
|
||||
"shared::components::ActorKind": PrefabAnchor,
|
||||
"shared::components::LevelObject": (),
|
||||
"shared::components::PrefabInstance": (
|
||||
asset_id: "11111111-1111-4111-8111-111111111111",
|
||||
source_path: "assets/prefabs/example_base.scn.ron",
|
||||
overrides_ron: None,
|
||||
),
|
||||
},
|
||||
),
|
||||
},
|
||||
)
|
||||
}),
|
||||
},
|
||||
)
|
||||
@ -1,23 +1,21 @@
|
||||
(schema_version: 2,
|
||||
resources: {},
|
||||
entities: {
|
||||
1: (
|
||||
components: {
|
||||
"bevy_ecs::name::Name": "Example Nested Variant Link",
|
||||
"bevy_transform::components::transform::Transform": (
|
||||
(schema_version: 4,resources: {
|
||||
},
|
||||
entities: {
|
||||
1: (components: {
|
||||
"bevy_ecs::name::Name": "Example Nested Variant 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": ("nested-link"),
|
||||
"shared::components::ActorKind": PrefabAnchor,
|
||||
"shared::components::LevelObject": (),
|
||||
"shared::components::PrefabInstance": (
|
||||
"shared::components::ActorId": ("nested-link"),
|
||||
"shared::components::ActorKind": PrefabAnchor,
|
||||
"shared::components::LevelObject": (),
|
||||
"shared::components::PrefabInstance": (
|
||||
asset_id: "22222222-2222-4222-8222-222222222222",
|
||||
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,18 +1,73 @@
|
||||
(
|
||||
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,13 +1,37 @@
|
||||
(
|
||||
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,17 +683,33 @@ 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) {
|
||||
let collider = record
|
||||
.import_settings
|
||||
.generate_collider
|
||||
.then(|| static_mesh_collider_for_renderer(&renderer));
|
||||
apply_static_mesh_placement_mode(
|
||||
&mut snapshot,
|
||||
renderer,
|
||||
collider,
|
||||
record.import_settings.hierarchy_mode,
|
||||
);
|
||||
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
|
||||
.then(|| static_mesh_collider_for_renderer(&renderer));
|
||||
apply_static_mesh_placement_mode(
|
||||
&mut snapshot,
|
||||
renderer,
|
||||
collider,
|
||||
record.import_settings.hierarchy_mode,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
warn!(
|
||||
"Static mesh placement fell back to SceneRoot for {} because no renderer manifest was available",
|
||||
@ -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,18 +31,105 @@ 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);
|
||||
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 material {}", asset.label);
|
||||
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(
|
||||
world: &mut World,
|
||||
ui: &mut egui::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,38 +169,106 @@ pub fn renderer_from_manifest(
|
||||
MaterialImportPolicy::SourceMaterials
|
||||
);
|
||||
|
||||
StaticMeshRenderer {
|
||||
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(),
|
||||
mesh: EditorAssetRef::new(
|
||||
manifest.asset_id.clone(),
|
||||
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| {
|
||||
EditorAssetRef::new(
|
||||
manifest.asset_id.clone(),
|
||||
id,
|
||||
part.material_slot_name.clone(),
|
||||
)
|
||||
})
|
||||
})
|
||||
.flatten(),
|
||||
local_transform: part.local_transform,
|
||||
visible: true,
|
||||
cast_shadows: true,
|
||||
receive_shadows: true,
|
||||
})
|
||||
.collect();
|
||||
let materials = RendererMaterialSet {
|
||||
slots: manifest
|
||||
.parts
|
||||
.iter()
|
||||
.map(|part| StaticMeshRendererEntry {
|
||||
id: ComponentInstanceId::new(part_effective_id(part)),
|
||||
name: part.name.clone(),
|
||||
mesh: EditorAssetRef::new(
|
||||
manifest.asset_id.clone(),
|
||||
part_effective_id(part),
|
||||
part.name.clone(),
|
||||
),
|
||||
material: use_source_materials
|
||||
.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| {
|
||||
EditorAssetRef::new(
|
||||
MaterialRef::new(EditorAssetRef::new(
|
||||
manifest.asset_id.clone(),
|
||||
id,
|
||||
part.material_slot_name.clone(),
|
||||
)
|
||||
))
|
||||
})
|
||||
})
|
||||
.flatten(),
|
||||
local_transform: part.local_transform,
|
||||
visible: true,
|
||||
cast_shadows: true,
|
||||
receive_shadows: true,
|
||||
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) = players.get_mut(runtime.player) {
|
||||
player.stop_all();
|
||||
commands
|
||||
.entity(runtime.player)
|
||||
.remove::<SuspendedAnimationTransitions>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
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 materials
|
||||
.get(&material.0)
|
||||
.is_some_and(|material| material.emissive.to_vec3() != Vec3::ZERO)
|
||||
{
|
||||
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 !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,21 +506,103 @@ 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 Some(shader) = asset.shader {
|
||||
add_reference(
|
||||
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,
|
||||
report,
|
||||
&source,
|
||||
None,
|
||||
"shader_schema",
|
||||
&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,
|
||||
"shader_schema",
|
||||
&shader,
|
||||
"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;
|
||||
}
|
||||
collect_material(project_root, report, &source, None, &asset.material);
|
||||
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(
|
||||
project_root,
|
||||
report,
|
||||
source_path,
|
||||
actor_id,
|
||||
"model",
|
||||
&model.path,
|
||||
);
|
||||
} else if let Some(record) = animation_catalog.registry_by_id.get(&model.asset_id) {
|
||||
add_reference(
|
||||
project_root,
|
||||
report,
|
||||
source_path,
|
||||
actor_id,
|
||||
"model",
|
||||
&record.source_path,
|
||||
);
|
||||
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(),
|
||||
});
|
||||
}
|
||||
collect_model_source_reference(
|
||||
project_root,
|
||||
report,
|
||||
source_path,
|
||||
actor_id,
|
||||
&model.asset_id,
|
||||
&model.path,
|
||||
"ModelRef",
|
||||
animation_catalog,
|
||||
);
|
||||
}
|
||||
COMPONENT_SKINNED_MESH_RENDERER => {
|
||||
let renderer = parse!(SkinnedMeshRenderer);
|
||||
collect_model_source_reference(
|
||||
project_root,
|
||||
report,
|
||||
source_path,
|
||||
actor_id,
|
||||
&renderer.asset_id,
|
||||
&renderer.path,
|
||||
"SkinnedMeshRenderer",
|
||||
animation_catalog,
|
||||
);
|
||||
collect_renderer_material_references(
|
||||
project_root,
|
||||
report,
|
||||
source_path,
|
||||
actor_id,
|
||||
&renderer.materials,
|
||||
);
|
||||
}
|
||||
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,13 +159,50 @@ 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)?;
|
||||
}
|
||||
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
|
||||
.diagnostics
|
||||
.into_iter()
|
||||
.find(|diagnostic| {
|
||||
diagnostic.severity == crate::brush_math::BrushDiagnosticSeverity::Error
|
||||
})
|
||||
.map(|diagnostic| diagnostic.message)
|
||||
.unwrap_or_else(|| "Brush geometry is invalid.".to_string());
|
||||
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 {
|
||||
match &kind {
|
||||
ActorKind::Brush => {
|
||||
let Some(brush) = entity.get::<BrushDesc>() else {
|
||||
return Err(ActorValidationError::BrushMissingDesc);
|
||||
@ -152,24 +213,10 @@ pub fn validate_actor(entity: EntityRef<'_>) -> Result<(), ActorValidationError>
|
||||
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 report = validate_brush(brush);
|
||||
if !report.is_valid() {
|
||||
let message = report
|
||||
.diagnostics
|
||||
.into_iter()
|
||||
.find(|diagnostic| {
|
||||
diagnostic.severity == crate::brush_math::BrushDiagnosticSeverity::Error
|
||||
})
|
||||
.map(|diagnostic| diagnostic.message)
|
||||
.unwrap_or_else(|| "Brush geometry is invalid.".to_string());
|
||||
return Err(ActorValidationError::InvalidBrushGeometry(message));
|
||||
}
|
||||
let _ = brush;
|
||||
}
|
||||
ActorKind::StaticMesh => {
|
||||
let has_static_mesh_renderer = entity
|
||||
@ -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)| {
|
||||
(
|
||||
entity,
|
||||
brush.clone(),
|
||||
material
|
||||
.filter(|_| inspector_component_active(order, COMPONENT_MATERIAL_DESC))
|
||||
.cloned(),
|
||||
collider
|
||||
.filter(|_| inspector_component_active(order, COMPONENT_COLLIDER_DESC))
|
||||
.cloned(),
|
||||
order.cloned(),
|
||||
)
|
||||
})
|
||||
.filter(|(_, _, _, _, order)| {
|
||||
inspector_component_active(order.as_ref(), COMPONENT_BRUSH_DESC)
|
||||
.filter_map(|(entity, brush, material, collider, states, order)| {
|
||||
authoring_component_active(states, order, COMPONENT_BRUSH_DESC).then(|| {
|
||||
(
|
||||
entity,
|
||||
brush.clone(),
|
||||
material
|
||||
.filter(|_| {
|
||||
authoring_component_active(states, order, COMPONENT_MATERIAL_DESC)
|
||||
})
|
||||
.cloned(),
|
||||
collider
|
||||
.filter(|_| {
|
||||
authoring_component_active(states, order, COMPONENT_COLLIDER_DESC)
|
||||
})
|
||||
.cloned(),
|
||||
)
|
||||
})
|
||||
})
|
||||
.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)| {
|
||||
(
|
||||
entity,
|
||||
renderer.clone(),
|
||||
material
|
||||
.filter(|_| inspector_component_active(order, COMPONENT_MATERIAL_DESC))
|
||||
.cloned(),
|
||||
material_override.cloned(),
|
||||
collider
|
||||
.filter(|_| inspector_component_active(order, COMPONENT_COLLIDER_DESC))
|
||||
.cloned(),
|
||||
order.cloned(),
|
||||
.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(|_| {
|
||||
authoring_component_active(
|
||||
states,
|
||||
order,
|
||||
COMPONENT_MATERIAL_DESC,
|
||||
)
|
||||
})
|
||||
.cloned(),
|
||||
material_override.cloned(),
|
||||
collider
|
||||
.filter(|_| {
|
||||
authoring_component_active(
|
||||
states,
|
||||
order,
|
||||
COMPONENT_COLLIDER_DESC,
|
||||
)
|
||||
})
|
||||
.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