From 96cd26d5b922026d2a16f8360f8f68eaab5f9384 Mon Sep 17 00:00:00 2001 From: Xein Date: Mon, 9 Nov 2026 14:41:35 +0100 Subject: [PATCH 1/5] more zoom on panels, resizes and J shortcut --- editor/VoxelEditor/VoxelEditorView.axaml | 29 ++++++++++++++------- editor/VoxelEditor/VoxelEditorView.axaml.cs | 11 +++++--- editor/VoxelEditor/VoxelEditorViewModel.cs | 3 ++- editor/VoxelEditor/VoxelOrthoView.cs | 2 +- 4 files changed, 30 insertions(+), 15 deletions(-) diff --git a/editor/VoxelEditor/VoxelEditorView.axaml b/editor/VoxelEditor/VoxelEditorView.axaml index b7adb892d..cf2095747 100644 --- a/editor/VoxelEditor/VoxelEditorView.axaml +++ b/editor/VoxelEditor/VoxelEditorView.axaml @@ -127,7 +127,7 @@ @@ -152,6 +152,7 @@ + @@ -271,24 +272,34 @@ ColumnDefinitions="*,4,340"> + RowDefinitions="*,4,*" + ColumnDefinitions="*,4,*"> - + - + + + RenderMode.VoxelPaletteId, VoxelViewMode.PhysicsMaterial => RenderMode.VoxelMaterials, VoxelViewMode.DdaSteps => RenderMode.VoxelSteps, + VoxelViewMode.Volumes => RenderMode.VoxelVolumes, _ => RenderMode.Lit }; } diff --git a/editor/VoxelEditor/VoxelOrthoView.cs b/editor/VoxelEditor/VoxelOrthoView.cs index cf871ba63..9ac52b0fe 100644 --- a/editor/VoxelEditor/VoxelOrthoView.cs +++ b/editor/VoxelEditor/VoxelOrthoView.cs @@ -19,7 +19,7 @@ public sealed class VoxelOrthoView : Control { AvaloniaProperty.Register(nameof(Projection)); private const double HandleSize = 8; - private const double MinScale = 1; + private const double MinScale = 0.01; // only keeps the maths finite private const double MaxScale = 80; private static readonly Typeface s_labelFont = new("Consolas"); From 5baf9d25b3deea86938a40106a70d4dd422c4b19 Mon Sep 17 00:00:00 2001 From: Xein Date: Mon, 9 Nov 2026 14:55:43 +0100 Subject: [PATCH 2/5] groups now can merge into a singular volume --- .../toast/voxel/nodes/procedural_voxel.cpp | 26 ++++++++++++++----- .../toast/voxel/nodes/procedural_voxel.hpp | 10 ++++--- engine/src/toast/voxel/nodes/voxel_group.hpp | 13 ++++++++++ engine/src/toast/world/workspace_voxel.cpp | 26 ++++++++++++++----- 4 files changed, 57 insertions(+), 18 deletions(-) diff --git a/engine/src/toast/voxel/nodes/procedural_voxel.cpp b/engine/src/toast/voxel/nodes/procedural_voxel.cpp index e168df045..63f1000f0 100644 --- a/engine/src/toast/voxel/nodes/procedural_voxel.cpp +++ b/engine/src/toast/voxel/nodes/procedural_voxel.cpp @@ -538,7 +538,7 @@ void ProceduralVoxel::rebuild() { refreshVolume(); } -void ProceduralVoxel::collect(Node& node, const glm::mat4& to_root, std::vector& out) { +void ProceduralVoxel::collect(Node& node, const glm::mat4& to_root, std::vector& out, uint64_t group_key) { for (const Box& child_box : node.children()) { if (!child_box.exists() || !child_box->enabled()) { continue; @@ -551,11 +551,12 @@ void ProceduralVoxel::collect(Node& node, const glm::mat4& to_root, std::vector< child_to_root = to_root * spatial->getTransform(); } if (auto* piece = reflect_cast(&child)) { - out.push_back({piece, child_to_root}); + out.push_back({piece, child_to_root, group_key}); // A piece can hold pieces too, they land right after it - collect(child, child_to_root, out); - } else if (reflect_cast(&child) != nullptr) { - collect(child, child_to_root, out); + collect(child, child_to_root, out, group_key); + } else if (auto* group = reflect_cast(&child)) { + uint64_t inner = group_key != 0 ? group_key : (group->mergesVolumes() ? partKeyOf(*group) : 0); + collect(child, child_to_root, out, inner); } } } @@ -597,6 +598,7 @@ auto ProceduralVoxel::keyOf(const std::vector& pieces) -> uint64_t { toast::hashCombine(key, entry.piece->uid().data()); toast::hashCombine(key, reinterpret_cast(entry.piece->info())); toast::hashCombine(key, entry.piece->inputRevision()); + toast::hashCombine(key, entry.group_key); hashFloats(key, &entry.to_root[0][0], 16); } @@ -676,7 +678,7 @@ auto ProceduralVoxel::land(const std::vector& pieces, bool wait) -> s .kind = entry.piece->kind(), .mode = entry.piece->writeMode(), .match_id = entry.piece->matchId(), - .key = partKeyOf(*entry.piece), + .key = entry.group_key != 0 ? entry.group_key : partKeyOf(*entry.piece), }); } return landings; @@ -881,6 +883,9 @@ auto ProceduralVoxel::compose(const ComposeInput& input) -> std::shared_ptr owner_keys; const auto claim = [&](uint64_t key) -> uint8_t { + if (const auto found = std::ranges::find(owner_keys, key); found != owner_keys.end()) { + return static_cast((found - owner_keys.begin()) + 1); + } if (owner_keys.size() >= 255) { out->too_many_parts = true; return 255; @@ -1178,7 +1183,14 @@ auto ProceduralVoxel::bakeParts() -> std::vector { std::vector out; out.reserve(output->parts.size()); for (ComposeOutput::Part& part : output->parts) { - out.push_back({.key = part.key, .origin = part.origin, .volume = std::move(part.volume)}); + uint64_t lead = 0; + for (const Landing& landing : input->landings) { + if (landing.key == part.key && landing.kind == PieceKind::fill) { + lead = landing.piece->uid().data(); + break; + } + } + out.push_back({.key = part.key, .lead = lead, .origin = part.origin, .volume = std::move(part.volume)}); } return out; } diff --git a/engine/src/toast/voxel/nodes/procedural_voxel.hpp b/engine/src/toast/voxel/nodes/procedural_voxel.hpp index d1a5a141d..264e57c46 100644 --- a/engine/src/toast/voxel/nodes/procedural_voxel.hpp +++ b/engine/src/toast/voxel/nodes/procedural_voxel.hpp @@ -102,13 +102,14 @@ class [[ToastNode, Color("Magenta")]] TOAST_API ProceduralVoxel : public VoxelNo }; [[nodiscard]] - static auto partKeyOf(const VoxelPiece& piece) noexcept -> uint64_t { - const uint64_t uid = piece.uid().data(); - return uid != 0 ? uid : (uint64_t {1} << 63) | piece.rid(); + static auto partKeyOf(const Node& node) noexcept -> uint64_t { + const uint64_t uid = node.uid().data(); + return uid != 0 ? uid : (uint64_t {1} << 63) | node.rid(); } struct BakedPart { uint64_t key = 0; + uint64_t lead = 0; glm::ivec3 origin {0}; std::unique_ptr volume; }; @@ -147,6 +148,7 @@ class [[ToastNode, Color("Magenta")]] TOAST_API ProceduralVoxel : public VoxelNo struct Collected { VoxelPiece* piece = nullptr; glm::mat4 to_root {1.0f}; + uint64_t group_key = 0; }; /** Everything needed to write one piece */ @@ -164,7 +166,7 @@ class [[ToastNode, Color("Magenta")]] TOAST_API ProceduralVoxel : public VoxelNo struct ComposeInput; struct ComposeOutput; - void collect(Node& node, const glm::mat4& to_root, std::vector& out); + void collect(Node& node, const glm::mat4& to_root, std::vector& out, uint64_t group_key = 0); struct BucketFill { VoxelBucket* bucket = nullptr; diff --git a/engine/src/toast/voxel/nodes/voxel_group.hpp b/engine/src/toast/voxel/nodes/voxel_group.hpp index 9de137555..7b75663f4 100644 --- a/engine/src/toast/voxel/nodes/voxel_group.hpp +++ b/engine/src/toast/voxel/nodes/voxel_group.hpp @@ -17,8 +17,21 @@ class [[ToastNode, Icon("Container"), Color("Magenta")]] TOAST_API VoxelGroup : [[nodiscard, Reflect]] auto getShape() -> Box; + /** @returns true when everything inside the group becomes one voxel volume */ + [[nodiscard, Reflect]] + auto mergesVolumes() const noexcept -> bool { + return m_merge_volumes; + } + protected: void updateInspectorMessages() override; + + /** + * Makes everything inside the group one voxel volume + * @note It renders, collides, breaks and bakes as a single volume, the pieces still apply in order and keep their fill modes + */ + [[Reflect, Name("Merge Volumes")]] + bool m_merge_volumes = false; }; } diff --git a/engine/src/toast/world/workspace_voxel.cpp b/engine/src/toast/world/workspace_voxel.cpp index 64724c380..dc5536570 100644 --- a/engine/src/toast/world/workspace_voxel.cpp +++ b/engine/src/toast/world/workspace_voxel.cpp @@ -16,6 +16,7 @@ #include #include #include +#include namespace toast { @@ -1143,6 +1144,7 @@ void Workspace::subscribeVoxelEditing() { }; // by piece std::unordered_map written; + std::unordered_set used_files; { std::unordered_map paths; @@ -1155,8 +1157,9 @@ void Workspace::subscribeVoxelEditing() { std::string_view path; if (part.key == 0) { path = e.base_path; - } else if (const auto found = paths.find(part.key); found != paths.end()) { + } else if (const auto found = paths.find(part.lead); found != paths.end()) { path = found->second; + used_files.insert(part.lead); } if (path.empty()) { continue; @@ -1194,7 +1197,7 @@ void Workspace::subscribeVoxelEditing() { leftovers.unused.push_back(e.base_path); } for (const event::VoxelBakePiece& piece : e.pieces) { - if (!written.contains(piece.uid.data())) { + if (!used_files.contains(piece.uid.data())) { leftovers.unused.push_back(piece.path); } } @@ -1264,7 +1267,8 @@ void Workspace::subscribeVoxelEditing() { // groups stay groups // every other piece becomes a VoxelNode - const auto mirror = [&](this auto&& self, Node& source, Box into, const glm::mat4& into_to_shape) -> void { + const auto mirror = + [&](this auto&& self, Node& source, Box into, const glm::mat4& into_to_shape, bool merged) -> void { std::vector> others; for (const Box& child_box : source.children()) { if (!child_box.exists()) { @@ -1286,17 +1290,25 @@ void Workspace::subscribeVoxelEditing() { made_3d->rotation = group->rotation; made_3d->scale = group->scale; made_3d->syncTransform(); - self(child, made, into_to_shape * group->getTransform()); + const glm::mat4 group_to_shape = into_to_shape * group->getTransform(); + bool inside = merged; + if (!merged && group->mergesVolumes()) { + inside = true; + if (const auto found = written.find(ProceduralVoxel::partKeyOf(*group)); found != written.end()) { + make_model(made, group_to_shape, group->name(), found->second); + } + } + self(child, made, group_to_shape, inside); } else if (auto* piece = reflect_cast(&child)) { if (!child.enabled()) { continue; } - if (piece->kind() == PieceKind::fill) { + if (!merged && piece->kind() == PieceKind::fill) { if (const auto found = written.find(ProceduralVoxel::partKeyOf(*piece)); found != written.end()) { make_model(into, into_to_shape, piece->name(), found->second); } } - self(child, into, into_to_shape); + self(child, into, into_to_shape, merged); } else if (reflect_cast(&child) == nullptr) { others.push_back(child_box); } @@ -1305,7 +1317,7 @@ void Workspace::subscribeVoxelEditing() { voxelReparent(other, into, into->m_children.size()); } }; - mirror(*shape, root, glm::mat4(1.0f)); + mirror(*shape, root, glm::mat4(1.0f), false); std::erase(parent->m_children, target); destroyOwnedTree(target); From 439de08fab8a45da86d94dd63a355667d8807208 Mon Sep 17 00:00:00 2001 From: Xein Date: Tue, 10 Nov 2026 00:31:09 +0100 Subject: [PATCH 3/5] debris shit sf --- editor/App.axaml | 2 + editor/Workspace/DockFactory.cs | 16 + editor/Workspace/MainWindowViewModel.cs | 13 +- editor/Workspace/WorkspaceViewModel.cs | 5 + engine/assets/shaders/debris.slang | 291 ++++++++++++++---- engine/src/toast/physics/debris.hpp | 7 +- .../src/toast/physics/debris_simulation.cpp | 10 +- .../src/toast/physics/debris_simulation.hpp | 9 +- engine/src/toast/physics/physics_settings.cpp | 9 + engine/src/toast/physics/physics_settings.hpp | 1 + engine/src/toast/physics/simulator.cpp | 66 ++-- 11 files changed, 316 insertions(+), 113 deletions(-) diff --git a/editor/App.axaml b/editor/App.axaml index 3aed34e6d..dd60b9ae8 100644 --- a/editor/App.axaml +++ b/editor/App.axaml @@ -440,6 +440,8 @@ diff --git a/editor/Workspace/DockFactory.cs b/editor/Workspace/DockFactory.cs index 519b33426..3d66044d3 100644 --- a/editor/Workspace/DockFactory.cs +++ b/editor/Workspace/DockFactory.cs @@ -1,7 +1,9 @@ using System; using System.Collections.Generic; +using System.ComponentModel; using System.Linq; using System.Threading.Tasks; +using Avalonia.Threading; using Dock.Model.Controls; using Dock.Model.Core; using Dock.Model.Mvvm; @@ -118,9 +120,23 @@ public override IRootDock CreateLayout() { m_rootDock = root; m_documentDock = documentDock; + documentDock.PropertyChanged += OnDocumentDockChanged; return root; } + private void OnDocumentDockChanged(object? sender, PropertyChangedEventArgs e) { + if (e.PropertyName != nameof(IDock.ActiveDockable) || !WorkspaceViewModel.AnyPlayActive) return; + if (m_documentDock?.ActiveDockable is not WorkspaceViewModel { IsPlayModeActive: false }) return; + + var playing = m_documentDock.VisibleDockables?.OfType().FirstOrDefault(w => w.IsPlayModeActive); + if (playing is null) return; + + Dispatcher.UIThread.Post(() => { + if (playing.IsPlayModeActive && m_documentDock?.VisibleDockables?.Contains(playing) == true) + SetActiveDockable(playing); + }); + } + public override void InitLayout(IDockable layout) { ContextLocator = new Dictionary> { ["Workspace"] = () => layout, diff --git a/editor/Workspace/MainWindowViewModel.cs b/editor/Workspace/MainWindowViewModel.cs index a89caf1c9..274b61f8b 100644 --- a/editor/Workspace/MainWindowViewModel.cs +++ b/editor/Workspace/MainWindowViewModel.cs @@ -203,6 +203,9 @@ private void OnPlayModeChanged() { SaveAllNodesCommand.NotifyCanExecuteChanged(); PlayCommand.NotifyCanExecuteChanged(); PlayInWindowCommand.NotifyCanExecuteChanged(); + NewNodeCommand.NotifyCanExecuteChanged(); + OpenNodeFileCommand.NotifyCanExecuteChanged(); + foreach (var workspace in m_workspaces.Values) workspace.RefreshCanActivateTab(); } private IEnumerable EnumerateAutosavables() { @@ -403,6 +406,7 @@ private async void OnEditorOpenRequested(AssetFile file) { if (file.Definition is not { CanBeEdited: true } def) return; if (file.Uid is not { } uid) return; if (!AssetDatabase.TryResolve(uid, out var virtualPath, out _)) return; + if (def.EditorTool == "NodeEditor" && !CanOpenNodes()) return; // Opening a lockable asset nobody holds takes its lock if (def is not ProjectSettingsAsset && def.EditorTool != "NodeEditor") @@ -823,7 +827,12 @@ private async Task DeleteLayout(string? name) { if (string.Equals(ActiveLayoutName, name, StringComparison.Ordinal)) ResetLayout(); } - [RelayCommand] + // no tab may open under a running game + private static bool CanOpenNodes() { + return !WorkspaceViewModel.AnyPlayActive; + } + + [RelayCommand(CanExecute = nameof(CanOpenNodes))] private async Task NewNode() { if (App.MainWindow is not { } owner) return; var popup = new NodeTypeTree(); @@ -835,7 +844,7 @@ private async Task NewNode() { SyncActiveWorkspace(); } - [RelayCommand] + [RelayCommand(CanExecute = nameof(CanOpenNodes))] private async Task OpenNodeFile() { if (App.MainWindow is not { } owner) return; var uid = await new AssetList("Node").ShowDialog(owner); diff --git a/editor/Workspace/WorkspaceViewModel.cs b/editor/Workspace/WorkspaceViewModel.cs index 19d497c0e..8f877291e 100644 --- a/editor/Workspace/WorkspaceViewModel.cs +++ b/editor/Workspace/WorkspaceViewModel.cs @@ -138,6 +138,11 @@ private void UpdateAdvancing() { public bool IsPlayingInViewport => PlayState is PlayState.Playing or PlayState.Simulating; public bool IsPlayingExternal => PlayState == PlayState.PlayingExternal; public bool IsPlayModeActive => PlayState != PlayState.Stopped; + public bool CanActivateTab => !AnyPlayActive || IsPlayModeActive; + + public void RefreshCanActivateTab() { + OnPropertyChanged(nameof(CanActivateTab)); + } public bool CanPause => PlayState != PlayState.Stopped; public bool IsOrbitCamera => CameraMode == CameraMode.Orbit; diff --git a/engine/assets/shaders/debris.slang b/engine/assets/shaders/debris.slang index fd796f7f3..5996268a8 100644 --- a/engine/assets/shaders/debris.slang +++ b/engine/assets/shaders/debris.slang @@ -14,7 +14,11 @@ struct Params { uint clear; uint retireCount; float fadeOut; + float maxSpeed; + float maxSpin; uint pad0; + uint pad1; + uint pad2; }; [[vk::push_constant]] @@ -84,6 +88,8 @@ StructuredBuffer gSpawns; StructuredBuffer gVolumes; [[vk::binding(7, 0)]] StructuredBuffer gRetires; +[[vk::binding(8, 0)]] +RWStructuredBuffer gGrid; struct VolumeRecord { uint brickDimsX; @@ -123,12 +129,27 @@ static const float kSpawnScale = 0.4; // grows from this share of its size static const float kSleepSpeed = 0.2; // below this speed a piece may sleep static const uint kSleepSteps = 15; -static const float kBounceSpeed = 0.5; // slower impact sticks +static const float kBounceSpeed = 0.5; static const float kContactSlop = 0.001; // next step does not start touching static const float kFloorZ = 0.0f; // lowest height a piece reaches static const uint kMaxSweeps = 8; static const float kSleepSpin = 1.0; -static const float kMaxSpin = 30.0; +static const uint kMaxContacts = 12; +static const uint kContactPasses = 2; +static const float kMaxCorrection = 0.5; // voxels a piece is moved out per sweep +static const float kInertia = 0.667; +static const float kAirDrag = 0.15; +static const float kSpinDrag = 0.3; +static const float kRollDrag = 6.0; +static const float kSpawnGrace = 0.25; // seconds a new piece ignores being buried +static const uint kGridCells = 1u << 16; // mirrors DebrisSimulation::k_grid_cells +static const uint kGridStride = 8; // mirrors DebrisSimulation::k_grid_stride +static const uint kGridSlots = kGridStride - 1u; +static const float kCrowdRelax = 0.6; +static const float kMaxCrowdPush = 0.5; // units a piece is moved by the others per step +static const float kCrowdFriction = 0.3; +static const float kSupportSpeed = 0.5; +static const float kWakeSpeed = 1.0; // a hit faster than this wakes a sleeping piece // Graphics queue patches sections in place unordered with this queue so new records can still be uninitialized headroom // No robust buffer access so every index is checked against the buffer @@ -206,6 +227,18 @@ bool latticePresent(uint mask, int3 p) { return false; } +int3 gridCell(float3 position) { + return int3(floor(position / (2.0 * gParams.voxelSize))); +} + +uint gridHash(int3 cell) { + return ((uint(cell.x) * 73856093u) ^ (uint(cell.y) * 19349663u) ^ (uint(cell.z) * 83492791u)) & (kGridCells - 1u); +} + +float pieceRadius(uint mask, float scale) { + return gParams.voxelSize * scale * lerp(0.6, 1.0, float(countbits(mask & kMaskBits)) / 8.0); +} + // Grows in after spawning and shrinks away before dying float displayScale(DebrisMotion motion) { float grow = lerp(kSpawnScale, 1.0, saturate(motion.age / max(gParams.fadeIn, 1e-4))); @@ -241,7 +274,7 @@ Candidates gatherCandidates(float3 centre, float radius) { return candidates; } -Contact contactAt(float3 world, Candidates candidates) { +Contact contactAt(float3 world, Candidates candidates, bool buried) { // Deepest overlap of a point Contact contact; contact.hit = false; @@ -282,6 +315,9 @@ Contact contactAt(float3 world, Candidates candidates) { } if (depthVoxels > 1e8) { + if (!buried) { + continue; + } // Buried so leave towards world up float3 up = mul(volume.worldToVoxel, float4(0.0, 0.0, 1.0, 0.0)).xyz; float3 magnitude = abs(up); @@ -354,6 +390,34 @@ void retireMain(uint3 id : SV_DispatchThreadID) { gMotion[slot] = motion; } +[shader("compute")] +[numthreads(kGroupSize, 1, 1)] +void gridClearMain(uint3 id : SV_DispatchThreadID) { + if (id.x < kGridCells) { + gGrid[id.x * kGridStride] = 0u; + } +} + +// Bins every live piece by where the last step left it +[shader("compute")] +[numthreads(kGroupSize, 1, 1)] +void gridBuildMain(uint3 id : SV_DispatchThreadID) { + uint i = id.x; + if (i >= gParams.capacity || gParams.clear != 0u) { + return; + } + DebrisParticle particle = gPrevious[i]; + if ((particle.packed & kMaskBits) == 0u) { + return; + } + uint bucket = gridHash(gridCell(particle.position)) * kGridStride; + uint place = 0u; + InterlockedAdd(gGrid[bucket], 1u, place); + if (place < kGridSlots) { + gGrid[bucket + 1u + place] = i; + } +} + [shader("compute")] [numthreads(kGroupSize, 1, 1)] void integrateMain(uint3 id : SV_DispatchThreadID, uint3 groupThread : SV_GroupThreadID) { @@ -387,19 +451,80 @@ void integrateMain(uint3 id : SV_DispatchThreadID, uint3 groupThread : SV_GroupT gMotion[i] = motion; return; } - particle.scale = displayScale(motion); const bool glass = (particle.packed & kFlagGlass) != 0u; + const bool asleep = (particle.packed & kFlagAsleep) != 0u; + const float unit = gParams.voxelSize * motion.baseScale; + + // Other pieces count as spheres and are read where the last step left them + float3 crowdPush = float3(0.0, 0.0, 0.0); + float3 crowdVelocity = float3(0.0, 0.0, 0.0); + float3 supportSum = float3(0.0, 0.0, 0.0); + bool struck = false; + { + float radius = pieceRadius(mask, particle.scale); + int3 base = gridCell(particle.position); + for (int cz = -1; cz <= 1; ++cz) { + for (int cy = -1; cy <= 1; ++cy) { + for (int cx = -1; cx <= 1; ++cx) { + int3 cell = base + int3(cx, cy, cz); + uint bucket = gridHash(cell) * kGridStride; + uint count = min(gGrid[bucket], kGridSlots); + for (uint slot = 0u; slot < count; ++slot) { + uint j = gGrid[bucket + 1u + slot]; + if (j == i || j >= gParams.capacity) { + continue; + } + DebrisParticle other = gPrevious[j]; + uint otherMask = other.packed & kMaskBits; + if (otherMask == 0u || any(gridCell(other.position) != cell)) { + continue; + } + + float3 delta = particle.position - other.position; + float reach = radius + pieceRadius(otherMask, other.scale); + float distanceSq = dot(delta, delta); + if (distanceSq >= reach * reach) { + continue; + } + float distance = sqrt(distanceSq); + float3 normal = distance > 1e-5 ? delta / distance : float3(0.0, 0.0, i > j ? 1.0 : -1.0); + bool otherAsleep = (other.packed & kFlagAsleep) != 0u; + float share = otherAsleep ? 1.0 : 0.5; + + float extra = (reach - distance) * share - dot(crowdPush, normal); + if (extra > 0.0) { + crowdPush += normal * extra; + } + + float3 relative = particle.velocity + crowdVelocity - other.velocity; + float closing = dot(relative, normal); + if (closing < 0.0) { + crowdVelocity -= normal * (closing * share); + float3 slide = relative - closing * normal; + crowdVelocity -= slide * (share * kCrowdFriction * saturate(gParams.friction)); + struck = struck || (!otherAsleep && closing < -kWakeSpeed); + } + if (normal.z > 0.3 && dot(other.velocity, other.velocity) < kSupportSpeed * kSupportSpeed) { + supportSum += normal; + } + } + } + } + } + } + const bool heldUp = dot(supportSum, supportSum) > 1e-6; + const float3 supportNormal = heldUp ? normalize(supportSum) : float3(0.0, 0.0, 1.0); // Farthest point plus the floor probe below it plus how far gravity and velocity carry it this step - float travel = (length(particle.velocity) + length(gParams.gravity) * stepTime) * stepTime; - float reach = 1.74 * gParams.voxelSize * motion.baseScale + 1.5 * gParams.voxelSize + travel; + float travel = (length(particle.velocity) + length(crowdVelocity) + length(gParams.gravity) * stepTime) * stepTime; + float reach = 1.74 * unit + 1.5 * gParams.voxelSize + travel + kMaxCrowdPush * unit; Candidates candidates = gatherCandidates(particle.position, reach); + const bool settled = motion.age > kSpawnGrace; - if ((particle.packed & kFlagAsleep) != 0u) { + if (asleep) { // Wakes once nothing supports it // probes under the corners - float sleepUnit = gParams.voxelSize * motion.baseScale; - bool supported = false; + bool supported = heldUp; for (int pz = -1; pz <= 1 && !supported; ++pz) { for (int py = -1; py <= 1 && !supported; ++py) { for (int px = -1; px <= 1 && !supported; ++px) { @@ -407,14 +532,15 @@ void integrateMain(uint3 id : SV_DispatchThreadID, uint3 groupThread : SV_GroupT if (all(p == 0) || !latticePresent(mask, p)) { continue; } - float3 corner = particle.position + rotateByQuat(particle.rotation, float3(p) * sleepUnit); + float3 corner = particle.position + rotateByQuat(particle.rotation, float3(p) * unit); supported = corner.z <= kFloorZ + 2.0 * kContactSlop || - contactAt(corner - float3(0.0, 0.0, 0.25 * gParams.voxelSize), candidates).hit; + contactAt(corner - float3(0.0, 0.0, 0.25 * gParams.voxelSize), candidates, true).hit; } } } - if (supported) { + if (supported && !struck) { + particle.scale = displayScale(motion); gCurrent[i] = particle; gMotion[i] = motion; appendVoxels(i, mask, glass); @@ -424,10 +550,27 @@ void integrateMain(uint3 id : SV_DispatchThreadID, uint3 groupThread : SV_GroupT motion.restSteps = 0u; } - bool touching = false; + particle.velocity += crowdVelocity; + float crowdDistance = length(crowdPush) * kCrowdRelax; + if (crowdDistance > 1e-6) { + particle.position += crowdPush * (kCrowdRelax * min(1.0, kMaxCrowdPush * unit / crowdDistance)); + } + + // Unit mass solid cube two units wide + const float invInertia = 1.0 / max(kInertia * unit * unit, 1e-8); + bool touching = heldUp; for (uint s = 0u; s < gParams.substeps; ++s) { float dt = gParams.dt; particle.velocity += gParams.gravity * dt; + particle.velocity *= 1.0 - saturate(kAirDrag * dt); + motion.angular *= 1.0 - saturate(kSpinDrag * dt); + if (heldUp) { + // The pieces under it carry it + float sinking = dot(particle.velocity, supportNormal); + if (sinking < 0.0) { + particle.velocity -= supportNormal * sinking; + } + } uint sweeps = clamp(uint(ceil(length(particle.velocity) * dt / (0.5 * gParams.voxelSize))), 1u, kMaxSweeps); float sweepDt = dt / float(sweeps); @@ -440,14 +583,10 @@ void integrateMain(uint3 id : SV_DispatchThreadID, uint3 groupThread : SV_GroupT } // The piece is tested by the corners of its cubes - float unit = gParams.voxelSize * motion.baseScale; - float floorDepth = 0.0; - Contact deepest; - deepest.hit = false; - deepest.normal = float3(0.0, 0.0, 1.0); - deepest.depth = 0.0; - float3 hitOffsetSum = float3(0.0, 0.0, 0.0); - float hitCount = 0.0; + float3 contactOffset[kMaxContacts]; + float3 contactNormal[kMaxContacts]; + uint contactCount = 0u; + float3 correction = float3(0.0, 0.0, 0.0); for (int pz = -1; pz <= 1; ++pz) { for (int py = -1; py <= 1; ++py) { for (int px = -1; px <= 1; ++px) { @@ -457,61 +596,86 @@ void integrateMain(uint3 id : SV_DispatchThreadID, uint3 groupThread : SV_GroupT } float3 offset = rotateByQuat(particle.rotation, float3(p) * unit); float3 point = particle.position + offset; - floorDepth = max(floorDepth, kFloorZ - point.z); - Contact contact = contactAt(point, candidates); - if (contact.hit) { - hitOffsetSum += offset; - hitCount += 1.0; - if (!deepest.hit || contact.depth > deepest.depth) { - deepest = contact; - } + + // Nothing goes under the ground plane + Contact contact = contactAt(point, candidates, settled); + float floorDepth = kFloorZ - point.z; + if (floorDepth > 0.0 && (!contact.hit || floorDepth > contact.depth)) { + contact.hit = true; + contact.normal = float3(0.0, 0.0, 1.0); + contact.depth = floorDepth; + } + if (!contact.hit) { + continue; + } + + // Leaves along every normal by the deepest point on it + float extra = contact.depth - dot(correction, contact.normal); + if (extra > 0.0) { + correction += contact.normal * extra; + } + if (contactCount < kMaxContacts) { + contactOffset[contactCount] = offset; + contactNormal[contactCount] = contact.normal; + ++contactCount; } } } } - // Nothing goes under the ground plane - if (floorDepth > 0.0) { - particle.position.z += floorDepth; - // It stops dead, otherwise it keeps spinning and sliding along the floor - particle.velocity = float3(0.0, 0.0, 0.0); - motion.angular = float3(0.0, 0.0, 0.0); - touching = true; + if (contactCount == 0u) { + continue; } + touching = true; - if (deepest.hit) { - touching = true; - particle.position += deepest.normal * (deepest.depth + kContactSlop); + float correctionLength = length(correction); + if (correctionLength > 1e-7) { + float moved = min(correctionLength, kMaxCorrection * gParams.voxelSize) + kContactSlop; + particle.position += correction * (moved / correctionLength); + } + + for (uint pass = 0u; pass < kContactPasses; ++pass) { + for (uint c = 0u; c < contactCount; ++c) { + float3 arm = contactOffset[c]; + float3 normal = contactNormal[c]; + float3 pointVelocity = particle.velocity + cross(motion.angular, arm); + float approach = dot(pointVelocity, normal); + if (approach >= 0.0) { + continue; + } - float approach = dot(particle.velocity, deepest.normal); - if (approach < 0.0) { - float3 before = particle.velocity; - float3 tangent = particle.velocity - approach * deepest.normal; - float bounce = -approach > kBounceSpeed ? -approach * gParams.restitution : 0.0; + float3 armNormal = cross(arm, normal); + float normalMass = 1.0 + invInertia * dot(armNormal, armNormal); + // slower impact sticks + float bounce = (pass == 0u && -approach > kBounceSpeed) ? gParams.restitution : 0.0; + float normalImpulse = -(1.0 + bounce) * approach / normalMass; + float3 impulse = normal * normalImpulse; // Friction only takes what the normal impulse allows - float tangentSpeed = length(tangent); - float grip = min(tangentSpeed, gParams.friction * (-approach + bounce)); - if (tangentSpeed > 1e-6) { - tangent -= tangent * (grip / tangentSpeed); - } - particle.velocity = tangent + bounce * deepest.normal; - - if (-approach > kBounceSpeed) { - // cube inertia - float3 contactOffset = hitOffsetSum / max(hitCount, 1.0); - float size = gParams.voxelSize * motion.baseScale; - motion.angular *= saturate(1.0 - gParams.friction); - motion.angular += cross(contactOffset, particle.velocity - before) * (1.5 / max(size * size, 1e-6)); - float angularSpeed = length(motion.angular); - if (angularSpeed > kMaxSpin) { - motion.angular *= kMaxSpin / angularSpeed; - } - } else { - motion.angular *= 0.5; + float3 slide = pointVelocity - approach * normal; + float slideSpeed = length(slide); + if (slideSpeed > 1e-5) { + float3 tangent = slide / slideSpeed; + float3 armTangent = cross(arm, tangent); + float tangentMass = 1.0 + invInertia * dot(armTangent, armTangent); + impulse -= tangent * min(slideSpeed / tangentMass, gParams.friction * normalImpulse); } + + particle.velocity += impulse; + motion.angular += invInertia * cross(arm, impulse); } } + + motion.angular *= 1.0 - saturate(kRollDrag * sweepDt); + } + + float speed = length(particle.velocity); + if (gParams.maxSpeed > 0.0 && speed > gParams.maxSpeed) { + particle.velocity *= gParams.maxSpeed / speed; + } + float angularSpeed = length(motion.angular); + if (gParams.maxSpin > 0.0 && angularSpeed > gParams.maxSpin) { + motion.angular *= gParams.maxSpin / angularSpeed; } } @@ -527,6 +691,7 @@ void integrateMain(uint3 id : SV_DispatchThreadID, uint3 groupThread : SV_GroupT particle.packed |= kFlagAsleep; } + particle.scale = displayScale(motion); gCurrent[i] = particle; gMotion[i] = motion; appendVoxels(i, mask, glass); diff --git a/engine/src/toast/physics/debris.hpp b/engine/src/toast/physics/debris.hpp index acf33b1db..8ce31db86 100644 --- a/engine/src/toast/physics/debris.hpp +++ b/engine/src/toast/physics/debris.hpp @@ -63,12 +63,13 @@ struct DebrisBlast { }; inline constexpr float k_max_debris_speed = 40.0f; -inline constexpr float k_debris_cone_spread = 0.5f; +inline constexpr float k_debris_cone_spread = 0.8f; inline constexpr float k_debris_max_spin = 8.0f; inline constexpr float k_debris_min_scale = 0.55f; inline constexpr float k_debris_lookahead_voxels = 3.0f; -inline constexpr float k_debris_spawn_restitution = 0.3f; -inline constexpr float k_debris_mouth_spread = 0.6f; +inline constexpr float k_debris_ejecta_share = 0.25f; +inline constexpr float k_debris_min_fling = 0.15f; +inline constexpr float k_debris_mouth_spread = 1.0f; [[nodiscard]] constexpr auto debrisBit(const glm::ivec3& local) -> uint32_t { diff --git a/engine/src/toast/physics/debris_simulation.cpp b/engine/src/toast/physics/debris_simulation.cpp index b1921a48c..dedde5686 100644 --- a/engine/src/toast/physics/debris_simulation.cpp +++ b/engine/src/toast/physics/debris_simulation.cpp @@ -19,6 +19,8 @@ constexpr uint32_t k_debris_reset_entry = 0; constexpr uint32_t k_debris_integrate_entry = 1; constexpr uint32_t k_debris_spawn_entry = 2; constexpr uint32_t k_debris_retire_entry = 3; +constexpr uint32_t k_debris_grid_clear_entry = 4; +constexpr uint32_t k_debris_grid_build_entry = 5; constexpr float k_debris_min_lifetime = 0.5f; constexpr double k_debris_min_extrapolation = -0.1; constexpr double k_debris_max_extrapolation = 0.25; @@ -33,7 +35,7 @@ auto debrisGroups(uint32_t count) -> uint32_t { DebrisSimulation::DebrisSimulation(uint32_t max_particles) : AsyncComputeTask({ .shader_uri = "core://shaders/debris.slang", - .entries = {"resetMain", "integrateMain", "spawnMain", "retireMain"}, + .entries = {"resetMain", "integrateMain", "spawnMain", "retireMain", "gridClearMain", "gridBuildMain"}, .debug_name = "Debris" }), m_capacity(std::max(max_particles, 1u) + (std::max(max_particles, 1u) / k_cap_headroom_divisor)), @@ -52,6 +54,7 @@ DebrisSimulation::DebrisSimulation(uint32_t max_particles) m_spawns = addHostBuffer(sizeof(GpuSpawn) * k_max_spawns_per_step); m_volumes = addHostBuffer(sizeof(GpuVolume) * k_max_volumes); m_retires = addHostBuffer(sizeof(uint32_t) * k_max_spawns_per_step); + m_grid = addPersistentBuffer(sizeof(uint32_t) * k_grid_cells * k_grid_stride); setAcquireUsage(m_draw_args, renderer::sync::Usage::indirect_read); bind(0, m_particles, Copy::previous); @@ -62,6 +65,7 @@ DebrisSimulation::DebrisSimulation(uint32_t max_particles) bind(5, m_spawns); bind(6, m_volumes); bind(7, m_retires); + bind(8, m_grid); const auto section = [this](renderer::VoxelGpuStorage::Section which) { return [this, which]() -> vk::Buffer { @@ -149,6 +153,8 @@ void DebrisSimulation::step(uint32_t steps, double fixed_delta, std::shared_ptr< .substeps = substeps, .clear = m_clear_pending ? 1u : 0u, .fade_out = k_fade_out_seconds, + .max_speed = k_max_debris_speed, + .max_spin = settings.debris_max_spin, }; m_world = world; @@ -177,6 +183,8 @@ void DebrisSimulation::step(uint32_t steps, double fixed_delta, std::shared_ptr< if (params.retire_count > 0) { ctx.dispatch(debrisGroups(params.retire_count), params, k_debris_retire_entry); } + ctx.dispatch(debrisGroups(k_grid_cells), params, k_debris_grid_clear_entry); + ctx.dispatch(debrisGroups(m_capacity), params, k_debris_grid_build_entry); ctx.dispatch(debrisGroups(m_capacity), params, k_debris_integrate_entry); if (params.spawn_count > 0) { ctx.dispatch(debrisGroups(params.spawn_count), params, k_debris_spawn_entry); diff --git a/engine/src/toast/physics/debris_simulation.hpp b/engine/src/toast/physics/debris_simulation.hpp index 86c45e534..3d94a6950 100644 --- a/engine/src/toast/physics/debris_simulation.hpp +++ b/engine/src/toast/physics/debris_simulation.hpp @@ -89,6 +89,8 @@ class TOAST_API DebrisSimulation : public renderer::AsyncComputeTask { static constexpr float k_fade_out_seconds = 0.6f; /// Slots past the cap so pieces shrinking away still have room static constexpr uint32_t k_cap_headroom_divisor = 4; + static constexpr uint32_t k_grid_cells = 1u << 16u; + static constexpr uint32_t k_grid_stride = 8; explicit DebrisSimulation(uint32_t max_particles); ~DebrisSimulation() override; @@ -149,10 +151,12 @@ class TOAST_API DebrisSimulation : public renderer::AsyncComputeTask { uint32_t clear = 0; uint32_t retire_count = 0; float fade_out = 0.0f; - uint32_t pad = 0; + float max_speed = 0.0f; + float max_spin = 0.0f; + std::array pad {}; }; - static_assert(sizeof(Params) == 64, "Params is mirrored by debris.slang"); + static_assert(sizeof(Params) == 80, "Params is mirrored by debris.slang"); static constexpr uint64_t k_step_history = 16; @@ -182,6 +186,7 @@ class TOAST_API DebrisSimulation : public renderer::AsyncComputeTask { BufferId m_spawns = 0; BufferId m_volumes = 0; BufferId m_retires = 0; + BufferId m_grid = 0; std::deque m_pending; bool m_clear_pending = false; diff --git a/engine/src/toast/physics/physics_settings.cpp b/engine/src/toast/physics/physics_settings.cpp index 4358a96e4..64438da57 100644 --- a/engine/src/toast/physics/physics_settings.cpp +++ b/engine/src/toast/physics/physics_settings.cpp @@ -493,6 +493,15 @@ void registerPhysicsSettings() { .max = 40.0f, .step = 0.5f}, &Tunables::debris_max_speed); + flt("physics.debris.max_spin", + 20.0, + {.label = "Max spin", + .category = "Debris", + .description = "Fastest a piece of debris can spin, in rad/s", + .min = 1.0f, + .max = 60.0f, + .step = 1.0f}, + &Tunables::debris_max_spin); flt("physics.debris.lifetime", 12.0, {.label = "Lifetime", .category = "Debris", .min = 0.5, .max = 600.0, .step = 0.5}, diff --git a/engine/src/toast/physics/physics_settings.hpp b/engine/src/toast/physics/physics_settings.hpp index 2c2964ba4..4e4d94acb 100644 --- a/engine/src/toast/physics/physics_settings.hpp +++ b/engine/src/toast/physics/physics_settings.hpp @@ -49,6 +49,7 @@ struct Tunables { float debris_speed_scale = 1.0f; float debris_density_multiplier = 1.0f; float debris_max_speed = 12.0f; + float debris_max_spin = 20.0f; float debris_lifetime = 12.0f; float debris_lifetime_jitter = 0.3f; uint32_t debris_max_particles = 16384; diff --git a/engine/src/toast/physics/simulator.cpp b/engine/src/toast/physics/simulator.cpp index 6febf3afe..9b2713050 100644 --- a/engine/src/toast/physics/simulator.cpp +++ b/engine/src/toast/physics/simulator.cpp @@ -964,6 +964,7 @@ void Simulator::emitDebris( const float energy_each = blast != nullptr ? blast->energy * k_joules_per_kilojoule / static_cast(clusters.size()) : 0.0f; const float speed_scale = tunables().debris_speed_scale; + const float max_speed = tunables().debris_max_speed; const glm::vec3 push_direction = push != nullptr ? push->direction : glm::vec3 {0.0f}; const float push_length = glm::length(push_direction); @@ -1031,6 +1032,7 @@ void Simulator::emitDebris( // starts with the speed of the point it broke off from glm::vec3 velocity = body->linear_velocity + glm::cross(body->angular_velocity, position - world_com); + glm::vec3 thrown {0.0f}; if (blast != nullptr) { // thrown outward from the crater axis inside a cone @@ -1040,82 +1042,62 @@ void Simulator::emitDebris( const glm::vec3 axis_local = glm::dot(mouth, mouth) > 0.0f ? glm::normalize(mouth + (radial * k_debris_mouth_spread)) : radial; const glm::vec3 axis = body->rotation * (shape->voxel.local_rotation * axis_local); - float speed = debrisSpeed(energy_each, mass, speed_scale, tunables().debris_max_speed); + float speed = debrisSpeed(energy_each * k_debris_ejecta_share, mass, speed_scale, max_speed); if (blast->max_speed > 0.0f) { speed = std::min(speed, blast->max_speed); } - velocity += debrisConeDirection(axis, rng, k_debris_cone_spread) * speed; + const float fling = rng.uniform(); + speed *= glm::mix(k_debris_min_fling, 1.0f, fling * fling * fling); + thrown += debrisConeDirection(axis, rng, k_debris_cone_spread) * speed; } glm::vec3 push_normal {0.0f}; const bool push_open = push_length > 1.0e-6f && (blast == nullptr || not blocked_ahead(start, to_lattice * (push_direction / push_length), push_normal)); if (push != nullptr && push_open && mass > 0.0f) { - const float speed = std::min({push->speed, push->max_impulse / mass, tunables().debris_max_speed}); - if (std::isfinite(speed) && speed > 0.0f) { - velocity += (push_direction / push_length) * speed; + float speed = std::min({push->speed, push->max_impulse / mass, max_speed}); + if (not together) { + speed *= rng.range(k_debris_min_fling, 1.0f); } - } - - float scale = cluster.whole ? cluster.scale : rng.range(k_debris_min_scale, 1.0f); - const auto overlaps_solid = [&](float s) { - for (uint32_t bit = 0; bit < 8; ++bit) { - if ((cluster.mask & (1u << bit)) == 0) { - continue; - } - const glm::vec3 cube_center = - start + - (glm::vec3(static_cast(bit & 1u), static_cast((bit >> 1u) & 1u), static_cast(bit >> 2u)) - - 0.5f) * - s; - for (uint32_t corner = 0; corner < 8; ++corner) { - // corner on the border dorstnt counts the neighbour - const glm::vec3 sign = - glm::vec3( - static_cast(corner & 1u), static_cast((corner >> 1u) & 1u), static_cast(corner >> 2u) - ) - - 0.5f; - if (solid_at(glm::ivec3(glm::floor(cube_center + (sign * 0.96f * s))))) { - return true; - } - } + if (std::isfinite(speed) && speed > 0.0f) { + thrown += (push_direction / push_length) * speed; } - return false; - }; - for (int attempt = 0; attempt < 3 && overlaps_solid(scale); ++attempt) { - scale *= 0.8f; - } - if (overlaps_solid(scale)) { - continue; } - // a piece never goes into solid voxels + const float scale = cluster.whole ? cluster.scale : rng.range(k_debris_min_scale, 1.0f); if (not together && data->volume != nullptr) { for (int pass = 0; pass < 2; ++pass) { - const float speed = glm::length(velocity); + const float speed = glm::length(thrown); if (speed < 1.0e-3f) { break; } glm::vec3 normal_local {0.0f}; - if (not blocked_ahead(start, to_lattice * (velocity / speed), normal_local)) { + if (not blocked_ahead(start, to_lattice * (thrown / speed), normal_local)) { break; } const glm::vec3 normal = lattice_rotation * normal_local; - const float into = glm::dot(velocity, normal); + const float into = glm::dot(thrown, normal); if (into >= 0.0f) { break; } - velocity -= (1.0f + k_debris_spawn_restitution) * into * normal; + thrown -= into * normal; } } + if (const float speed = glm::length(thrown); speed > max_speed) { + thrown *= max_speed / speed; + } + velocity += thrown; + m_debris_spawns.push_back( DebrisSpawn { .position = position, .rotation = lattice_rotation, .velocity = velocity, - .angular_velocity = together ? body->angular_velocity : rng.direction() * rng.range(0.0f, k_debris_max_spin), + .angular_velocity = together + ? body->angular_velocity + : rng.direction() * rng.range(0.0f, std::min(k_debris_max_spin, tunables().debris_max_spin)), .mask = cluster.mask, .material = cluster.material, .albedo_roughness = static_cast(look.albedo_r) | (static_cast(look.albedo_g) << 8u) | From fe85cb30050a73e8455832d952a20b1b32d42e61 Mon Sep 17 00:00:00 2001 From: Xein Date: Tue, 10 Nov 2026 16:12:05 +0100 Subject: [PATCH 4/5] UI rewrite 1 week before submission --- editor/App.axaml | 2 + editor/Assets/AssetBrowserView.axaml | 61 +++-- editor/Components/Elements/AnimatedReveal.cs | 60 +++++ editor/Components/Elements/ArrayBox.cs | 2 +- editor/Components/Elements/ColorBoxBase.cs | 7 +- .../Components/Elements/DragVectorBoxBase.cs | 10 +- editor/Components/Elements/LabelValueRow.cs | 93 +++++++ editor/Components/Elements/LayerTagBox.cs | 2 +- editor/Converters.cs | 11 + editor/Editors/CurveView.axaml | 62 ++--- editor/Editors/GenericView.axaml | 168 ++++++------- editor/Editors/HapticsView.axaml | 90 +++---- editor/Editors/PaletteView.axaml | 126 +++++----- editor/Editors/SchemaView.axaml | 238 +++++++++--------- editor/Editors/TableView.axaml | 48 ++-- editor/Git/LocksView.axaml | 30 +-- editor/Logger/LogsView.axaml | 46 ++-- editor/Resources/ArrayBox.axaml | 4 +- editor/Resources/AssetBox.axaml | 7 +- editor/Resources/BoolBox.axaml | 9 +- editor/Resources/ColorBox.axaml | 3 +- editor/Resources/CompactStyles.axaml | 105 ++++++++ editor/Resources/DragNumberBox.axaml | 7 +- editor/Resources/EnumBox.axaml | 3 +- editor/Resources/NodeBox.axaml | 5 +- editor/Resources/StringBox.axaml | 7 +- editor/Resources/TagBox.axaml | 6 +- editor/VoxelEditor/VoxelEditorView.axaml | 170 ++++++------- editor/VoxelEditor/VoxelEditorView.axaml.cs | 5 +- editor/VoxelEditor/VoxelPalettePicker.cs | 5 +- editor/VoxelEditor/VoxelViewPanel.cs | 75 ++++++ editor/Workspace/DockFactory.cs | 6 +- editor/Workspace/HierarchyConnectorLayer.cs | 42 +++- editor/Workspace/HierarchyView.axaml | 81 +++--- editor/Workspace/HierarchyView.axaml.cs | 61 ++++- editor/Workspace/HierarchyViewModel.cs | 5 + editor/Workspace/HistoryGraphCell.cs | 2 +- editor/Workspace/HistoryView.axaml | 54 ++-- editor/Workspace/InspectorView.axaml | 154 ++++++------ editor/Workspace/LabelWidthState.cs | 37 +++ editor/Workspace/ProjectSettingsView.axaml | 178 ++++++------- editor/Workspace/RendererSettingsView.axaml | 64 ++--- editor/Workspace/SignalsView.axaml | 23 +- editor/Workspace/WorkspaceView.axaml | 149 +++++------ 44 files changed, 1415 insertions(+), 908 deletions(-) create mode 100644 editor/Components/Elements/AnimatedReveal.cs create mode 100644 editor/Components/Elements/LabelValueRow.cs create mode 100644 editor/Resources/CompactStyles.axaml create mode 100644 editor/VoxelEditor/VoxelViewPanel.cs create mode 100644 editor/Workspace/LabelWidthState.cs diff --git a/editor/App.axaml b/editor/App.axaml index dd60b9ae8..2e5323c86 100644 --- a/editor/App.axaml +++ b/editor/App.axaml @@ -22,6 +22,7 @@ + @@ -486,6 +487,7 @@ + \ No newline at end of file diff --git a/editor/Assets/AssetBrowserView.axaml b/editor/Assets/AssetBrowserView.axaml index 80de4e38b..42522589d 100644 --- a/editor/Assets/AssetBrowserView.axaml +++ b/editor/Assets/AssetBrowserView.axaml @@ -47,6 +47,15 @@ Value="{StaticResource Red}" /> + + + + - @@ -126,21 +135,21 @@ + Padding="6"> + VerticalAlignment="Center" Classes="compact-text" /> - - + - - + - - + @@ -190,123 +192,107 @@ - - - + Value="{Binding DefaultScript, Mode=TwoWay}" Classes="compact-field" /> - + Options="{x:Static ve:VoxelEditorViewModel.FillModes}" Classes="compact-field" /> + - - + + - + - + - - + + - + FourUpProperty = + AvaloniaProperty.Register(nameof(FourUp)); + + static VoxelViewPanel() { + AffectsMeasure(FourUpProperty); + } + + public VoxelViewPanel() { + ClipToBounds = true; + AddHandler(Thumb.DragDeltaEvent, OnDividerDrag); + } + + public bool FourUp { + get => GetValue(FourUpProperty); + set => SetValue(FourUpProperty, value); + } + + private void OnDividerDrag(object? sender, VectorEventArgs e) { + if (!FourUp || Bounds.Width <= 0 || Bounds.Height <= 0) return; + switch ((e.Source as Control)?.Name) { + case "ViewsColumnSplitter": + m_sideSplit = Math.Clamp(m_sideSplit + e.Vector.X / Bounds.Width, 0.15, 0.85); + break; + case "ViewsRowSplitter": + m_topSplit = Math.Clamp(m_topSplit + e.Vector.Y / Bounds.Height, 0.15, 0.85); + break; + default: return; + } + InvalidateMeasure(); + e.Handled = true; + } + + private Rect Frame(Control child, Size size) { + var halfWidth = size.Width / 2; + var halfHeight = size.Height / 2; + var side = size.Width * m_sideSplit; + var top = size.Height * m_topSplit; + var inset = Gap / 2; + return child.Name switch { + "FrontViewHost" => new Rect(0, 0, Math.Max(0, Math.Max(halfWidth, side) - inset), Math.Max(0, Math.Max(halfHeight, top) - inset)), + "SideViewHost" => new Rect(side + inset, 0, Math.Max(0, size.Width - side - inset), Math.Max(0, halfHeight - inset)), + "TopViewHost" => new Rect(0, top + inset, Math.Max(0, halfWidth - inset), Math.Max(0, size.Height - top - inset)), + "ViewportHost" when FourUp => new Rect(halfWidth + inset, halfHeight + inset, Math.Max(0, halfWidth - inset), Math.Max(0, halfHeight - inset)), + "ViewportHost" => new Rect(size), + "ViewsColumnSplitter" => new Rect(side - inset, 0, Gap, Math.Min(top, halfHeight)), + "ViewsRowSplitter" => new Rect(0, top - inset, Math.Min(side, halfWidth), Gap), + _ => default + }; + } + + protected override Size MeasureOverride(Size availableSize) { + var size = new Size(double.IsFinite(availableSize.Width) ? availableSize.Width : 0, + double.IsFinite(availableSize.Height) ? availableSize.Height : 0); + foreach (var child in Children) child.Measure(Frame(child, size).Size); + return size; + } + + protected override Size ArrangeOverride(Size finalSize) { + foreach (var child in Children) child.Arrange(Frame(child, finalSize)); + return finalSize; + } +} diff --git a/editor/Workspace/DockFactory.cs b/editor/Workspace/DockFactory.cs index 3d66044d3..342b60ed2 100644 --- a/editor/Workspace/DockFactory.cs +++ b/editor/Workspace/DockFactory.cs @@ -15,7 +15,7 @@ namespace editor.Workspace; public class DockFactory : Factory { - private const double SidePanelProportion = 0.2; + private const double SidePanelProportion = 0.16; private IDocumentDock? m_documentDock; private bool m_genericClosePending; @@ -73,7 +73,7 @@ public override IRootDock CreateLayout() { m_leftToolDock = leftToolDock; var leftDock = new ProportionalDock { - Proportion = 0.2, + Proportion = SidePanelProportion, AllowedDropOperations = DockOperationMask.None, Orientation = Orientation.Vertical, VisibleDockables = CreateList(leftToolDock) @@ -89,7 +89,7 @@ public override IRootDock CreateLayout() { m_rightToolDock = rightToolDock; var rightDock = new ProportionalDock { - Proportion = 0.22, + Proportion = SidePanelProportion, Orientation = Orientation.Vertical, VisibleDockables = CreateList(rightToolDock) }; diff --git a/editor/Workspace/HierarchyConnectorLayer.cs b/editor/Workspace/HierarchyConnectorLayer.cs index 083c2dc61..fe9bf5db2 100644 --- a/editor/Workspace/HierarchyConnectorLayer.cs +++ b/editor/Workspace/HierarchyConnectorLayer.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System; using Avalonia; using Avalonia.Controls; using Avalonia.Media; @@ -7,7 +8,7 @@ namespace editor.Workspace; // You dont even know how much this took -x public sealed class HierarchyConnectorLayer : Control { - public const double RowHeight = 28; + public const double RowHeight = 26; public const double IndentStep = 20; public const double LeftPad = 10; // left inset of the whole tree public const double SwatchHalf = 8; @@ -24,13 +25,28 @@ public sealed class HierarchyConnectorLayer : Control { private IReadOnlyList m_rows = []; private int m_selectedIndex = -1; + private int m_hoveredIndex = -1; + private double[] m_offsets = [0]; + public int HoveredIndex { + get => m_hoveredIndex; + set { if (m_hoveredIndex == value) return; m_hoveredIndex = value; InvalidateVisual(); } + } + public void RefreshGeometry() { + m_offsets = new double[m_rows.Count + 1]; + for (var i = 0; i < m_rows.Count; i++) m_offsets[i + 1] = m_offsets[i] + m_rows[i].VisualHeight; + InvalidateMeasure(); InvalidateVisual(); + } + public double GetRowTop(int index) => m_offsets[Math.Clamp(index, 0, m_offsets.Length - 1)]; + public int GetRowAt(double y) { + for (var i = 0; i < m_rows.Count; i++) if (y >= m_offsets[i] && y < m_offsets[i + 1]) return i; + return -1; + } public IReadOnlyList Rows { get => m_rows; set { m_rows = value; - InvalidateMeasure(); - InvalidateVisual(); + RefreshGeometry(); } } @@ -52,7 +68,7 @@ public void SetDropLine(int index, int depth) { } protected override Size MeasureOverride(Size availableSize) { - return new Size(0, m_rows.Count * RowHeight); + return new Size(0, m_offsets[^1]); // width 0 = stretches to the grid cell } @@ -64,13 +80,21 @@ private static double CenterX(int depth) { return ContentX(depth) + SwatchHalf; } - private static double CenterY(int index) { - return index * RowHeight + RowHeight / 2; + private double CenterY(int index) { + return GetRowTop(index) + m_rows[index].VisualHeight / 2; } public override void Render(DrawingContext ctx) { DrawSelection(ctx); DrawDropLine(ctx); + if (m_hoveredIndex >= 0 && m_hoveredIndex < m_rows.Count) { + var last = m_hoveredIndex; + while (last + 1 < m_rows.Count && m_rows[last + 1].Depth > m_rows[m_hoveredIndex].Depth) last++; + var x = ContentX(m_rows[m_hoveredIndex].Depth) - BoxLeftPad; + var rect = new Rect(x, GetRowTop(m_hoveredIndex), Math.Max(0, Bounds.Width - x - BoxRightPad), GetRowTop(last + 1) - GetRowTop(m_hoveredIndex)); + if (rect.Width > 2 && rect.Height > 2) + ctx.DrawRectangle(null, new Pen(Resolve("Bg4", "#383637"), 2), new RoundedRect(rect.Deflate(1), 8)); + } } private void DrawSelection(DrawingContext ctx) { @@ -88,9 +112,9 @@ private void DrawSelection(DrawingContext ctx) { // selection box var box = new Rect( ContentX(selDepth) - BoxLeftPad, - m_selectedIndex * RowHeight, + GetRowTop(m_selectedIndex), Bounds.Width - (ContentX(selDepth) - BoxLeftPad) - BoxRightPad, - (lastDesc - m_selectedIndex + 1) * RowHeight); + GetRowTop(lastDesc + 1) - GetRowTop(m_selectedIndex)); ctx.DrawRectangle(bg3, null, new RoundedRect(box, BoxCorner)); // descendant connectors @@ -117,7 +141,7 @@ private void DrawDropLine(DrawingContext ctx) { if (m_dropLineIndex < 0) return; var red = Resolve("Red", "#ff1659"); - var y = m_dropLineIndex * RowHeight; + var y = GetRowTop(m_dropLineIndex); var x0 = ContentX(m_dropLineDepth); var pen = new Pen(red, DropLineWidth) { LineCap = PenLineCap.Round }; diff --git a/editor/Workspace/HierarchyView.axaml b/editor/Workspace/HierarchyView.axaml index cde85eb6a..e49224342 100644 --- a/editor/Workspace/HierarchyView.axaml +++ b/editor/Workspace/HierarchyView.axaml @@ -85,6 +85,21 @@ + + @@ -142,7 +142,7 @@ + ToolTip.Tip="{Binding NodeName}" Classes="compact-text" /> + ToolTip.Tip="{Binding OperationText}" Classes="compact-text" /> + ToolTip.Tip="{Binding PreviousValue}" Classes="compact-text" /> + ToolTip.Tip="{Binding CurrentValue}" Classes="compact-text" /> @@ -199,4 +199,4 @@ - \ No newline at end of file + diff --git a/editor/Workspace/InspectorView.axaml b/editor/Workspace/InspectorView.axaml index 7d80eeefe..415e8c4b0 100644 --- a/editor/Workspace/InspectorView.axaml +++ b/editor/Workspace/InspectorView.axaml @@ -10,10 +10,11 @@ d:DesignWidth="300" d:DesignHeight="1000" x:Class="editor.Workspace.InspectorView" - Padding="8" + Padding="6" Background="{StaticResource Bg2}" x:DataType="vm:InspectorViewModel"> + @@ -21,13 +22,13 @@ - + @@ -97,6 +98,18 @@ + + @@ -216,8 +229,7 @@ - @@ -230,13 +242,13 @@ - - + Foreground="{StaticResource TextMuted}" Classes="compact-text" /> @@ -668,7 +682,7 @@ Background="{StaticResource Bg1}" Text="{Binding FilterText, Mode=TwoWay}" PlaceholderText="Filter parameters..." - PlaceholderForeground="{StaticResource TextMuted}" /> + PlaceholderForeground="{StaticResource TextMuted}" Classes="compact-control" /> @@ -684,17 +698,17 @@ Classes.warning="{Binding !Error}" Classes.error="{Binding Error}" CornerRadius="8" - Padding="8"> + Padding="6"> @@ -702,7 +716,7 @@ Foreground="{StaticResource Bg1}" FontWeight="Bold" TextWrapping="Wrap" - VerticalAlignment="Center" /> + VerticalAlignment="Center" Classes="compact-text" /> @@ -720,7 +734,7 @@ - @@ -731,7 +745,7 @@ HorizontalAlignment="Center" /> + TextAlignment="Center" Classes="compact-text" /> diff --git a/editor/Workspace/LabelWidthState.cs b/editor/Workspace/LabelWidthState.cs new file mode 100644 index 000000000..74e9d1600 --- /dev/null +++ b/editor/Workspace/LabelWidthState.cs @@ -0,0 +1,37 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json; +using editor.Assets; + +namespace editor.Workspace; + +public sealed class LabelWidthState { + private readonly string m_path; + private readonly Dictionary m_widths; + private static LabelWidthState? s_project; + private LabelWidthState(string path, Dictionary widths) { m_path = path; m_widths = widths; } + public static LabelWidthState Load(string path) { + try { + return new LabelWidthState(path, File.Exists(path) + ? JsonSerializer.Deserialize>(File.ReadAllText(path)) ?? [] : []); + } catch { return new LabelWidthState(path, []); } + } + public static LabelWidthState? ForProject() { + if (!ProjectContext.IsInitialized) return null; + var path = ProjectContext.Resolve("cache://tools/label_widths.json"); + if (s_project?.m_path != path) s_project = Load(path); + return s_project; + } + public double? Get(string key) => m_widths.TryGetValue(key, out var width) && double.IsFinite(width) && width >= 0 ? width : null; + public void Set(string key, double width) { + if (!double.IsFinite(width) || width < 0) return; + m_widths[key] = width; + try { + Directory.CreateDirectory(Path.GetDirectoryName(m_path)!); + var temp = m_path + ".tmp." + Guid.NewGuid().ToString("N"); + File.WriteAllText(temp, JsonSerializer.Serialize(m_widths)); + File.Move(temp, m_path, true); + } catch { /* Best-effort editor state. */ } + } +} diff --git a/editor/Workspace/ProjectSettingsView.axaml b/editor/Workspace/ProjectSettingsView.axaml index 4add50fee..7606d2beb 100644 --- a/editor/Workspace/ProjectSettingsView.axaml +++ b/editor/Workspace/ProjectSettingsView.axaml @@ -49,7 +49,7 @@ + Value="6" />