diff --git a/editor/App.axaml b/editor/App.axaml
index 1a82bd324..3aed34e6d 100644
--- a/editor/App.axaml
+++ b/editor/App.axaml
@@ -20,6 +20,7 @@
+
diff --git a/editor/Assets/Types/DestructionMaterialAsset.cs b/editor/Assets/Types/DestructionMaterialAsset.cs
index c69624d37..82f812d32 100644
--- a/editor/Assets/Types/DestructionMaterialAsset.cs
+++ b/editor/Assets/Types/DestructionMaterialAsset.cs
@@ -26,10 +26,10 @@ public override Task CreateAsync(string path) {
File.WriteAllText(path,
$"# Toast Destruction Material\n" +
$"schema = \"{schemaUid}\"\n" +
- $"density = 1000.0\n" +
- $"toughness = 1000.0\n" +
- $"structural_strength = 1000.0\n" +
- $"shatter_radius = 0.5\n" +
+ $"density = 2000.0\n" +
+ $"toughness = 2000.0\n" +
+ $"structural_strength = 1000000.0\n" +
+ $"shatter_radius = 0.3\n" +
$"destructible = true\n" +
$"flammable = false\n" +
$"burn_rate = 0.0\n");
diff --git a/editor/Assets/Types/PhysicsMaterialAsset.cs b/editor/Assets/Types/PhysicsMaterialAsset.cs
index edac4fa9c..e51cba3df 100644
--- a/editor/Assets/Types/PhysicsMaterialAsset.cs
+++ b/editor/Assets/Types/PhysicsMaterialAsset.cs
@@ -28,7 +28,9 @@ public override Task CreateAsync(string path) {
$"schema = \"{schemaUid}\"\n" +
$"restitution = 0.1\n" +
$"static_friction = 0.6\n" +
- $"dynamic_friction = 0.5\n");
+ $"dynamic_friction = 0.5\n" +
+ $"rolling_friction = 0.01\n" +
+ $"spin_friction = 0.005\n");
return Task.CompletedTask;
}
}
diff --git a/editor/CMakeLists.txt b/editor/CMakeLists.txt
index 098c72878..bd0f5949f 100644
--- a/editor/CMakeLists.txt
+++ b/editor/CMakeLists.txt
@@ -3,7 +3,7 @@ project(editor NONE)
set(CSHARP_PROJECT "${CMAKE_CURRENT_SOURCE_DIR}/editor.csproj")
set(CSHARP_ASSEMBLY_NAME "editor")
-set(DOTNET_CONFIG "$,Debug,Release>")
+set(DOTNET_CONFIG "$,Debug,$,RelWithDebInfo,Release>>")
find_program(DOTNET_EXECUTABLE dotnet REQUIRED)
message(STATUS "Found dotnet: ${DOTNET_EXECUTABLE}")
diff --git a/editor/Components/Elements/LayerTagBox.cs b/editor/Components/Elements/LayerTagBox.cs
new file mode 100644
index 000000000..d582d7269
--- /dev/null
+++ b/editor/Components/Elements/LayerTagBox.cs
@@ -0,0 +1,232 @@
+//
+// LayerTagBox.cs by Xein
+// 12 Oct 2026
+//
+
+using System;
+using System.Collections.ObjectModel;
+using System.Linq;
+using System.Windows.Input;
+using Avalonia;
+using Avalonia.Controls;
+using Avalonia.Controls.Primitives;
+using Avalonia.Data;
+using Avalonia.Input;
+using CommunityToolkit.Mvvm.ComponentModel;
+using editor.Engine;
+
+namespace editor.Components.Elements;
+
+public enum LayerState {
+ Disabled,
+ Included,
+ Excluded
+}
+
+public readonly record struct LayerMasks(uint Included, uint Excluded);
+
+public sealed partial class LayerCell : ObservableObject {
+ [ObservableProperty] [NotifyPropertyChangedFor(nameof(IsIncluded), nameof(IsExcluded), nameof(IsDisabled))]
+ private LayerState m_state;
+
+ [ObservableProperty] private string m_text = "";
+ [ObservableProperty] private string m_tooltip = "";
+
+ public LayerCell(int index) {
+ Index = index;
+ }
+
+ public int Index { get; }
+ public bool IsIncluded => State == LayerState.Included;
+ public bool IsExcluded => State == LayerState.Excluded;
+ public bool IsDisabled => State == LayerState.Disabled;
+}
+
+public sealed class LayerTagBox : TemplatedControl {
+ public const int Columns = 4;
+ public const int MinRows = 1;
+ public const int MaxRows = PhysicsLayers.Count / Columns;
+ private const double RowStep = 36.0; // cell height + row spacing
+
+ public static readonly StyledProperty IncludedProperty =
+ AvaloniaProperty.Register(nameof(Included));
+
+ public static readonly StyledProperty ExcludedProperty =
+ AvaloniaProperty.Register(nameof(Excluded));
+
+ public static readonly StyledProperty RowsProperty =
+ AvaloniaProperty.Register(nameof(Rows), 2, defaultBindingMode: BindingMode.TwoWay,
+ coerce: (_, value) => Math.Clamp(value, MinRows, MaxRows));
+
+ public static readonly StyledProperty CommandProperty =
+ AvaloniaProperty.Register(nameof(Command));
+
+ private readonly LayerCell[] m_cells;
+ private double m_dragStartY;
+ private int m_dragStartRows;
+ private bool m_dragging;
+ private Control? m_handle;
+ private ItemsControl? m_items;
+
+ // What the last click asked for
+ private uint m_included;
+ private uint m_excluded;
+
+ public LayerTagBox() {
+ m_cells = Enumerable.Range(0, PhysicsLayers.Count).Select(i => new LayerCell(i)).ToArray();
+ RefreshNames();
+ RefreshStates();
+ RefreshVisible();
+ }
+
+ public uint Included {
+ get => GetValue(IncludedProperty);
+ set => SetValue(IncludedProperty, value);
+ }
+
+ public uint Excluded {
+ get => GetValue(ExcludedProperty);
+ set => SetValue(ExcludedProperty, value);
+ }
+
+ public int Rows {
+ get => GetValue(RowsProperty);
+ set => SetValue(RowsProperty, value);
+ }
+
+ public ICommand? Command {
+ get => GetValue(CommandProperty);
+ set => SetValue(CommandProperty, value);
+ }
+
+ public ObservableCollection VisibleCells { get; } = [];
+
+ protected override Type StyleKeyOverride => typeof(LayerTagBox);
+
+ protected override void OnApplyTemplate(TemplateAppliedEventArgs e) {
+ base.OnApplyTemplate(e);
+ DetachHandlers();
+
+ m_items = e.NameScope.Find("PART_Cells");
+ m_handle = e.NameScope.Find("PART_Handle");
+ if (m_items != null) {
+ m_items.Tapped += OnCellTapped;
+ m_items.DoubleTapped += OnCellTapped;
+ }
+ if (m_handle != null) {
+ m_handle.PointerPressed += OnHandlePressed;
+ m_handle.PointerMoved += OnHandleMoved;
+ m_handle.PointerReleased += OnHandleReleased;
+ m_handle.PointerCaptureLost += OnHandleCaptureLost;
+ }
+ }
+
+ protected override void OnAttachedToVisualTree(VisualTreeAttachmentEventArgs e) {
+ base.OnAttachedToVisualTree(e);
+ RefreshNames(); // names may have changed since the box was made
+ }
+
+ private void DetachHandlers() {
+ if (m_items != null) {
+ m_items.Tapped -= OnCellTapped;
+ m_items.DoubleTapped -= OnCellTapped;
+ }
+ if (m_handle != null) {
+ m_handle.PointerPressed -= OnHandlePressed;
+ m_handle.PointerMoved -= OnHandleMoved;
+ m_handle.PointerReleased -= OnHandleReleased;
+ m_handle.PointerCaptureLost -= OnHandleCaptureLost;
+ }
+ }
+
+ private void OnCellTapped(object? sender, TappedEventArgs e) {
+ if (!IsEnabled) return;
+ for (var visual = e.Source as StyledElement; visual != null; visual = visual.Parent) {
+ if (visual.DataContext is not LayerCell cell) continue;
+ Toggle(cell.Index);
+ e.Handled = true;
+ return;
+ }
+ }
+
+ private void Toggle(int index) {
+ var bit = 1u << index;
+ var included = m_included;
+ var excluded = m_excluded;
+
+ // disabled -> included -> excluded -> disabled
+ if ((excluded & bit) != 0) {
+ excluded &= ~bit;
+ } else if ((included & bit) != 0) {
+ included &= ~bit;
+ excluded |= bit;
+ } else {
+ included |= bit;
+ }
+
+ m_included = included;
+ m_excluded = excluded;
+ RefreshStates();
+
+ var masks = new LayerMasks(included, excluded);
+ if (Command?.CanExecute(masks) == true) Command.Execute(masks);
+ }
+
+ private void OnHandlePressed(object? sender, PointerPressedEventArgs e) {
+ if (!e.GetCurrentPoint(this).Properties.IsLeftButtonPressed) return;
+ m_dragging = true;
+ m_dragStartY = e.GetPosition(this).Y;
+ m_dragStartRows = Rows;
+ e.Pointer.Capture(m_handle);
+ e.Handled = true;
+ }
+
+ private void OnHandleMoved(object? sender, PointerEventArgs e) {
+ if (!m_dragging) return;
+ var steps = (int)Math.Round((e.GetPosition(this).Y - m_dragStartY) / RowStep);
+ Rows = m_dragStartRows + steps;
+ }
+
+ private void OnHandleReleased(object? sender, PointerReleasedEventArgs e) {
+ if (!m_dragging) return;
+ m_dragging = false;
+ e.Pointer.Capture(null);
+ e.Handled = true;
+ }
+
+ private void OnHandleCaptureLost(object? sender, PointerCaptureLostEventArgs e) {
+ m_dragging = false;
+ }
+
+ public void RefreshNames() {
+ foreach (var cell in m_cells) {
+ cell.Text = PhysicsLayers.Abbreviation(cell.Index);
+ cell.Tooltip = $"{cell.Index}: {PhysicsLayers.Name(cell.Index)}";
+ }
+ }
+
+ private void RefreshStates() {
+ foreach (var cell in m_cells) {
+ var bit = 1u << cell.Index;
+ cell.State = (m_excluded & bit) != 0 ? LayerState.Excluded
+ : (m_included & bit) != 0 ? LayerState.Included
+ : LayerState.Disabled;
+ }
+ }
+
+ private void RefreshVisible() {
+ var count = Rows * Columns;
+ while (VisibleCells.Count > count) VisibleCells.RemoveAt(VisibleCells.Count - 1);
+ while (VisibleCells.Count < count) VisibleCells.Add(m_cells[VisibleCells.Count]);
+ }
+
+ protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) {
+ base.OnPropertyChanged(change);
+ if (change.Property == IncludedProperty || change.Property == ExcludedProperty) {
+ m_included = Included;
+ m_excluded = Excluded;
+ RefreshStates();
+ }
+ else if (change.Property == RowsProperty) RefreshVisible();
+ }
+}
diff --git a/editor/Engine/PhysicsLayers.cs b/editor/Engine/PhysicsLayers.cs
new file mode 100644
index 000000000..8fcf37edc
--- /dev/null
+++ b/editor/Engine/PhysicsLayers.cs
@@ -0,0 +1,26 @@
+//
+// PhysicsLayers.cs by Xein
+// 12 Oct 2026
+//
+
+namespace editor.Engine;
+
+public static class PhysicsLayers {
+ public const int Count = 32;
+ public const int MaxAbbreviationLength = 3;
+
+ public static string NameKey(int index) => $"physics.layers.{index}.name";
+ public static string AbbreviationKey(int index) => $"physics.layers.{index}.abbr";
+
+ public static string Name(int index) {
+ if (index == 0) return "Default";
+ var name = ToastSettings.GetString(NameKey(index));
+ return string.IsNullOrEmpty(name) ? $"Layer {index}" : name;
+ }
+
+ public static string Abbreviation(int index) {
+ if (index == 0) return "0";
+ var abbreviation = ToastSettings.GetString(AbbreviationKey(index));
+ return string.IsNullOrEmpty(abbreviation) ? index.ToString() : abbreviation;
+ }
+}
diff --git a/editor/Resources/TagBox.axaml b/editor/Resources/TagBox.axaml
new file mode 100644
index 000000000..7279b207f
--- /dev/null
+++ b/editor/Resources/TagBox.axaml
@@ -0,0 +1,121 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/editor/Resources/files/lib/CMakeLists.txt b/editor/Resources/files/lib/CMakeLists.txt
index c9dd19de7..ea44952cb 100644
--- a/editor/Resources/files/lib/CMakeLists.txt
+++ b/editor/Resources/files/lib/CMakeLists.txt
@@ -43,6 +43,16 @@ elseif (CMAKE_SYSTEM_PROCESSOR MATCHES "^(x86_64|AMD64)$")
target_compile_options(toast_engine INTERFACE $<$:-march=x86-64-v3>)
endif ()
+if (WIN32)
+ set(_toast_profiled "$")
+ target_compile_definitions(toast_engine INTERFACE
+ $<${_toast_profiled}:TRACY_ENABLE>
+ $<${_toast_profiled}:TRACY_ON_DEMAND>
+ $<${_toast_profiled}:TRACY_IMPORTS>
+ )
+ target_link_libraries(toast_engine INTERFACE $<${_toast_profiled}:${TOAST_PATH}/lib/TracyClient.lib>)
+endif ()
+
file(GLOB_RECURSE SOURCE "src/*.cpp" "generated/*.cpp")
add_library(my_game SHARED ${SOURCE})
target_include_directories(my_game PUBLIC
diff --git a/editor/StartWindow/StartWindowViewModel.cs b/editor/StartWindow/StartWindowViewModel.cs
index 5ed04522f..f6177984c 100644
--- a/editor/StartWindow/StartWindowViewModel.cs
+++ b/editor/StartWindow/StartWindowViewModel.cs
@@ -148,6 +148,8 @@ private void LaunchProject(string projectPath, Window parentWindow) {
tasks.Add(LoaderTask.Run("cmake --build .toast/cmake_cache", "cmake",
#if DEBUG
"--build .toast/cmake_cache --config Debug --parallel"));
+#elif RELWITHDEBINFO
+ "--build .toast/cmake_cache --config RelWithDebInfo --parallel"));
#else
"--build .toast/cmake_cache --config Release --parallel"));
#endif
diff --git a/editor/Workspace/InspectorModels.cs b/editor/Workspace/InspectorModels.cs
index e36f4c511..731fe2f63 100644
--- a/editor/Workspace/InspectorModels.cs
+++ b/editor/Workspace/InspectorModels.cs
@@ -30,7 +30,8 @@ public enum WidgetKind {
ReadOnly,
Color3,
Color4,
- Array
+ Array,
+ Layers
}
public static class InspectorFormat {
@@ -133,6 +134,16 @@ public static bool TryInt(string s, out int v) {
return int.TryParse(s.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out v);
}
+ // layer masks are uint32
+ // honestly i have no clue if ill receive them as -1 or 4294967295
+ public static bool TryMask(string s, out uint v) {
+ v = 0;
+ if (!long.TryParse(s.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var wide) ||
+ wide < int.MinValue || wide > uint.MaxValue) return false;
+ v = unchecked((uint)wide);
+ return true;
+ }
+
public static float[] Floats(string s) {
var cleaned = s.Replace('[', ' ').Replace(']', ' ').Replace(',', ' ').Replace(';', ' ');
return cleaned.Split(new[] { ' ', '\t', '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries)
@@ -168,6 +179,7 @@ public static float[] Floats(string s) {
switch (kind) {
case WidgetKind.Float:
case WidgetKind.Int:
+ case WidgetKind.Layers:
case WidgetKind.Enum: {
var m = NumberToken.Match(raw);
return m.Success ? m.Value : null;
@@ -233,7 +245,7 @@ public static float[] Floats(string s) {
public static string? TypeZero(WidgetKind kind) {
return kind switch {
- WidgetKind.Float or WidgetKind.Int => "0",
+ WidgetKind.Float or WidgetKind.Int or WidgetKind.Layers => "0",
WidgetKind.Bool => "false",
WidgetKind.String => "",
WidgetKind.Vec2 => "0 0",
@@ -271,6 +283,8 @@ public static bool ValuesEqual(WidgetKind kind, string a, string b) {
}
case WidgetKind.Array:
return string.Equals(a.Trim(), b.Trim(), StringComparison.Ordinal);
+ case WidgetKind.Layers:
+ return TryMask(a, out var ma) && TryMask(b, out var mb) ? ma == mb : string.Equals(a.Trim(), b.Trim(), StringComparison.Ordinal);
default:
return string.Equals(a.Trim(), b.Trim(), StringComparison.Ordinal);
}
@@ -292,6 +306,10 @@ public partial class FieldVM : ObservableObject {
[ObservableProperty] private float m_float;
[ObservableProperty] private int m_int;
+ [ObservableProperty] private uint m_mask;
+ [ObservableProperty] private int m_layerRows = 2;
+ [ObservableProperty] private FieldVM? m_layersPartner;
+ private FieldVM? m_layersOwner;
[ObservableProperty] private bool m_isDefault = true;
[ObservableProperty] private string m_readOnlyText = "";
@@ -324,6 +342,16 @@ public FieldVM(FieldInfo info) {
else if (info.FieldType == "vec4_t") Kind = WidgetKind.Color4;
}
+ if (!info.IsArray && info.FieldType == "int_t") {
+ if (ReflectionDatabase.HasAttr(info.Attributes, "Layers")) {
+ Kind = WidgetKind.Layers;
+ LayersPartnerName = ReflectionDatabase.GetAttr(info.Attributes, "Layers");
+ } else if (ReflectionDatabase.HasAttr(info.Attributes, "LayersExcluded")) {
+ Kind = WidgetKind.Layers;
+ IsLayersPartner = true;
+ }
+ }
+
var nameOverride = ReflectionDatabase.GetAttr(info.Attributes, "Name");
DisplayName = string.IsNullOrEmpty(nameOverride) ? InspectorFormat.DisplayName(info.Name) : nameOverride;
@@ -405,7 +433,7 @@ internal FieldVM(
public DateTime LastEdit { get; private set; } = DateTime.MinValue;
- public bool ShowReset => Editable && !IsDefault;
+ public bool ShowReset => Editable && !(IsDefault && (LayersPartner?.IsDefault ?? true));
public ObservableCollection Segments { get; } = [];
@@ -424,7 +452,14 @@ internal FieldVM(
public bool IsAssetRef => Kind == WidgetKind.AssetRef;
public bool IsNodeRef => Kind == WidgetKind.NodeRef;
public bool IsReadOnlyText => Kind == WidgetKind.ReadOnly;
- public bool IsVectorLayout => Kind is WidgetKind.Vec2 or WidgetKind.Vec3 or WidgetKind.Vec4;
+ public bool IsLayers => Kind == WidgetKind.Layers;
+ public bool IsVectorLayout => Kind is WidgetKind.Vec2 or WidgetKind.Vec3 or WidgetKind.Vec4 or WidgetKind.Layers;
+ public string? LayersPartnerName { get; }
+ public bool IsLayersPartner { get; }
+
+ public event Action? LayersEdited;
+
+ internal string? DefaultEngineValue => m_default;
public bool Editable => !ReadOnly && Kind != WidgetKind.ReadOnly;
@@ -454,6 +489,23 @@ internal FieldVM(
partial void OnIsDefaultChanged(bool value) {
OnPropertyChanged(nameof(ShowReset));
+ m_layersOwner?.OnPropertyChanged(nameof(ShowReset));
+ }
+
+ partial void OnLayersPartnerChanged(FieldVM? oldValue, FieldVM? newValue) {
+ if (oldValue != null) oldValue.m_layersOwner = null;
+ if (newValue != null) newValue.m_layersOwner = this;
+ OnPropertyChanged(nameof(ShowReset));
+ }
+
+ partial void OnMaskChanged(uint value) {
+ OnUserEdited();
+ }
+
+ [RelayCommand]
+ private void SetLayers(LayerMasks masks) {
+ if (!Editable) return;
+ LayersEdited?.Invoke(this, masks);
}
partial void OnFloatChanged(float value) {
@@ -631,6 +683,8 @@ private string ToEngineString() {
return Kind switch {
WidgetKind.Float => InspectorFormat.Float(Float),
WidgetKind.Int => Int.ToString(CultureInfo.InvariantCulture),
+ // the prefab keeps the mask as an int
+ WidgetKind.Layers => unchecked((int)Mask).ToString(CultureInfo.InvariantCulture),
WidgetKind.Enum => EnumValue is not null && m_enumLabelToValue.TryGetValue(EnumValue, out var enumValue)
? enumValue
: EnumValue ?? "",
@@ -657,6 +711,9 @@ public void ApplyEngineString(string s) {
case WidgetKind.Int:
if (InspectorFormat.TryInt(s, out var i)) Int = i;
break;
+ case WidgetKind.Layers:
+ if (InspectorFormat.TryMask(s, out var mask)) Mask = mask;
+ break;
case WidgetKind.Enum: {
var value = s.Trim();
EnumValue = m_enumValueToLabel.GetValueOrDefault(value, value);
@@ -779,6 +836,13 @@ private void UpdateIsDefault(string current) {
[RelayCommand]
private void Reset() {
if (m_default is null) return;
+ if (Kind == WidgetKind.Layers && LayersPartner is { } partner) {
+ InspectorFormat.TryMask(m_default, out var included);
+ InspectorFormat.TryMask(partner.m_default ?? "0", out var excluded);
+ LayersEdited?.Invoke(this, new LayerMasks(included, excluded));
+ return;
+ }
+
Edited?.Invoke(this, m_default);
ApplyEngineString(m_default);
}
diff --git a/editor/Workspace/InspectorState.cs b/editor/Workspace/InspectorState.cs
index 521fc3f5b..c558b14b7 100644
--- a/editor/Workspace/InspectorState.cs
+++ b/editor/Workspace/InspectorState.cs
@@ -7,28 +7,36 @@ namespace editor.Workspace;
public sealed class InspectorState {
private readonly Dictionary m_collapsed;
+ private readonly Dictionary m_numbers;
private readonly string? m_path;
- private InspectorState(string? path, Dictionary collapsed) {
+ private InspectorState(string? path, Dictionary collapsed, Dictionary numbers) {
m_path = path;
m_collapsed = collapsed;
+ m_numbers = numbers;
}
public static InspectorState Load(string uid) {
var path = ProjectContext.Resolve($"cache://tools/inspector/{uid}.json");
- Dictionary? data = null;
+ var collapsed = new Dictionary();
+ var numbers = new Dictionary();
try {
- if (File.Exists(path))
- data = JsonSerializer.Deserialize>(File.ReadAllText(path));
+ if (File.Exists(path)) {
+ var data = JsonSerializer.Deserialize>(File.ReadAllText(path));
+ foreach (var (key, value) in data ?? []) {
+ if (value.ValueKind is JsonValueKind.True or JsonValueKind.False) collapsed[key] = value.GetBoolean();
+ else if (value.ValueKind == JsonValueKind.Number && value.TryGetInt32(out var number)) numbers[key] = number;
+ }
+ }
} catch {
// corrupt cache -> fall back to defaults
}
- return new InspectorState(path, data ?? new Dictionary());
+ return new InspectorState(path, collapsed, numbers);
}
public static InspectorState CreateTransient() {
- return new InspectorState(null, new Dictionary());
+ return new InspectorState(null, new Dictionary(), new Dictionary());
}
public bool Get(string key, bool defaultCollapsed) {
@@ -40,11 +48,23 @@ public void Set(string key, bool collapsed) {
Save();
}
+ public int GetInt(string key, int fallback) {
+ return m_numbers.TryGetValue(key, out var v) ? v : fallback;
+ }
+
+ public void SetInt(string key, int value) {
+ m_numbers[key] = value;
+ Save();
+ }
+
private void Save() {
if (m_path is null) return;
try {
+ var all = new Dictionary();
+ foreach (var (key, value) in m_collapsed) all[key] = value;
+ foreach (var (key, value) in m_numbers) all[key] = value;
Directory.CreateDirectory(Path.GetDirectoryName(m_path)!);
- File.WriteAllText(m_path, JsonSerializer.Serialize(m_collapsed));
+ File.WriteAllText(m_path, JsonSerializer.Serialize(all));
} catch {
// best-effort persistence; ignore IO errors
}
diff --git a/editor/Workspace/InspectorView.axaml b/editor/Workspace/InspectorView.axaml
index fde282510..7d80eeefe 100644
--- a/editor/Workspace/InspectorView.axaml
+++ b/editor/Workspace/InspectorView.axaml
@@ -477,6 +477,12 @@
PasteCommand="{Binding PasteCommand}"
IsEnabled="{Binding Editable}"
IsVisible="{Binding IsVec4}" />
+
diff --git a/editor/Workspace/InspectorViewModel.cs b/editor/Workspace/InspectorViewModel.cs
index 6d2794706..fd016c0fc 100644
--- a/editor/Workspace/InspectorViewModel.cs
+++ b/editor/Workspace/InspectorViewModel.cs
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
+using System.Globalization;
using System.Linq;
using System.Text.Json;
using System.Threading.Tasks;
@@ -11,6 +12,7 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Dock.Model.Mvvm.Controls;
+using editor.Components.Elements;
using editor.Engine;
using Proto.Events;
@@ -520,8 +522,35 @@ private void AddField(ObservableCollection target, FieldInfo info) {
var vm = new FieldVM(info);
vm.AttachClipboardHost(this);
vm.Edited += OnFieldEdited;
- target.Add(vm);
m_fieldByParam[vm.ParameterName] = vm;
+
+ if (vm.IsLayers) {
+ LinkLayerFields(vm);
+ if (vm.IsLayersPartner) return; // the excluded mask receives engine values
+ var rowsKey = $"layers:{info.Name}";
+ vm.LayerRows = m_state!.GetInt(rowsKey, vm.LayerRows);
+ vm.PropertyChanged += (_, e) => {
+ if (e.PropertyName == nameof(FieldVM.LayerRows)) m_state?.SetInt(rowsKey, vm.LayerRows);
+ };
+ vm.LayersEdited += OnLayersEdited;
+ }
+
+ target.Add(vm);
+ }
+
+ private void LinkLayerFields(FieldVM field) {
+ foreach (var candidate in m_fieldByParam.Values) {
+ if (!candidate.IsLayers || ReferenceEquals(candidate, field)) continue;
+ if (field.IsLayersPartner && candidate.LayersPartnerName == field.ParameterName) candidate.LayersPartner = field;
+ else if (candidate.IsLayersPartner && field.LayersPartnerName == candidate.ParameterName) field.LayersPartner = candidate;
+ }
+ }
+
+ private void OnLayersEdited(FieldVM field, LayerMasks masks) {
+ var edits = new List<(FieldVM, string)> { (field, unchecked((int)masks.Included).ToString(CultureInfo.InvariantCulture)) };
+ if (field.LayersPartner is { } partner)
+ edits.Add((partner, unchecked((int)masks.Excluded).ToString(CultureInfo.InvariantCulture)));
+ ApplyPasteBatch(edits, $"{field.DisplayName} changed");
}
private void OnFieldEdited(FieldVM field, string value) {
diff --git a/editor/Workspace/MainWindowViewModel.cs b/editor/Workspace/MainWindowViewModel.cs
index 1160e8e71..f441a62c7 100644
--- a/editor/Workspace/MainWindowViewModel.cs
+++ b/editor/Workspace/MainWindowViewModel.cs
@@ -872,8 +872,11 @@ private async Task ReloadGame() {
"cmake build",
"cmake",
// the game shares std types with the engine so both must use the same CRT
+ // RelWithDebInfo keeps game PDB and ttracy
#if DEBUG
"--build .toast/cmake_cache --config Debug --parallel"
+#elif RELWITHDEBINFO
+ "--build .toast/cmake_cache --config RelWithDebInfo --parallel"
#else
"--build .toast/cmake_cache --config Release --parallel"
#endif
diff --git a/editor/Workspace/PhysicsLayersViewModel.cs b/editor/Workspace/PhysicsLayersViewModel.cs
new file mode 100644
index 000000000..4696a01fd
--- /dev/null
+++ b/editor/Workspace/PhysicsLayersViewModel.cs
@@ -0,0 +1,60 @@
+//
+// PhysicsLayersViewModel.cs by Xein
+// 12 Oct 2026
+//
+
+using System.Collections.ObjectModel;
+using CommunityToolkit.Mvvm.ComponentModel;
+using editor.Engine;
+
+namespace editor.Workspace;
+
+public sealed partial class PhysicsLayerRow : ObservableObject {
+ private bool m_suppressWrite;
+ [ObservableProperty] private string m_abbreviation;
+ [ObservableProperty] private string m_name;
+
+ public PhysicsLayerRow(int index) {
+ Index = index;
+ m_name = PhysicsLayers.Name(index);
+ m_abbreviation = PhysicsLayers.Abbreviation(index);
+ }
+
+ public int Index { get; }
+ public bool Editable => Index != 0;
+
+ partial void OnNameChanged(string value) {
+ if (m_suppressWrite || !Editable) return;
+ ToastSettings.SetString(PhysicsLayers.NameKey(Index), value);
+ }
+
+ partial void OnAbbreviationChanged(string value) {
+ if (m_suppressWrite || !Editable) return;
+ if (value.Length > PhysicsLayers.MaxAbbreviationLength) {
+ Abbreviation = value[..PhysicsLayers.MaxAbbreviationLength];
+ return;
+ }
+
+ ToastSettings.SetString(PhysicsLayers.AbbreviationKey(Index), value);
+ }
+
+ public void Reload() {
+ m_suppressWrite = true;
+ Name = PhysicsLayers.Name(Index);
+ Abbreviation = PhysicsLayers.Abbreviation(Index);
+ m_suppressWrite = false;
+ }
+}
+
+// project settings
+public sealed class PhysicsLayersViewModel {
+ public PhysicsLayersViewModel() {
+ for (var i = 0; i < PhysicsLayers.Count; i++) Layers.Add(new PhysicsLayerRow(i));
+ }
+
+ public ObservableCollection Layers { get; } = [];
+
+ public void Reload() {
+ foreach (var layer in Layers) layer.Reload();
+ }
+}
diff --git a/editor/Workspace/ProjectSettingsView.axaml b/editor/Workspace/ProjectSettingsView.axaml
index 05899dfcb..874eb0360 100644
--- a/editor/Workspace/ProjectSettingsView.axaml
+++ b/editor/Workspace/ProjectSettingsView.axaml
@@ -90,6 +90,12 @@
Maximum="{Binding Maximum}" />
+
+
+
+
@@ -245,9 +251,56 @@
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/editor/Workspace/ProjectSettingsViewModel.cs b/editor/Workspace/ProjectSettingsViewModel.cs
index f44db029c..145164bdd 100644
--- a/editor/Workspace/ProjectSettingsViewModel.cs
+++ b/editor/Workspace/ProjectSettingsViewModel.cs
@@ -41,6 +41,7 @@ public partial class ProjectSettingsViewModel : Tool {
private const string RendererPrefix = "renderer.";
private const string PhysicsPrefix = "physics.";
public const string AssetBrowserCategory = "Asset Browser";
+ public const string PhysicsTagsCategory = "Tags";
private readonly DispatcherTimer m_statusTimer;
@@ -69,6 +70,8 @@ public ProjectSettingsViewModel() {
public AssetBrowserSettingsViewModel AssetBrowser { get; } = new();
+ public PhysicsLayersViewModel PhysicsLayers { get; } = new();
+
public ObservableCollection Sections { get; }
public ObservableCollection VisibleCategories { get; }
@@ -78,8 +81,10 @@ public ProjectSettingsViewModel() {
public bool IsGeneral => ActiveTab == SettingsTab.General;
public bool IsRendering => ActiveTab == SettingsTab.Rendering;
public bool IsPhysics => ActiveTab == SettingsTab.Physics;
+ public bool IsPhysicsTags => IsPhysics && SelectedSection?.Category == PhysicsTagsCategory;
public bool IsEditor => ActiveTab == SettingsTab.Editor;
- public bool IsEngineTab => IsRendering || IsPhysics;
+ public bool IsEngineTab => IsRendering || IsPhysics;
+ public bool IsSettingList => IsEngineTab && !IsPhysicsTags;
public bool HasStaleReflectionProbes => StaleReflectionProbes > 0;
public bool HasStaleIrradianceVolumes => StaleIrradianceVolumes > 0;
@@ -90,9 +95,13 @@ public void Load() {
Sections.Clear();
Sections.Add(BuildRoot("General", SettingsTab.General, []));
Sections.Add(BuildRoot("Rendering", SettingsTab.Rendering, SettingRowFactory.BuildCategories(RendererPrefix)));
- Sections.Add(BuildRoot("Physics", SettingsTab.Physics, SettingRowFactory.BuildCategories(PhysicsPrefix)));
+ var physics = BuildRoot("Physics", SettingsTab.Physics, SettingRowFactory.BuildCategories(PhysicsPrefix));
+ physics.Children.Add(new SettingsSection
+ { Name = PhysicsTagsCategory, Tab = SettingsTab.Physics, Category = PhysicsTagsCategory });
+ Sections.Add(physics);
Sections.Add(BuildEditorRoot());
+ PhysicsLayers.Reload();
SavePath = ToastSettings.ActivePath;
RayTracingSupported = ToastRenderer.SupportsRayTracing;
RefreshStatus();
@@ -176,6 +185,8 @@ partial void OnSelectedSectionChanged(SettingsSection? value) {
OnPropertyChanged(nameof(IsPhysics));
OnPropertyChanged(nameof(IsEditor));
OnPropertyChanged(nameof(IsEngineTab));
+ OnPropertyChanged(nameof(IsPhysicsTags));
+ OnPropertyChanged(nameof(IsSettingList));
}
private void RefreshStatus() {
@@ -198,6 +209,7 @@ private void ResetAll() {
foreach (var category in VisibleCategories)
foreach (var row in category.Rows)
row.Reload();
+ PhysicsLayers.Reload();
StatusMessage = "Reset to defaults";
}
diff --git a/editor/Workspace/SettingRowViewModels.cs b/editor/Workspace/SettingRowViewModels.cs
index c1eef187c..8a0694aa0 100644
--- a/editor/Workspace/SettingRowViewModels.cs
+++ b/editor/Workspace/SettingRowViewModels.cs
@@ -6,6 +6,7 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
+using System.Linq;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using editor.Engine;
@@ -36,7 +37,7 @@ protected SettingRow(SettingDescriptor descriptor) {
public SettingDescriptor Descriptor { get; }
public string Key => Descriptor.Key;
- public string Label => Descriptor.Label;
+ public virtual string Label => Descriptor.Label;
public string Description => Descriptor.Description;
public bool HasDescription => !string.IsNullOrWhiteSpace(Descriptor.Description);
public bool RequiresRestart => Descriptor.RequiresRestart;
@@ -44,10 +45,14 @@ protected SettingRow(SettingDescriptor descriptor) {
/// Drops the active layer's override, then shows whatever was underneath it
[RelayCommand]
private void Reset() {
- ToastSettings.Reset(Key);
+ ResetValue();
Reload();
}
+ protected virtual void ResetValue() {
+ ToastSettings.Reset(Key);
+ }
+
/// Re-reads the engine's value without writing anything back
public abstract void Reload();
}
@@ -189,6 +194,58 @@ public override void Reload() {
}
}
+public sealed partial class Vec3SettingRow : SettingRow {
+ private readonly SettingDescriptor[] m_axes;
+ [ObservableProperty] private float m_x;
+ [ObservableProperty] private float m_y;
+ [ObservableProperty] private float m_z;
+
+ public Vec3SettingRow(SettingDescriptor x, SettingDescriptor y, SettingDescriptor z) : base(x) {
+ m_axes = [x, y, z];
+ m_x = (float)ToastSettings.GetFloat(x.Key);
+ m_y = (float)ToastSettings.GetFloat(y.Key);
+ m_z = (float)ToastSettings.GetFloat(z.Key);
+ Label = StripAxis(x.Label);
+ }
+
+ public override string Label { get; }
+
+ partial void OnXChanged(float value) => Write(0, value);
+ partial void OnYChanged(float value) => Write(1, value);
+ partial void OnZChanged(float value) => Write(2, value);
+
+ private void Write(int axis, float value) {
+ if (SuppressWrite) return;
+ ToastSettings.SetFloat(m_axes[axis].Key, value);
+ }
+
+ protected override void ResetValue() {
+ foreach (var axis in m_axes) ToastSettings.Reset(axis.Key);
+ }
+
+ public override void Reload() {
+ SuppressWrite = true;
+ X = (float)ToastSettings.GetFloat(m_axes[0].Key);
+ Y = (float)ToastSettings.GetFloat(m_axes[1].Key);
+ Z = (float)ToastSettings.GetFloat(m_axes[2].Key);
+ SuppressWrite = false;
+ }
+
+ /// "Gravity X" -> "Gravity"
+ private static string StripAxis(string label) {
+ return label.EndsWith(" X", StringComparison.Ordinal) ? label[..^2] : label;
+ }
+
+ /// The key without its _x suffix when @p x, @p y and @p z are the three axes of one vector
+ public static string? StemOf(SettingDescriptor x, SettingDescriptor y, SettingDescriptor z) {
+ if (x.Type != SettingType.Float || y.Type != SettingType.Float || z.Type != SettingType.Float) return null;
+ if (x.Category != y.Category || x.Category != z.Category) return null;
+ if (!x.Key.EndsWith("_x", StringComparison.Ordinal)) return null;
+ var stem = x.Key[..^2];
+ return y.Key == stem + "_y" && z.Key == stem + "_z" ? stem : null;
+ }
+}
+
/// One collapsible group of rows, named by the engine's Meta::category
public sealed class SettingCategory {
public required string Name { get; init; }
@@ -202,12 +259,20 @@ public static List BuildCategories(string keyPrefix) {
var byCategory = new Dictionary>();
var order = new List();
- foreach (var descriptor in ToastSettings.Enumerate()) {
- if (descriptor.Hidden) continue;
- if (!string.IsNullOrEmpty(keyPrefix) && !descriptor.Key.StartsWith(keyPrefix, StringComparison.Ordinal))
- continue;
+ var descriptors = ToastSettings.Enumerate()
+ .Where(d => !d.Hidden &&
+ (string.IsNullOrEmpty(keyPrefix) || d.Key.StartsWith(keyPrefix, StringComparison.Ordinal)))
+ .ToList();
+
+ for (var i = 0; i < descriptors.Count; i++) {
+ var descriptor = descriptors[i];
- SettingRow row = descriptor switch {
+ SettingRow row;
+ if (i + 2 < descriptors.Count &&
+ Vec3SettingRow.StemOf(descriptor, descriptors[i + 1], descriptors[i + 2]) is not null) {
+ row = new Vec3SettingRow(descriptor, descriptors[i + 1], descriptors[i + 2]);
+ i += 2;
+ } else row = descriptor switch {
{ IsEnum: true } => new EnumSettingRow(descriptor),
{ Type: SettingType.Bool } => new BoolSettingRow(descriptor),
{ Type: SettingType.Int } => new IntSettingRow(descriptor),
diff --git a/editor/editor.csproj b/editor/editor.csproj
index 3622fa9d5..9a90e00ba 100644
--- a/editor/editor.csproj
+++ b/editor/editor.csproj
@@ -29,6 +29,7 @@
false
true
true
+ $(DefineConstants);RELWITHDEBINFO
diff --git a/engine/CMakeLists.txt b/engine/CMakeLists.txt
index 66aec8369..7eb4a7652 100644
--- a/engine/CMakeLists.txt
+++ b/engine/CMakeLists.txt
@@ -19,6 +19,14 @@ endif ()
find_package(asio CONFIG REQUIRED)
find_package(SDL3 CONFIG REQUIRED)
find_package(Tracy CONFIG REQUIRED)
+get_target_property(_tracy_definitions Tracy::TracyClient INTERFACE_COMPILE_DEFINITIONS)
+if (_tracy_definitions)
+ set(_tracy_profiled_definitions)
+ foreach (_definition IN LISTS _tracy_definitions)
+ list(APPEND _tracy_profiled_definitions "$<$,$>:${_definition}>")
+ endforeach ()
+ set_property(TARGET Tracy::TracyClient PROPERTY INTERFACE_COMPILE_DEFINITIONS "${_tracy_profiled_definitions}")
+endif ()
find_package(glm CONFIG REQUIRED)
find_package(nlohmann_json CONFIG REQUIRED)
find_package(Vulkan REQUIRED)
@@ -227,6 +235,8 @@ add_custom_command(TARGET toast_engine POST_BUILD
COMMAND ${CMAKE_COMMAND} -E copy_directory "${_nlohmann_include}/nlohmann" "${TOAST_ENGINE_OUTPUT_ROOT}/include/nlohmann"
COMMAND ${CMAKE_COMMAND} -E copy_directory "${_tracy_include}/tracy" "${TOAST_ENGINE_OUTPUT_ROOT}/include/tracy"
COMMAND ${CMAKE_COMMAND} -E copy_directory "${_tracy_include}/common" "${TOAST_ENGINE_OUTPUT_ROOT}/include/common"
+ COMMAND ${CMAKE_COMMAND} -E copy_directory "${_tracy_include}/client" "${TOAST_ENGINE_OUTPUT_ROOT}/include/client"
+ COMMAND ${CMAKE_COMMAND} -E copy_if_different "$" "${TOAST_ENGINE_OUTPUT_ROOT}/lib"
COMMAND ${CMAKE_COMMAND} -E copy_directory "${_toml_include}/toml++" "${TOAST_ENGINE_OUTPUT_ROOT}/include/toml++"
COMMAND ${CMAKE_COMMAND} -E copy_directory "${_glm_include}/glm" "${TOAST_ENGINE_OUTPUT_ROOT}/include/glm"
COMMAND ${CMAKE_COMMAND} -E copy_directory "${LUA_INCLUDE_DIR}" "${TOAST_ENGINE_OUTPUT_ROOT}/include"
diff --git a/engine/assets/lua/physics.d.lua b/engine/assets/lua/physics.d.lua
index 9bb4d77cc..3bea70e7d 100644
--- a/engine/assets/lua/physics.d.lua
+++ b/engine/assets/lua/physics.d.lua
@@ -15,3 +15,20 @@ Physics = {}
---@param min_radius number
---@return boolean
function Physics.shootVoxel(origin, direction, max_distance, energy, min_radius) end
+
+---Sets the wind of the level in world space m/s
+---It lasts until another level is loaded or Physics.resetWind is called
+---@param wind vec3
+function Physics.setWind(wind) end
+
+---The wind the physics currently uses in world space m/s
+---@return vec3
+function Physics.wind() end
+
+---Goes back to the wind of the physics settings
+function Physics.resetWind() end
+
+---Index of the physics layer with this name
+---@param name string
+---@return integer
+function Physics.getLayer(name) end
diff --git a/engine/assets/schemas/destruction_material.schema.json b/engine/assets/schemas/destruction_material.schema.json
index 3cc28397d..f82ea8153 100644
--- a/engine/assets/schemas/destruction_material.schema.json
+++ b/engine/assets/schemas/destruction_material.schema.json
@@ -9,29 +9,29 @@
"type": "number",
"x-toast-type" : "float",
"x-toast-unit" : "kg/m^3",
- "default" : 1000.0,
+ "default" : 2000.0,
"minimum" : 1,
"maximum" : 65535
},
"toughness": {
"type": "number",
"x-toast-type" : "float",
- "x-toast-unit" : "J",
- "default" : 1000.0,
+ "x-toast-unit" : "N*s",
+ "default" : 2000.0,
"minimum" : 0
},
"structural_strength": {
"type": "number",
"x-toast-type" : "float",
"x-toast-unit" : "Pa",
- "default" : 1000.0,
+ "default" : 1000000.0,
"minimum" : 0
},
"shatter_radius": {
"type": "number",
"x-toast-type" : "float",
"x-toast-unit" : "m",
- "default" : 0.5,
+ "default" : 0.3,
"minimum" : 0
},
"destructible": {
diff --git a/engine/assets/schemas/physics_material.schema.json b/engine/assets/schemas/physics_material.schema.json
index ab2462a64..bf6fe0e91 100644
--- a/engine/assets/schemas/physics_material.schema.json
+++ b/engine/assets/schemas/physics_material.schema.json
@@ -21,6 +21,18 @@
"type": "number",
"x-toast-type" : "float",
"default" : 0.5
+ },
+ "rolling_friction": {
+ "type": "number",
+ "x-toast-type" : "float",
+ "default" : 0.01,
+ "minimum" : 0
+ },
+ "spin_friction": {
+ "type": "number",
+ "x-toast-type" : "float",
+ "default" : 0.005,
+ "minimum" : 0
}
}
}
diff --git a/engine/src/toast/assets/prefab.cpp b/engine/src/toast/assets/prefab.cpp
index ae15aa275..0c98cda93 100644
--- a/engine/src/toast/assets/prefab.cpp
+++ b/engine/src/toast/assets/prefab.cpp
@@ -785,6 +785,10 @@ auto Prefab::valueFromString(FieldType type, bool is_array, std::string_view val
if (auto b = getValue(token)) {
return std::any {b.value()};
}
+ // uint32_t fields such as layer masks stringify above INT_MAX, keep their bits
+ if (auto wide = getValue(token)) {
+ return std::any {static_cast(wide.value())};
+ }
return std::nullopt;
case FieldType::float_t:
if (auto b = getValue(token)) {
diff --git a/engine/src/toast/physics/aabb.hpp b/engine/src/toast/physics/aabb.hpp
index b2438638e..cb3026024 100644
--- a/engine/src/toast/physics/aabb.hpp
+++ b/engine/src/toast/physics/aabb.hpp
@@ -20,8 +20,6 @@ struct TOAST_API AABB {
[[nodiscard]]
auto overlaps(const AABB& other) const -> bool;
[[nodiscard]]
- auto overlaps(const glm::vec3& origin, const glm::vec3& inv_dir) const -> bool;
- [[nodiscard]]
auto contains(const AABB& other) const -> bool;
[[nodiscard]]
auto expanded(float amount) const -> AABB;
diff --git a/engine/src/toast/physics/aabb_tree.cpp b/engine/src/toast/physics/aabb_tree.cpp
index a8e0cafb0..a1ec2e1fb 100644
--- a/engine/src/toast/physics/aabb_tree.cpp
+++ b/engine/src/toast/physics/aabb_tree.cpp
@@ -216,41 +216,6 @@ auto AABBTree::updateLeaf(TreeNodeID leaf_id, const AABB& tight_bounds) -> bool
return true;
}
-auto AABBTree::query(glm::vec3 pos, glm::vec3 inv_dir, float max_distance, ShapeID ignored_shape) const -> std::vector {
- ZoneScopedN("physics::AABBTree::Query(Ray)");
- ZoneValue(static_cast(ignored_shape.slot));
-
- std::vector result;
- if (m_root == null_node) {
- return result;
- }
-
- std::vector stack {m_root};
- while (not stack.empty()) {
- auto node = m_nodes[stack.back()];
- stack.pop_back();
- if (max_distance >= 0.0f) {
- const auto hit = node.bounds.intersectRay(pos, inv_dir);
- if (not hit || hit->t_max < 0.0f || hit->t_min > max_distance) {
- continue;
- }
- } else if (not node.bounds.overlaps(pos, inv_dir)) {
- continue;
- }
- if (node.isLeaf()) {
- if (node.shape != ignored_shape) {
- result.emplace_back(node.shape);
- }
- } else {
- stack.emplace_back(node.left);
- stack.emplace_back(node.right);
- }
- }
-
- ZoneValue(static_cast(result.size()));
- return result;
-}
-
auto AABBTree::query(const AABB& bounds, ShapeID ignored_shape) const -> std::vector {
// Removed due to bloating tracy shi
// ZoneScopedN("physics::AABBTree::Query");
@@ -519,33 +484,6 @@ auto AABBTree::validate() const -> bool {
return true;
}
-auto AABB::overlaps(const glm::vec3& origin, const glm::vec3& inv_dir) const -> bool {
- float t_entry = -std::numeric_limits::infinity();
- float t_exit = std::numeric_limits::infinity();
-
- for (int axis = 0; axis < 3; ++axis) {
- if (std::isinf(inv_dir[axis])) {
- if (origin[axis] < min[axis] || origin[axis] > max[axis]) {
- return false;
- }
- continue;
- }
-
- float t1 = (min[axis] - origin[axis]) * inv_dir[axis];
- float t2 = (max[axis] - origin[axis]) * inv_dir[axis];
- if (t1 > t2) {
- std::swap(t1, t2);
- }
- t_entry = std::max(t_entry, t1);
- t_exit = std::min(t_exit, t2);
- if (t_entry > t_exit) {
- return false;
- }
- }
-
- return t_exit >= 0.0f;
-}
-
auto AABB::overlaps(const AABB& other) const -> bool {
bool intersects_x = min.x <= other.max.x && max.x >= other.min.x;
bool intersects_y = min.y <= other.max.y && max.y >= other.min.y;
diff --git a/engine/src/toast/physics/aabb_tree.hpp b/engine/src/toast/physics/aabb_tree.hpp
index cb96b354f..7129b6cc7 100644
--- a/engine/src/toast/physics/aabb_tree.hpp
+++ b/engine/src/toast/physics/aabb_tree.hpp
@@ -10,10 +10,14 @@
#include "aabb.hpp"
#include "shape.hpp"
+#include
+#include
#include
#include
#include
+#include
#include
+#include
#include
namespace physics {
@@ -56,9 +60,8 @@ class TOAST_API AABBTree {
auto updateLeaf(TreeNodeID leaf_id, const AABB& tight_bounds) -> bool;
[[nodiscard]]
auto query(const AABB& bounds, ShapeID ignored_shape = {}) const -> std::vector;
- [[nodiscard]]
- auto query(glm::vec3 pos, glm::vec3 inv_dir, float max_distance = -1.0f, ShapeID ignored_shape = {}) const
- -> std::vector;
+ template
+ void raycast(glm::vec3 origin, glm::vec3 inv_dir, float max_distance, Visit&& visit) const;
[[nodiscard]]
auto debugNodes() const -> std::vector;
[[nodiscard]]
@@ -84,4 +87,74 @@ class TOAST_API AABBTree {
size_t m_active_node_count = 0;
};
+template
+void AABBTree::raycast(glm::vec3 origin, glm::vec3 inv_dir, float max_distance, Visit&& visit) const {
+ if (m_root == null_node) {
+ return;
+ }
+
+ // Depth first with the nearer child on top
+ struct Pending {
+ TreeNodeID node;
+ float t_enter;
+ };
+
+ std::array stack;
+ size_t count = 0;
+
+ float reach = max_distance;
+ const auto enters = [&](TreeNodeID id, float& t_enter) {
+ const std::optional hit = m_nodes[id].bounds.intersectRay(origin, inv_dir);
+ if (!hit || hit->t_max < 0.0f || hit->t_min > reach) {
+ return false;
+ }
+ t_enter = std::max(hit->t_min, 0.0f);
+ return true;
+ };
+
+ float t_root = 0.0f;
+ if (!enters(m_root, t_root)) {
+ return;
+ }
+ stack[count++] = {m_root, t_root};
+
+ while (count > 0) {
+ const Pending pending = stack[--count];
+ // The reach may have shrunk
+ if (pending.t_enter > reach) {
+ continue;
+ }
+
+ const TreeNode& node = m_nodes[pending.node];
+ if (node.isLeaf()) {
+ reach = std::min(reach, visit(node.shape, pending.t_enter));
+ continue;
+ }
+
+ float t_left = 0.0f;
+ float t_right = 0.0f;
+ const bool hits_left = enters(node.left, t_left);
+ const bool hits_right = enters(node.right, t_right);
+ TOAST_ASSERT(count + 2 <= stack.size(), "Physics", "AABB tree ray stack is too small for the tree");
+ if (count + 2 > stack.size()) {
+ return;
+ }
+
+ // The farther child goes in first
+ if (hits_left && hits_right) {
+ if (t_left <= t_right) {
+ stack[count++] = {node.right, t_right};
+ stack[count++] = {node.left, t_left};
+ } else {
+ stack[count++] = {node.left, t_left};
+ stack[count++] = {node.right, t_right};
+ }
+ } else if (hits_left) {
+ stack[count++] = {node.left, t_left};
+ } else if (hits_right) {
+ stack[count++] = {node.right, t_right};
+ }
+ }
+}
+
}
diff --git a/engine/src/toast/physics/body.hpp b/engine/src/toast/physics/body.hpp
index 055530269..79a24bbac 100644
--- a/engine/src/toast/physics/body.hpp
+++ b/engine/src/toast/physics/body.hpp
@@ -7,6 +7,8 @@
#pragma once
+#include "layers.hpp"
+
#include
#include
#include
@@ -38,6 +40,10 @@ struct BodyDescriptor {
float gravity_scale = 1.0f;
glm::bvec3 lock_position = glm::bvec3(false);
glm::bvec3 lock_rotation = glm::bvec3(false);
+ float linear_damping = 0.0f;
+ float angular_damping = 0.0f;
+ float drag_scale = 1.0f;
+ LayerFilter layers;
};
struct BodyState {
@@ -59,7 +65,6 @@ struct Body {
bool sleep_locked = false;
float sleep_timer = 0.0f;
float asleep_seconds = 0.0f;
- /// Pose the still window opened at
glm::vec3 sleep_anchor_center = {};
glm::quat sleep_anchor_rotation = glm::quat(1.0f, 0.0f, 0.0f, 0.0f);
glm::vec3 position = {};
@@ -73,10 +78,16 @@ struct Body {
glm::mat3 inverse_inertia_local = {0.0f};
glm::mat3 inverse_inertia_world = {0.0f};
glm::vec3 local_center_of_mass = {};
- /// Distance from the center of mass to the farthest point of the shape
- float extent_radius = 0.0f;
+ float extent_radius = 0.0f; /// Distance to the center of mass
glm::bvec3 lock_position = glm::bvec3(false);
glm::bvec3 lock_rotation = glm::bvec3(false);
+ float linear_damping = 0.0f;
+ float angular_damping = 0.0f;
+ float drag_scale = 1.0f;
+ LayerFilter layers;
+ float drag_coefficient = 0.0f;
+ float drag_area_base = 0.0f;
+ glm::vec3 drag_area_axis = {};
[[nodiscard]]
auto worldCenterOfMass() const -> glm::vec3 {
diff --git a/engine/src/toast/physics/broad_phase.cpp b/engine/src/toast/physics/broad_phase.cpp
index 06a5bf544..c5cb9ee58 100644
--- a/engine/src/toast/physics/broad_phase.cpp
+++ b/engine/src/toast/physics/broad_phase.cpp
@@ -240,6 +240,7 @@ auto BroadPhase::findPairs(CollisionWorldView world) -> std::vector std::vector std::vector {
- ZoneScopedN("physics::QueryRay");
- return m_tree.query(pos, dir, max_distance);
-}
-
auto BroadPhase::queryBounds(const AABB& bounds) const -> std::vector {
ZoneScopedN("physics::QueryBounds");
return m_tree.query(bounds);
@@ -309,6 +305,11 @@ auto BroadPhase::testPair(CollisionWorldView world, ShapeID shape_a_id, ShapeID
return std::nullopt;
}
+ if (not layersCollide(body_a->layers, body_b->layers)) {
+ ++stats.rejected_layers;
+ return std::nullopt;
+ }
+
return canonicalPair(
BodyShapeKey {
.body = shape_a->owner,
diff --git a/engine/src/toast/physics/broad_phase.hpp b/engine/src/toast/physics/broad_phase.hpp
index 018a92727..97b223002 100644
--- a/engine/src/toast/physics/broad_phase.hpp
+++ b/engine/src/toast/physics/broad_phase.hpp
@@ -10,6 +10,7 @@
#include "collision_world.hpp"
#include
+#include
#include
namespace physics {
@@ -39,6 +40,7 @@ struct BroadPhaseStats {
size_t rejected_same_body = 0;
size_t rejected_invalid_bodies = 0;
size_t rejected_immovable_bodies = 0;
+ size_t rejected_layers = 0;
size_t tree_nodes = 0;
};
@@ -56,8 +58,10 @@ class BroadPhase {
[[nodiscard]]
auto queryBounds(const AABB& bounds) const -> std::vector;
- [[nodiscard]]
- auto queryRay(glm::vec3 pos, glm::vec3 dir, float max_distance = -1.0f) const -> std::vector;
+ template
+ void raycast(glm::vec3 origin, glm::vec3 inv_dir, float max_distance, Visit&& visit) const {
+ m_tree.raycast(origin, inv_dir, max_distance, std::forward(visit));
+ }
[[nodiscard]]
auto debugNodes() const -> std::vector;
diff --git a/engine/src/toast/physics/constraint.hpp b/engine/src/toast/physics/constraint.hpp
index bd789ab65..7724af480 100644
--- a/engine/src/toast/physics/constraint.hpp
+++ b/engine/src/toast/physics/constraint.hpp
@@ -8,7 +8,9 @@
#pragma once
#include "collision.hpp"
+#include "material_defaults.hpp"
+#include
#include
namespace physics {
@@ -22,19 +24,28 @@ struct Constraint {
BodyID body_b;
glm::vec3 contact_point = {};
glm::vec3 normal = {};
- glm::vec3 tangent = {};
+ glm::vec3 tangent_1 = {};
+ glm::vec3 tangent_2 = {};
glm::vec3 r_a = {};
glm::vec3 r_b = {};
float penetration = 0.0f;
float normal_mass = 0.0f;
- float tangent_mass = 0.0f;
+ float tangent_mass_1 = 0.0f;
+ float tangent_mass_2 = 0.0f;
float restitution_bias = 0.0f;
- float static_friction = 0.6f;
- float dynamic_friction = 0.4f;
+ float static_friction = material_defaults::k_static_friction;
+ float dynamic_friction = material_defaults::k_dynamic_friction;
float accumulated_normal_impulse = 0.0f;
- float accumulated_tangent_impulse = 0.0f;
+ glm::vec2 accumulated_tangent_impulse = {};
+ float rolling_friction = 0.0f;
+ float spin_friction = 0.0f;
+ float rolling_mass_1 = 0.0f;
+ float rolling_mass_2 = 0.0f;
+ float spin_mass = 0.0f;
+ glm::vec2 accumulated_rolling_impulse = {};
+ float accumulated_spin_impulse = 0.0f;
};
}
diff --git a/engine/src/toast/physics/destruction_material.cpp b/engine/src/toast/physics/destruction_material.cpp
index 2076d2c7d..f1df39d90 100644
--- a/engine/src/toast/physics/destruction_material.cpp
+++ b/engine/src/toast/physics/destruction_material.cpp
@@ -1,5 +1,7 @@
#include "destruction_material.hpp"
+#include "material_defaults.hpp"
+
assets::DestructionMaterial::DestructionMaterial(const toml::table& table, Handle schema)
: Data(table, std::move(schema), Data::keep_all_keys) {
readFields();
@@ -10,10 +12,10 @@ void assets::DestructionMaterial::onReloaded() {
}
void assets::DestructionMaterial::readFields() {
- m_density = 1000.0f;
- m_toughness = 1000.0f;
- m_structural_strength = 1000.0f;
- m_shatter_radius = 0.5f;
+ m_density = physics::material_defaults::k_density;
+ m_toughness = physics::material_defaults::k_toughness;
+ m_structural_strength = physics::material_defaults::k_structural_strength;
+ m_shatter_radius = physics::material_defaults::k_shatter_radius;
m_destructible = true;
m_flammable = false;
m_burn_rate = 0.0f;
diff --git a/engine/src/toast/physics/destruction_material.hpp b/engine/src/toast/physics/destruction_material.hpp
index 6fa68bc80..567d4c9af 100644
--- a/engine/src/toast/physics/destruction_material.hpp
+++ b/engine/src/toast/physics/destruction_material.hpp
@@ -55,10 +55,10 @@ class TOAST_API DestructionMaterial : public Data {
private:
void readFields();
- float m_density = 1000.0f;
- float m_toughness = 1000.0f;
- float m_structural_strength = 1000.0f;
- float m_shatter_radius = 0.5f;
+ float m_density = 0.0f;
+ float m_toughness = 0.0f;
+ float m_structural_strength = 0.0f;
+ float m_shatter_radius = 0.0f;
bool m_destructible = true;
bool m_flammable = false;
float m_burn_rate = 0.0f;
diff --git a/engine/src/toast/physics/layers.cpp b/engine/src/toast/physics/layers.cpp
new file mode 100644
index 000000000..d1144f63b
--- /dev/null
+++ b/engine/src/toast/physics/layers.cpp
@@ -0,0 +1,114 @@
+#include "layers.hpp"
+
+#include
+#include
+#include
+
+namespace physics {
+
+namespace {
+
+struct LayerInfo {
+ std::string name;
+ std::string abbreviation;
+};
+
+struct LayerTable {
+ std::mutex mutex;
+ std::array layers;
+};
+
+auto table() -> LayerTable& {
+ static LayerTable instance;
+ return instance;
+}
+
+auto defaultLayer(int layer) -> LayerInfo {
+ switch (layer) {
+ case default_layer: return {.name = "Default", .abbreviation = "0"};
+ case trigger_layer: return {.name = "Trigger", .abbreviation = "T"};
+ case characters_layer: return {.name = "Characters", .abbreviation = "Ch"};
+ case player_layer: return {.name = "Player", .abbreviation = "P"};
+ case small_debris_layer: return {.name = "Small debris", .abbreviation = "Ds"};
+ case large_debris_layer: return {.name = "Large debris", .abbreviation = "Dl"};
+ case projectiles_layer: return {.name = "Projectiles", .abbreviation = "Pry"};
+ default: return {.name = "Layer " + std::to_string(layer), .abbreviation = std::to_string(layer)};
+ }
+}
+
+auto settingKey(int layer, std::string_view field) -> std::string {
+ return "physics.layers." + std::to_string(layer) + "." + std::string {field};
+}
+
+}
+
+auto layerMask(const std::vector& layers) -> uint32_t {
+ if (layers.empty()) {
+ return k_all_layers;
+ }
+ uint32_t mask = 0;
+ for (const int layer : layers) {
+ mask |= layerBit(layer);
+ }
+ return mask;
+}
+
+auto getLayer(std::string_view name) -> int {
+ LayerTable& all = table();
+ std::scoped_lock lock(all.mutex);
+ for (int layer = 0; layer < k_layer_count; ++layer) {
+ if (all.layers[layer].name == name) {
+ return layer;
+ }
+ }
+ return -1;
+}
+
+auto layerName(int layer) -> std::string {
+ if (layer < 0 || layer >= k_layer_count) {
+ return {};
+ }
+ LayerTable& all = table();
+ std::scoped_lock lock(all.mutex);
+ return all.layers[layer].name;
+}
+
+auto layerAbbreviation(int layer) -> std::string {
+ if (layer < 0 || layer >= k_layer_count) {
+ return {};
+ }
+ LayerTable& all = table();
+ std::scoped_lock lock(all.mutex);
+ return all.layers[layer].abbreviation;
+}
+
+void registerLayerSettings() {
+ LayerTable& all = table();
+ {
+ std::scoped_lock lock(all.mutex);
+ for (int layer = 0; layer < k_layer_count; ++layer) {
+ all.layers[layer] = defaultLayer(layer);
+ }
+ }
+
+ // Default can not be renamed so it has no setting
+ for (int layer = 1; layer < k_layer_count; ++layer) {
+ const LayerInfo info = defaultLayer(layer);
+ toast::settings::declareString(settingKey(layer, "name"), info.name, {.label = info.name, .category = "Tags", .hidden = true})
+ .onChange([layer](const std::string& value) {
+ LayerTable& all = table();
+ std::scoped_lock lock(all.mutex);
+ all.layers[layer].name = value;
+ });
+ toast::settings::declareString(
+ settingKey(layer, "abbr"), info.abbreviation, {.label = info.abbreviation, .category = "Tags", .hidden = true}
+ )
+ .onChange([layer](const std::string& value) {
+ LayerTable& all = table();
+ std::scoped_lock lock(all.mutex);
+ all.layers[layer].abbreviation = value.substr(0, k_layer_abbreviation_length);
+ });
+ }
+}
+
+}
diff --git a/engine/src/toast/physics/layers.hpp b/engine/src/toast/physics/layers.hpp
new file mode 100644
index 000000000..84acb630f
--- /dev/null
+++ b/engine/src/toast/physics/layers.hpp
@@ -0,0 +1,81 @@
+/**
+ * @file layers.hpp
+ * @author Xein
+ * @date 12 Oct 2026
+ * @brief Collision layers
+ */
+
+#pragma once
+#include
+#include
+#include
+#include
+#include
+
+namespace physics {
+
+inline constexpr int k_layer_count = 32;
+inline constexpr uint32_t k_all_layers = 0xFFFF'FFFFu;
+inline constexpr size_t k_layer_abbreviation_length = 3;
+
+enum BuiltinLayer : uint8_t {
+ default_layer = 0,
+ trigger_layer,
+ characters_layer,
+ player_layer,
+ small_debris_layer,
+ large_debris_layer,
+ projectiles_layer,
+};
+
+[[nodiscard]]
+constexpr auto layerBit(int layer) -> uint32_t {
+ return layer >= 0 && layer < k_layer_count ? 1u << static_cast(layer) : 0u;
+}
+
+struct LayerFilter {
+ uint32_t included = layerBit(default_layer);
+ uint32_t excluded = 0;
+};
+
+/**
+ * Puts @p layer in or out of the included mask
+ */
+constexpr void includeLayer(LayerFilter& filter, int layer, bool value) {
+ const uint32_t bit = layerBit(layer);
+ filter.included = value ? filter.included | bit : filter.included & ~bit;
+ if (value) {
+ filter.excluded &= ~bit;
+ }
+}
+
+constexpr void excludeLayer(LayerFilter& filter, int layer, bool value) {
+ const uint32_t bit = layerBit(layer);
+ filter.excluded = value ? filter.excluded | bit : filter.excluded & ~bit;
+ if (value) {
+ filter.included &= ~bit;
+ }
+}
+
+[[nodiscard]]
+constexpr auto layersCollide(LayerFilter a, LayerFilter b) -> bool {
+ return (a.included & b.included) != 0 && (a.excluded & b.included) == 0 && (b.excluded & a.included) == 0;
+}
+
+/// Mask of the given layer indices
+[[nodiscard]]
+TOAST_API auto layerMask(const std::vector& layers) -> uint32_t;
+
+/// Index of the layer named @p name
+[[nodiscard]]
+TOAST_API auto getLayer(std::string_view name) -> int;
+
+[[nodiscard]]
+TOAST_API auto layerName(int layer) -> std::string;
+
+[[nodiscard]]
+TOAST_API auto layerAbbreviation(int layer) -> std::string;
+
+void registerLayerSettings();
+
+}
diff --git a/engine/src/toast/physics/manifold.cpp b/engine/src/toast/physics/manifold.cpp
index 10d1a70be..c5f39232d 100644
--- a/engine/src/toast/physics/manifold.cpp
+++ b/engine/src/toast/physics/manifold.cpp
@@ -183,40 +183,78 @@ auto closestPointOnBox(const glm::vec3& point, const WorldBox& box) -> glm::vec3
return box.center + box.rotation * local_closest;
}
-auto closestPointsSegmentBox(const glm::vec3& segment_a, const glm::vec3& segment_b, const WorldBox& box)
- -> SegmentBoxClosestPoints {
- const glm::mat3 inverse_rotation = glm::transpose(box.rotation);
- const glm::vec3 local_a = inverse_rotation * (segment_a - box.center);
- const glm::vec3 local_b = inverse_rotation * (segment_b - box.center);
+auto closestPointsSegmentAabb(const glm::vec3& local_a, const glm::vec3& local_b, const glm::vec3& half_extents)
+ -> SegmentAabbClosestPoints {
const glm::vec3 direction = local_b - local_a;
+ const float axis_epsilon = std::sqrt(direction_epsilon_sq);
- std::vector interval_ends {0.0f, 1.0f};
- interval_ends.reserve(8);
- for (uint8_t axis = 0; axis < 3; ++axis) {
- if (std::abs(direction[axis]) <= std::sqrt(direction_epsilon_sq)) {
+ int moving_axis = 0;
+ int moving_axes = 0;
+ for (int axis = 0; axis < 3; ++axis) {
+ if (std::abs(direction[axis]) > axis_epsilon) {
+ moving_axis = axis;
+ ++moving_axes;
+ }
+ }
+
+ // a segment along one axis or point needs no intervals
+ if (moving_axes <= 1) {
+ float parameter = 0.0f;
+ if (moving_axes == 1) {
+ const float start = local_a[moving_axis];
+ const float step = direction[moving_axis];
+ const float extent = half_extents[moving_axis];
+ if (start < -extent && step > 0.0f) {
+ parameter = std::min((-extent - start) / step, 1.0f);
+ } else if (start > extent && step < 0.0f) {
+ parameter = std::min((extent - start) / step, 1.0f);
+ }
+ }
+ const glm::vec3 segment_point = local_a + direction * parameter;
+ return {
+ .segment_point = segment_point,
+ .box_point = glm::clamp(segment_point, -half_extents, half_extents),
+ .segment_parameter = parameter,
+ };
+ }
+
+ // 0, 1 and two crossings per axis
+ std::array interval_ends {0.0f, 1.0f};
+ size_t end_count = 2;
+ for (int axis = 0; axis < 3; ++axis) {
+ if (std::abs(direction[axis]) <= axis_epsilon) {
continue;
}
- for (const float side : std::array {-box.half_extents[axis], box.half_extents[axis]}) {
+ for (const float side : {-half_extents[axis], half_extents[axis]}) {
const float parameter = (side - local_a[axis]) / direction[axis];
if (parameter > 0.0f && parameter < 1.0f) {
- interval_ends.emplace_back(parameter);
+ interval_ends[end_count++] = parameter;
}
}
}
- std::ranges::sort(interval_ends);
- const auto unique_end = std::ranges::unique(interval_ends, [](float lhs, float rhs) {
- return std::abs(lhs - rhs) <= std::numeric_limits::epsilon();
- }).begin();
- interval_ends.erase(unique_end, interval_ends.end());
+ for (size_t i = 1; i < end_count; ++i) {
+ const float value = interval_ends[i];
+ size_t j = i;
+ for (; j > 0 && interval_ends[j - 1] > value; --j) {
+ interval_ends[j] = interval_ends[j - 1];
+ }
+ interval_ends[j] = value;
+ }
+ size_t unique_count = 1;
+ for (size_t i = 1; i < end_count; ++i) {
+ if (std::abs(interval_ends[i] - interval_ends[unique_count - 1]) > std::numeric_limits::epsilon()) {
+ interval_ends[unique_count++] = interval_ends[i];
+ }
+ }
float best_parameter = 0.0f;
float best_distance_squared = std::numeric_limits::infinity();
glm::vec3 best_segment_point = local_a;
- glm::vec3 best_box_point = glm::clamp(local_a, -box.half_extents, box.half_extents);
+ glm::vec3 best_box_point = glm::clamp(local_a, -half_extents, half_extents);
const auto test_parameter = [&](float parameter) {
const glm::vec3 segment_point = local_a + direction * parameter;
- const glm::vec3 box_point = glm::clamp(segment_point, -box.half_extents, box.half_extents);
+ const glm::vec3 box_point = glm::clamp(segment_point, -half_extents, half_extents);
const glm::vec3 delta = box_point - segment_point;
const float distance_squared = glm::dot(delta, delta);
if (distance_squared < best_distance_squared || (distance_squared == best_distance_squared && parameter < best_parameter)) {
@@ -227,20 +265,20 @@ auto closestPointsSegmentBox(const glm::vec3& segment_a, const glm::vec3& segmen
}
};
- for (size_t interval = 0; interval + 1 < interval_ends.size(); ++interval) {
+ for (size_t interval = 0; interval + 1 < unique_count; ++interval) {
const float interval_start = interval_ends[interval];
const float interval_end = interval_ends[interval + 1];
const float midpoint = 0.5f * (interval_start + interval_end);
float numerator = 0.0f;
float denominator = 0.0f;
- for (uint8_t axis = 0; axis < 3; ++axis) {
+ for (int axis = 0; axis < 3; ++axis) {
const float midpoint_coordinate = local_a[axis] + (direction[axis] * midpoint);
float offset = 0.0f;
- if (midpoint_coordinate < -box.half_extents[axis]) {
- offset = local_a[axis] + box.half_extents[axis];
- } else if (midpoint_coordinate > box.half_extents[axis]) {
- offset = local_a[axis] - box.half_extents[axis];
+ if (midpoint_coordinate < -half_extents[axis]) {
+ offset = local_a[axis] + half_extents[axis];
+ } else if (midpoint_coordinate > half_extents[axis]) {
+ offset = local_a[axis] - half_extents[axis];
} else {
continue;
}
@@ -255,12 +293,53 @@ auto closestPointsSegmentBox(const glm::vec3& segment_a, const glm::vec3& segmen
}
}
+ return {.segment_point = best_segment_point, .box_point = best_box_point, .segment_parameter = best_parameter};
+}
+
+auto closestPointsSegmentBox(const glm::vec3& segment_a, const glm::vec3& segment_b, const WorldBox& box)
+ -> SegmentBoxClosestPoints {
+ const glm::mat3 inverse_rotation = glm::transpose(box.rotation);
+ const SegmentAabbClosestPoints local = closestPointsSegmentAabb(
+ inverse_rotation * (segment_a - box.center), inverse_rotation * (segment_b - box.center), box.half_extents
+ );
+
return {
- .segment_point = box.center + box.rotation * best_segment_point,
- .box_point = box.center + box.rotation * best_box_point,
- .segment_parameter = best_parameter,
- .box_feature = boxFeature(best_box_point, box.half_extents),
+ .segment_point = box.center + box.rotation * local.segment_point,
+ .box_point = box.center + box.rotation * local.box_point,
+ .segment_parameter = local.segment_parameter,
+ .box_feature = boxFeature(local.box_point, box.half_extents),
+ };
+}
+
+auto capsuleVoxelContact(
+ const glm::vec3& segment_a, const glm::vec3& segment_b, float radius, const glm::vec3& voxel_min, uint8_t normal_index
+) -> std::optional {
+ const glm::vec3 half_extents {voxel::k_voxel_size * 0.5f};
+ const glm::vec3 center = voxel_min + half_extents;
+ const SegmentAabbClosestPoints closest = closestPointsSegmentAabb(segment_a - center, segment_b - center, half_extents);
+ const glm::vec3 delta = closest.box_point - closest.segment_point;
+ const float distance_sq = glm::dot(delta, delta);
+ if (not std::isfinite(distance_sq) || distance_sq > radius * radius) {
+ return std::nullopt;
+ }
+
+ const glm::vec3 segment_point = closest.segment_point + center;
+ CapsuleVoxelContact contact {
+ .local_point = closest.box_point + center,
+ .segment_parameter = closest.segment_parameter,
};
+ if (distance_sq <= direction_epsilon_sq) {
+ // axis nside the voxel
+ contact.local_normal = -glm::vec3(voxel::normalDirection(normal_index));
+ contact.local_surface = segment_point - contact.local_normal * radius;
+ contact.penetration = radius;
+ } else {
+ const float distance = std::sqrt(distance_sq);
+ contact.local_normal = delta / distance;
+ contact.local_surface = segment_point + contact.local_normal * radius;
+ contact.penetration = radius - distance;
+ }
+ return contact;
}
auto perpendicularTo(const glm::vec3& segment) -> glm::vec3 {
@@ -289,10 +368,23 @@ auto perpendicularTo(const glm::vec3& segment) -> glm::vec3 {
}
auto combineMaterials(const PhysicsMaterial& a, const PhysicsMaterial& b) -> ContactMaterial {
+ // geometric mean
+ return {
+ .restitution = std::max(a.restitution, b.restitution),
+ .static_friction = std::sqrt(std::max(a.static_friction * b.static_friction, 0.0f)),
+ .dynamic_friction = std::sqrt(std::max(a.dynamic_friction * b.dynamic_friction, 0.0f)),
+ .rolling_friction = std::max(a.rolling_friction, b.rolling_friction),
+ .spin_friction = std::max(a.spin_friction, b.spin_friction),
+ };
+}
+
+auto surfaceMaterial(const voxel::PhysicalMaterial& material) -> PhysicsMaterial {
return {
- .restitution = (a.restitution + b.restitution) * 0.5f,
- .static_friction = (a.static_friction + b.static_friction) * 0.5f,
- .dynamic_friction = (a.dynamic_friction + b.dynamic_friction) * 0.5f,
+ .restitution = material.restitution,
+ .static_friction = material.static_friction,
+ .dynamic_friction = material.dynamic_friction,
+ .rolling_friction = material.rolling_friction,
+ .spin_friction = material.spin_friction,
};
}
@@ -1338,11 +1430,7 @@ void collideSphereVoxel(
.local_surface = local_surface,
.penetration = penetration,
.voxel_feature = voxelFeature(type, candidate.brick_slot, candidate.local_index, candidate.normal_index),
- .material = {
- .restitution = candidate.material->restitution,
- .static_friction = candidate.material->static_friction,
- .dynamic_friction = candidate.material->dynamic_friction,
- },
+ .material = _detail::combineMaterials(sph_shape.material, _detail::surfaceMaterial(*candidate.material)),
};
});
}
@@ -1806,18 +1894,8 @@ auto collideVoxelVoxelRegion(const VoxelVoxelPairView& view, const AABB& probe_r
if (not partial.group_started[group]) {
partial.group_started[group] = true;
partial.normal_per_group[group] = sat->normal;
- partial.material_per_group[group] = _detail::combineMaterials(
- PhysicsMaterial {
- .restitution = probe_c.material->restitution,
- .static_friction = probe_c.material->static_friction,
- .dynamic_friction = probe_c.material->dynamic_friction,
- },
- PhysicsMaterial {
- .restitution = ref_c.material->restitution,
- .static_friction = ref_c.material->static_friction,
- .dynamic_friction = ref_c.material->dynamic_friction,
- }
- );
+ partial.material_per_group[group] =
+ _detail::combineMaterials(_detail::surfaceMaterial(*probe_c.material), _detail::surfaceMaterial(*ref_c.material));
}
// Same reasoning as collideBoxVoxel bounds one region bucket to a batch at a time
@@ -2037,16 +2115,7 @@ void collideVoxelVoxel(
group_started[group] = true;
normal_per_group[group] = sat->normal;
material_per_group[group] = _detail::combineMaterials(
- PhysicsMaterial {
- .restitution = probe_c.material->restitution,
- .static_friction = probe_c.material->static_friction,
- .dynamic_friction = probe_c.material->dynamic_friction
- },
- PhysicsMaterial {
- .restitution = ref_c.material->restitution,
- .static_friction = ref_c.material->static_friction,
- .dynamic_friction = ref_c.material->dynamic_friction
- }
+ _detail::surfaceMaterial(*probe_c.material), _detail::surfaceMaterial(*ref_c.material)
);
}
// clang-format on
@@ -2152,52 +2221,27 @@ void collideCapsuleVoxel(
return;
}
- const _detail::WorldBox voxel_box {
- .center = (candidate.min + candidate.max) * 0.5f,
- .rotation = glm::mat3(1.0f),
- .half_extents = (candidate.max - candidate.min) * 0.5f,
- };
-
- const _detail::SegmentBoxClosestPoints closest = _detail::closestPointsSegmentBox(local_a, local_b, voxel_box);
- const glm::vec3 delta = closest.box_point - closest.segment_point;
- const float distance_sq = glm::dot(delta, delta);
- if (not std::isfinite(distance_sq) || distance_sq > radius * radius) {
+ const std::optional<_detail::CapsuleVoxelContact> contact =
+ _detail::capsuleVoxelContact(local_a, local_b, radius, candidate.min, candidate.normal_index);
+ if (not contact.has_value()) {
return;
}
- glm::vec3 local_normal;
- glm::vec3 local_surface;
- float penetration;
- if (distance_sq <= _detail::direction_epsilon_sq) {
- local_normal = -glm::vec3(voxel::normalDirection(candidate.normal_index));
- local_surface = closest.segment_point - local_normal * radius;
- penetration = radius;
- } else {
- const float distance = std::sqrt(distance_sq);
- local_normal = delta / distance;
- local_surface = closest.segment_point + local_normal * radius;
- penetration = radius - distance;
- }
-
std::optional& slot = best_per_normal[candidate.normal_index];
- if (slot.has_value() && slot->penetration >= penetration) {
+ if (slot.has_value() && slot->penetration >= contact->penetration) {
return;
}
const FeatureType type =
candidate.classification == voxel::VoxelClass::edge ? FeatureType::voxel_edge : FeatureType::voxel_face;
slot = BestContact {
- .local_normal = local_normal,
- .local_point = closest.box_point,
- .local_surface = local_surface,
- .penetration = penetration,
- .segment_parameter = closest.segment_parameter,
+ .local_normal = contact->local_normal,
+ .local_point = contact->local_point,
+ .local_surface = contact->local_surface,
+ .penetration = contact->penetration,
+ .segment_parameter = contact->segment_parameter,
.voxel_feature = voxelFeature(type, candidate.brick_slot, candidate.local_index, candidate.normal_index),
- .material = {
- .restitution = candidate.material->restitution,
- .static_friction = candidate.material->static_friction,
- .dynamic_friction = candidate.material->dynamic_friction,
- },
+ .material = _detail::combineMaterials(caps_shape.material, _detail::surfaceMaterial(*candidate.material)),
};
});
}
@@ -2399,11 +2443,8 @@ auto collideBoxVoxelRegion(const BoxVoxelPairView& view, const AABB& region) ->
if (not partial.group_started[group]) {
partial.group_started[group] = true;
partial.normal_per_group[group] = sat->normal;
- partial.material_per_group[group] = {
- .restitution = candidate.material->restitution,
- .static_friction = candidate.material->static_friction,
- .dynamic_friction = candidate.material->dynamic_friction,
- };
+ partial.material_per_group[group] =
+ _detail::combineMaterials(view.box.shape.material, _detail::surfaceMaterial(*candidate.material));
}
// Same reasoning as collideBoxVoxel bounds one region bucket to a batch at a time
@@ -2560,11 +2601,7 @@ void collideBoxVoxel(
if (not group_started[group]) {
group_started[group] = true;
normal_per_group[group] = sat->normal;
- material_per_group[group] = {
- .restitution = candidate.material->restitution,
- .static_friction = candidate.material->static_friction,
- .dynamic_friction = candidate.material->dynamic_friction,
- };
+ material_per_group[group] = _detail::combineMaterials(box_shape.material, _detail::surfaceMaterial(*candidate.material));
}
// Bounds this to a batch worth of candidates at a time survivors compete again next batch
diff --git a/engine/src/toast/physics/manifold.hpp b/engine/src/toast/physics/manifold.hpp
index 49f95480c..f2138a905 100644
--- a/engine/src/toast/physics/manifold.hpp
+++ b/engine/src/toast/physics/manifold.hpp
@@ -9,6 +9,7 @@
#pragma once
#include "collision.hpp"
#include "collision_world.hpp"
+#include "material_defaults.hpp"
#include "voxel_shape_data.hpp"
#include
@@ -22,9 +23,11 @@
namespace physics {
struct ContactMaterial {
- float restitution = 0.0f;
- float static_friction = 0.6f;
- float dynamic_friction = 0.4f;
+ float restitution = material_defaults::k_restitution;
+ float static_friction = material_defaults::k_static_friction;
+ float dynamic_friction = material_defaults::k_dynamic_friction;
+ float rolling_friction = material_defaults::k_rolling_friction;
+ float spin_friction = material_defaults::k_spin_friction;
};
struct ContactPoint {
@@ -148,6 +151,20 @@ struct SegmentBoxClosestPoints {
BoxFeature box_feature;
};
+struct SegmentAabbClosestPoints {
+ glm::vec3 segment_point;
+ glm::vec3 box_point;
+ float segment_parameter;
+};
+
+struct CapsuleVoxelContact {
+ glm::vec3 local_normal;
+ glm::vec3 local_point;
+ glm::vec3 local_surface;
+ float penetration;
+ float segment_parameter;
+};
+
struct ContactCandidate {
glm::vec3 position {};
float penetration = 0.0f;
@@ -176,9 +193,18 @@ auto closestPointOnBox(const glm::vec3& point, const WorldBox& box) -> glm::vec3
auto closestPointsSegmentBox(const glm::vec3& segment_a, const glm::vec3& segment_b, const WorldBox& box)
-> SegmentBoxClosestPoints;
[[nodiscard]]
+auto closestPointsSegmentAabb(const glm::vec3& local_a, const glm::vec3& local_b, const glm::vec3& half_extents)
+ -> SegmentAabbClosestPoints;
+[[nodiscard]]
+auto capsuleVoxelContact(
+ const glm::vec3& segment_a, const glm::vec3& segment_b, float radius, const glm::vec3& voxel_min, uint8_t normal_index
+) -> std::optional;
+[[nodiscard]]
auto perpendicularTo(const glm::vec3& segment) -> glm::vec3;
[[nodiscard]]
auto combineMaterials(const PhysicsMaterial& a, const PhysicsMaterial& b) -> ContactMaterial;
+[[nodiscard]]
+auto surfaceMaterial(const voxel::PhysicalMaterial& material) -> PhysicsMaterial;
}
diff --git a/engine/src/toast/physics/material_defaults.hpp b/engine/src/toast/physics/material_defaults.hpp
new file mode 100644
index 000000000..3cb7dea1a
--- /dev/null
+++ b/engine/src/toast/physics/material_defaults.hpp
@@ -0,0 +1,29 @@
+/**
+ * @file material_defaults.hpp
+ * @author Xein
+ * @date 12 Oct 2026
+ * @brief The values a material has when nothing sets them
+ */
+
+#pragma once
+
+namespace physics::material_defaults {
+
+inline constexpr float k_restitution = 0.1f;
+inline constexpr float k_static_friction = 0.6f;
+inline constexpr float k_dynamic_friction = 0.5f;
+
+/// A ball rolling on a hard floor loses a few percent of its speed per metre
+inline constexpr float k_rolling_friction = 0.01f;
+inline constexpr float k_spin_friction = 0.005f;
+
+/// between wood at 600 and stone at 2600
+inline constexpr float k_density = 2000.0f;
+inline constexpr float k_toughness = 2000.0f;
+
+/// weak bricks
+inline constexpr float k_structural_strength = 1.0e6f;
+
+inline constexpr float k_shatter_radius = 0.3f;
+
+}
diff --git a/engine/src/toast/physics/nodes/dynamic_rigidbody.hpp b/engine/src/toast/physics/nodes/dynamic_rigidbody.hpp
index 16c0867ff..74d620e77 100644
--- a/engine/src/toast/physics/nodes/dynamic_rigidbody.hpp
+++ b/engine/src/toast/physics/nodes/dynamic_rigidbody.hpp
@@ -40,6 +40,13 @@ class [[ToastNode, Icon("RigidBody")]] TOAST_API DynamicRigidbody : public physi
[[Reflect, ReadOnly]]
bool awake = true;
+ [[Reflect, Group("Drag"), Unit("1/s")]]
+ float linear_damping = 0.0f;
+ [[Reflect, Group("Drag"), Unit("1/s")]]
+ float angular_damping = 0.0f;
+ [[Reflect, Group("Drag")]]
+ float drag_scale = 1.0f;
+
// TODO:
[[Reflect, Group("Mass Distribution"), Unit("m"), ReadOnly]]
@@ -64,6 +71,9 @@ class [[ToastNode, Icon("RigidBody")]] TOAST_API DynamicRigidbody : public physi
descriptor.mass = mass;
descriptor.gravity_scale = gravity_scale;
descriptor.allow_sleep = allow_sleep;
+ descriptor.linear_damping = linear_damping;
+ descriptor.angular_damping = angular_damping;
+ descriptor.drag_scale = drag_scale;
}
};
diff --git a/engine/src/toast/physics/nodes/rigidbody.cpp b/engine/src/toast/physics/nodes/rigidbody.cpp
index fd6a70880..d76737d5a 100644
--- a/engine/src/toast/physics/nodes/rigidbody.cpp
+++ b/engine/src/toast/physics/nodes/rigidbody.cpp
@@ -113,6 +113,24 @@ void Rigidbody::handleContactEnd(const BroadPhasePair& pair) {
contact_end.fire(other_node);
}
+void Rigidbody::setLayers(int included_layers, int excluded_layers) {
+ included = static_cast(included_layers);
+ excluded = static_cast(excluded_layers);
+ Simulator::setBodyLayers(m_body, {.included = included, .excluded = excluded});
+}
+
+void Rigidbody::includeLayer(int layer, bool value) {
+ LayerFilter layers {.included = included, .excluded = excluded};
+ physics::includeLayer(layers, layer, value);
+ setLayers(static_cast(layers.included), static_cast(layers.excluded));
+}
+
+void Rigidbody::excludeLayer(int layer, bool value) {
+ LayerFilter layers {.included = included, .excluded = excluded};
+ physics::excludeLayer(layers, layer, value);
+ setLayers(static_cast(layers.included), static_cast(layers.excluded));
+}
+
void Rigidbody::onEnable() {
Simulator::setBodyEnabled(m_body, true);
}
@@ -129,6 +147,7 @@ auto Rigidbody::descriptor() const -> BodyDescriptor {
result.rotation = world_rotation;
result.lock_position = {lock_pos_x, lock_pos_y, lock_pos_z};
result.lock_rotation = {lock_rot_x, lock_rot_y, lock_rot_z};
+ result.layers = {.included = included, .excluded = excluded};
configureBodyDescriptor(result);
return result;
}
diff --git a/engine/src/toast/physics/nodes/rigidbody.hpp b/engine/src/toast/physics/nodes/rigidbody.hpp
index 9f83f3720..3eea45af8 100644
--- a/engine/src/toast/physics/nodes/rigidbody.hpp
+++ b/engine/src/toast/physics/nodes/rigidbody.hpp
@@ -26,6 +26,21 @@ class [[ToastNode, Hidden, Interface, Icon("PhysicsBody"), Color("Green")]] TOAS
signals::Signal> contact_begin;
signals::Signal> contact_end;
+ [[Reflect, Layers("excluded"), Name("Layers")]]
+ uint32_t included = 1; // Default
+
+ [[Reflect, LayersExcluded]]
+ uint32_t excluded = 0;
+
+ [[Reflect]]
+ void setLayers(int included_layers, int excluded_layers);
+
+ [[Reflect]]
+ void includeLayer(int layer, bool value = true);
+
+ [[Reflect]]
+ void excludeLayer(int layer, bool value = true);
+
protected:
explicit Rigidbody(BodyType type) : m_body_type(type) { }
diff --git a/engine/src/toast/physics/nodes/trigger.cpp b/engine/src/toast/physics/nodes/trigger.cpp
index c6276e1e5..2b332d0f7 100644
--- a/engine/src/toast/physics/nodes/trigger.cpp
+++ b/engine/src/toast/physics/nodes/trigger.cpp
@@ -59,6 +59,20 @@ void Trigger::enableCollider(bool value) {
}
}
+void Trigger::includeLayer(int layer, bool value) {
+ LayerFilter layers {.included = included, .excluded = excluded};
+ physics::includeLayer(layers, layer, value);
+ included = layers.included;
+ excluded = layers.excluded;
+}
+
+void Trigger::excludeLayer(int layer, bool value) {
+ LayerFilter layers {.included = included, .excluded = excluded};
+ physics::excludeLayer(layers, layer, value);
+ included = layers.included;
+ excluded = layers.excluded;
+}
+
void Trigger::begin() {
if (participatesIn(toast::NodeOwnerParticipation::gameplay_tick)) {
Simulator::registerTrigger(*this);
diff --git a/engine/src/toast/physics/nodes/trigger.hpp b/engine/src/toast/physics/nodes/trigger.hpp
index 079131e80..61ae2c376 100644
--- a/engine/src/toast/physics/nodes/trigger.hpp
+++ b/engine/src/toast/physics/nodes/trigger.hpp
@@ -37,6 +37,18 @@ class TOAST_API [[ToastNode, Icon("Area"), Color("Green")]] Trigger : public toa
[[Reflect]]
void enableCollider(bool value);
+ [[Reflect]]
+ void includeLayer(int layer, bool value = true);
+
+ [[Reflect]]
+ void excludeLayer(int layer, bool value = true);
+
+ [[Reflect, Layers("excluded"), Name("Layers")]]
+ uint32_t included = 2; // Trigger
+
+ [[Reflect, LayersExcluded]]
+ uint32_t excluded = 0;
+
/// Color of the collider while something is inside
[[Reflect, Color]]
glm::vec4 debug_color = glm::vec4(0.0f, 1.0f, 0.251f, 0.5f);
diff --git a/engine/src/toast/physics/physics_material.cpp b/engine/src/toast/physics/physics_material.cpp
index 1bc623fcc..51bda4657 100644
--- a/engine/src/toast/physics/physics_material.cpp
+++ b/engine/src/toast/physics/physics_material.cpp
@@ -1,5 +1,9 @@
#include "physics_material.hpp"
+#include "material_defaults.hpp"
+
+#include
+
namespace physics { }
assets::PhysicsMaterial::PhysicsMaterial(const toml::table& table, Handle schema)
@@ -12,9 +16,11 @@ void assets::PhysicsMaterial::onReloaded() {
}
void assets::PhysicsMaterial::readFields() {
- m_restitution = 0.1f;
- m_static_friction = 0.6f;
- m_dynamic_friction = 0.5f;
+ m_restitution = physics::material_defaults::k_restitution;
+ m_static_friction = physics::material_defaults::k_static_friction;
+ m_dynamic_friction = physics::material_defaults::k_dynamic_friction;
+ m_rolling_friction = physics::material_defaults::k_rolling_friction;
+ m_spin_friction = physics::material_defaults::k_spin_friction;
const auto& d = static_cast(m_root);
if (d.contains("restitution")) {
m_restitution = d["restitution"].as();
@@ -25,6 +31,17 @@ void assets::PhysicsMaterial::readFields() {
if (d.contains("dynamic_friction")) {
m_dynamic_friction = d["dynamic_friction"].as();
}
+ if (d.contains("rolling_friction")) {
+ m_rolling_friction = d["rolling_friction"].as();
+ }
+ if (d.contains("spin_friction")) {
+ m_spin_friction = d["spin_friction"].as();
+ }
+ // sliding should never be higher than static friction
+ m_dynamic_friction = std::min(m_dynamic_friction, m_static_friction);
+ // a negative resistance would speed the body up
+ m_rolling_friction = std::max(m_rolling_friction, 0.0f);
+ m_spin_friction = std::max(m_spin_friction, 0.0f);
}
auto assets::PhysicsMaterial::get() const -> toml::table {
@@ -42,3 +59,11 @@ auto assets::PhysicsMaterial::staticFriction() const noexcept -> float {
auto assets::PhysicsMaterial::dynamicFriction() const noexcept -> float {
return m_dynamic_friction;
}
+
+auto assets::PhysicsMaterial::rollingFriction() const noexcept -> float {
+ return m_rolling_friction;
+}
+
+auto assets::PhysicsMaterial::spinFriction() const noexcept -> float {
+ return m_spin_friction;
+}
diff --git a/engine/src/toast/physics/physics_material.hpp b/engine/src/toast/physics/physics_material.hpp
index 3cb886be2..516d55351 100644
--- a/engine/src/toast/physics/physics_material.hpp
+++ b/engine/src/toast/physics/physics_material.hpp
@@ -28,6 +28,10 @@ class TOAST_API PhysicsMaterial : public Data {
auto staticFriction() const noexcept -> float;
[[nodiscard]]
auto dynamicFriction() const noexcept -> float;
+ [[nodiscard]]
+ auto rollingFriction() const noexcept -> float;
+ [[nodiscard]]
+ auto spinFriction() const noexcept -> float;
protected:
void onReloaded() override;
@@ -35,9 +39,11 @@ class TOAST_API PhysicsMaterial : public Data {
private:
void readFields();
- float m_restitution = 0.1f;
- float m_static_friction = 0.6f;
- float m_dynamic_friction = 0.5f;
+ float m_restitution = 0.0f;
+ float m_static_friction = 0.0f;
+ float m_dynamic_friction = 0.0f;
+ float m_rolling_friction = 0.0f;
+ float m_spin_friction = 0.0f;
};
}
diff --git a/engine/src/toast/physics/physics_settings.cpp b/engine/src/toast/physics/physics_settings.cpp
index a2f1a6d66..8ee54c6e1 100644
--- a/engine/src/toast/physics/physics_settings.cpp
+++ b/engine/src/toast/physics/physics_settings.cpp
@@ -1,5 +1,7 @@
#include "physics_settings.hpp"
+#include "layers.hpp"
+
#include
#include
#include
@@ -18,6 +20,8 @@ auto tunables() -> const Tunables& {
}
void registerPhysicsSettings() {
+ registerLayerSettings();
+
const auto flt = [](std::string_view key, double value, Meta meta, float Tunables::* field) {
toast::settings::declareFloat(key, value, std::move(meta)).onChange([field](double v) {
g_tunables.*field = static_cast(v);
@@ -46,6 +50,30 @@ void registerPhysicsSettings() {
gravity_axis("physics.world.gravity_y", "Gravity Y", 0.0, 1);
gravity_axis("physics.world.gravity_z", "Gravity Z", -9.8, 2);
+ flt("physics.world.air_density",
+ 1.225f,
+ {.label = "Air density", .category = "World", .min = 0.0f, .step = 0.1f},
+ &Tunables::air_density);
+
+ const auto wind_axis = [](std::string_view key, std::string_view label, double value, int axis) {
+ toast::settings::declareFloat(
+ key, value, {.label = std::string {label}, .category = "World", .min = -1000.0, .max = 1000.0, .step = 0.1}
+ )
+ .onChange([axis](double v) { g_tunables.wind[axis] = static_cast(v); });
+ };
+ wind_axis("physics.world.wind_x", "Wind X", 0.0f, 0);
+ wind_axis("physics.world.wind_y", "Wind Y", 0.0f, 1);
+ wind_axis("physics.world.wind_z", "Wind Z", 0.0f, 2);
+
+ flt("physics.world.wind_shelter_distance",
+ 64.0f,
+ {.label = "Wind shelter distance",
+ .category = "World",
+ .description = "A body with solid geometry this far upwind of it, like the wall of a building, feels no wind. 0 disables.",
+ .min = 0.0f,
+ .step = 1.0f},
+ &Tunables::wind_shelter_distance);
+
uint_setting(
"physics.solver.iterations",
8,
@@ -247,6 +275,17 @@ void registerPhysicsSettings() {
.step = 1.0},
&Tunables::fragment_despawn_max_voxels
);
+ uint_setting(
+ "physics.fracture.large_debris_min_voxels",
+ 16,
+ {.label = "Large debris min voxels",
+ .category = "Fracture",
+ .description = "Fragments with at least this many voxels go on the Large debris layer instead of Small debris",
+ .min = 1.0,
+ .max = 4096.0,
+ .step = 1.0},
+ &Tunables::large_debris_min_voxels
+ );
flt("physics.fracture.despawn_settle_seconds",
3.0,
{.label = "Despawn settle time",
@@ -405,23 +444,6 @@ void registerPhysicsSettings() {
.step = 1.0},
&Tunables::sweep_refine_iterations
);
-
- toast::settings::declareBool(
- "physics.triggers.check_dynamic",
- true,
- {.label = "Check for Dynamic",
- .category = "Triggers",
- .description = "Triggers report dynamic bodies entering and leaving them"}
- )
- .onChange([](bool v) { g_tunables.trigger_check_dynamic = v; });
- toast::settings::declareBool(
- "physics.triggers.check_kinematic",
- true,
- {.label = "Check for Kinematic",
- .category = "Triggers",
- .description = "Triggers report kinematic bodies entering and leaving them"}
- )
- .onChange([](bool v) { g_tunables.trigger_check_kinematic = v; });
}
}
diff --git a/engine/src/toast/physics/physics_settings.hpp b/engine/src/toast/physics/physics_settings.hpp
index e61d8b631..3db044e0b 100644
--- a/engine/src/toast/physics/physics_settings.hpp
+++ b/engine/src/toast/physics/physics_settings.hpp
@@ -32,6 +32,7 @@ struct Tunables {
uint32_t fragment_pool_headroom = 4096;
uint32_t min_fragment_voxels = 4;
uint32_t fragment_despawn_max_voxels = 16;
+ uint32_t large_debris_min_voxels = 16;
float fragment_despawn_settle_seconds = 3.0f;
float force_sleep_slack = 4.0f;
float fragment_sleep_scale = 3.0f;
@@ -43,17 +44,14 @@ struct Tunables {
uint32_t min_shapes_per_query_job = 16;
uint32_t min_bodies_per_job = 64;
uint32_t min_wave_constraints_for_dispatch = 512;
- /// Deliberately very high right now to disable splitting until box_voxel_max_estimated_voxels gives a real number
uint32_t box_voxel_split_min_voxels = 500000;
- /// Same off-until-measured reasoning as box_voxel_split_min_voxels
uint32_t voxel_voxel_split_min_voxels = 250000;
uint32_t voxel_pair_split_regions = 8;
- /// Bisection steps a sweep refines by once it finds a blocking contact a clear sweep never pays this
uint32_t sweep_refine_iterations = 6;
uint32_t position_iterations = 3;
- /// Which body types a trigger reports, static bodies never count
- bool trigger_check_dynamic = true;
- bool trigger_check_kinematic = true;
+ float air_density = 1.225f;
+ glm::vec3 wind = {0.0f, 0.0f, 0.0f};
+ float wind_shelter_distance = 64.0f;
[[nodiscard]]
auto fixedDelta() const noexcept -> double {
diff --git a/engine/src/toast/physics/raycast.cpp b/engine/src/toast/physics/raycast.cpp
index 441a1752d..8a66a84b2 100644
--- a/engine/src/toast/physics/raycast.cpp
+++ b/engine/src/toast/physics/raycast.cpp
@@ -1,104 +1,91 @@
+#include "nodes/rigidbody.hpp"
#include "toast/physics/broad_phase.hpp"
#include "toast/physics/shape.hpp"
#include "toast/physics/simulator.hpp"
+#include "toast/physics/voxel_query.hpp"
+#include
#include
#include
#include
#include
#include
-#include
-#include
#include
+#include
+#include
namespace {
using namespace physics;
+constexpr float k_no_end = std::numeric_limits::infinity();
+
struct LocalHit {
float distance;
glm::vec3 normal;
+ bool inside = false; ///< if true, normal is the one against the ray
};
-/// @brief Raycast To Sphere
-auto raycastSphere(const SphereShape& obj, glm::vec3 pos, glm::vec3 dir) -> std::optional {
- ZoneScoped;
- // Solve the ray/sphere intersection in local space.
- float b = glm::dot(pos, dir);
- float c = dot(pos, pos) - (obj.radius * obj.radius);
- float h = (b * b) - c;
+[[nodiscard]]
+auto startsInside(glm::vec3 direction) -> LocalHit {
+ return LocalHit {.distance = 0.0f, .normal = -direction, .inside = true};
+}
- // Ray Misses the sphere
- if (h < 0.0f) {
+auto raycastSphere(float radius, glm::vec3 pos, glm::vec3 dir, float clip) -> std::optional {
+ // Solve the ray-sphere intersection in local space
+ const float c = glm::dot(pos, pos) - (radius * radius);
+ if (c < 0.0f) {
+ return startsInside(dir);
+ }
+
+ // With c not negative both roots have the same sign
+ // b is not negative when the sphere is behind the ray
+ const float b = glm::dot(pos, dir);
+ if (b >= 0.0f) {
return std::nullopt;
}
- h = std::sqrt(h);
- // The Ray has 2 intersections entry and exit
- // This check is needed if ray starts from inside a sphere
- float d1 = -b - h;
- float t = (d1 >= 0.0f) ? d1 : -b + h;
-
- // Both Raycast intersection are behind the ray
- if (t < 0.0f) {
+ const float h = (b * b) - c;
+ if (h < 0.0f) {
return std::nullopt;
}
- // calculate hit and normal
- glm::vec3 hit_pos = pos + dir * t;
- glm::vec3 normal = glm::normalize(hit_pos);
-
- return LocalHit {
- .distance = t,
- .normal = normal,
- };
+ const float t = -b - std::sqrt(h);
+ if (t > clip) {
+ return std::nullopt;
+ }
+ return LocalHit {.distance = t, .normal = glm::normalize(pos + (dir * t))};
}
-/// @brief Raycast to AABB
-auto raycastBox(const BoxShape& obj, glm::vec3 pos, glm::vec3 dir) -> std::optional {
- ZoneScoped;
- // Calculate Half Extents
- glm::vec3 half_extents = obj.size * 0.5f;
-
+auto raycastBox(glm::vec3 half_extents, glm::vec3 pos, glm::vec3 dir, float clip) -> std::optional {
// values for the entry and exit intersection of the ray (dir * t)
- float entry = -std::numeric_limits::infinity();
- float exit = std::numeric_limits::infinity();
+ float entry = -k_no_end;
+ float exit = k_no_end;
glm::vec3 entry_normal {};
- glm::vec3 exit_normal {};
- // check intersection for each axis of the AABB
+ // check intersection for each axis of the box
for (int axis = 0; axis < 3; ++axis) {
// If Raycast is Parallel
if (std::abs(dir[axis]) <= 1.0e-6f) {
// If Raycast is Outside the axis, It does not intersect
- if (pos[axis] < -half_extents[axis] || pos[axis] > half_extents[axis]) {
+ if (std::abs(pos[axis]) > half_extents[axis]) {
return std::nullopt;
}
// Otherwise check next axis
continue;
}
- // Near and Far represent the entry and exit plane of that axis
- float near = (-half_extents[axis] - pos[axis]) / dir[axis];
- float far = (half_extents[axis] - pos[axis]) / dir[axis];
- glm::vec3 near_normal {};
- glm::vec3 far_normal {};
- near_normal[axis] = -1.0f;
- far_normal[axis] = 1.0f;
-
- // Make sure entry plane is the entry :P
- if (near > far) {
- std::swap(near, far);
- std::swap(near_normal, far_normal);
- }
-
- // Check if Plane entry/exit are more correct
- if (near > entry) {
- entry = near;
- entry_normal = near_normal;
- }
- if (far < exit) {
- exit = far;
- exit_normal = far_normal;
+ // The entry and exit plane of that axis
+ const float inverse = 1.0f / dir[axis];
+ const float t0 = (-half_extents[axis] - pos[axis]) * inverse;
+ const float t1 = (half_extents[axis] - pos[axis]) * inverse;
+ const float t_enter = std::min(t0, t1);
+ const float t_leave = std::max(t0, t1);
+
+ if (t_enter > entry) {
+ entry = t_enter;
+ entry_normal = glm::vec3 {};
+ entry_normal[axis] = dir[axis] > 0.0f ? -1.0f : 1.0f;
}
+ exit = std::min(exit, t_leave);
// entry plane should always be closest
if (entry > exit) {
@@ -106,340 +93,341 @@ auto raycastBox(const BoxShape& obj, glm::vec3 pos, glm::vec3 dir) -> std::optio
}
}
- // Ray Intersection is behind the ray
- if (exit < 0.0f) {
+ // The box is behind the ray
+ if (exit <= 0.0f) {
return std::nullopt;
}
-
- // The Ray has 2 intersections entry and exit
- // This check is needed if ray starts from inside a sphere
- return entry >= 0.0f ? LocalHit {.distance = entry, .normal = entry_normal}
- : LocalHit {.distance = exit, .normal = exit_normal};
+ if (entry < 0.0f) {
+ return startsInside(dir);
+ }
+ if (entry > clip) {
+ return std::nullopt;
+ }
+ return LocalHit {.distance = entry, .normal = entry_normal};
}
-/// @brief Raycast to Sphere
-auto raycastCapsule(const CapsuleShape& obj, glm::vec3 pos, glm::vec3 dir) -> std::optional {
- ZoneScoped;
- float shaft_half_length = (obj.height * 0.5f) - obj.radius;
+auto raycastCapsule(float radius, float height, glm::vec3 pos, glm::vec3 dir, float clip) -> std::optional {
+ const float shaft_half_length = (height * 0.5f) - radius;
// A short capsule is a sphere
if (shaft_half_length <= 1.0e-6f) {
- return raycastSphere(SphereShape {.radius = obj.radius}, pos, dir);
+ return raycastSphere(radius, pos, dir, clip);
+ }
+
+ const glm::vec3 from_axis {pos.x, pos.y, pos.z - std::clamp(pos.z, -shaft_half_length, shaft_half_length)};
+ if (glm::dot(from_axis, from_axis) < radius * radius) {
+ return startsInside(dir);
}
std::optional closest;
- auto consider = [&closest](LocalHit candidate) {
- if (!closest || candidate.distance < closest->distance) {
- closest = candidate;
+ float limit = clip;
+ const auto consider = [&](float t, glm::vec3 normal) {
+ if (t >= 0.0f && t <= limit) {
+ limit = t;
+ closest = LocalHit {.distance = t, .normal = normal};
}
};
// Shaft Intersection
- float shaft_a = (dir.x * dir.x) + (dir.y * dir.y);
+ const float shaft_a = (dir.x * dir.x) + (dir.y * dir.y);
if (shaft_a > 1.0e-6f) {
- float shaft_b = (pos.x * dir.x) + (pos.y * dir.y);
- float shaft_c = (pos.x * pos.x) + (pos.y * pos.y) - (obj.radius * obj.radius);
- float discriminant = (shaft_b * shaft_b) - (shaft_a * shaft_c);
+ const float shaft_b = (pos.x * dir.x) + (pos.y * dir.y);
+ const float shaft_c = (pos.x * pos.x) + (pos.y * pos.y) - (radius * radius);
+ const float discriminant = (shaft_b * shaft_b) - (shaft_a * shaft_c);
if (discriminant >= 0.0f) {
- float root = std::sqrt(discriminant);
- for (float t : {(-shaft_b - root) / shaft_a, (-shaft_b + root) / shaft_a}) {
- float z = pos.z + (dir.z * t);
- if (t < 0.0f || z < -shaft_half_length || z > shaft_half_length) {
- continue;
+ const float root = std::sqrt(discriminant);
+ for (const float t : {(-shaft_b - root) / shaft_a, (-shaft_b + root) / shaft_a}) {
+ if (std::abs(pos.z + (dir.z * t)) <= shaft_half_length) {
+ consider(t, glm::normalize(glm::vec3 {pos.x + (dir.x * t), pos.y + (dir.y * t), 0.0f}));
}
- glm::vec3 hit_pos = pos + dir * t;
- consider(LocalHit {.distance = t, .normal = glm::normalize(glm::vec3 {hit_pos.x, hit_pos.y, 0.0f})});
}
}
}
- struct Cap {
- glm::vec3 center;
- float outward_z;
- };
-
- auto caps = {
- Cap {{0.0f, 0.0f, -shaft_half_length}, -1.0f},
- Cap { {0.0f, 0.0f, shaft_half_length}, 1.0f}
- };
-
// Sphere Cap Intersection
- for (const Cap& cap : caps) {
- glm::vec3 cap_pos = pos - cap.center;
- float b = glm::dot(cap_pos, dir);
- float c = glm::dot(cap_pos, cap_pos) - (obj.radius * obj.radius);
- float discriminant = (b * b) - c;
+ for (const float side : {-1.0f, 1.0f}) {
+ const glm::vec3 cap_pos = pos - glm::vec3 {0.0f, 0.0f, side * shaft_half_length};
+ const float b = glm::dot(cap_pos, dir);
+ const float c = glm::dot(cap_pos, cap_pos) - (radius * radius);
+ const float discriminant = (b * b) - c;
if (discriminant < 0.0f) {
continue;
}
- float root = std::sqrt(discriminant);
- for (float t : {-b - root, -b + root}) {
- if (t < 0.0f) {
- continue;
+ const float root = std::sqrt(discriminant);
+ for (const float t : {-b - root, -b + root}) {
+ const glm::vec3 hit_offset = cap_pos + (dir * t);
+ if (side * hit_offset.z >= 0.0f) {
+ consider(t, glm::normalize(hit_offset));
}
- glm::vec3 hit_offset = cap_pos + dir * t;
- if (cap.outward_z * hit_offset.z < 0.0f) {
- continue;
- }
- consider(LocalHit {.distance = t, .normal = glm::normalize(hit_offset)});
}
}
return closest;
}
-/// @brief Raycast to Voxel
-auto raycastVoxel(const VoxelShape& shape, glm::vec3 pos, glm::vec3 dir, float max_distance) -> std::optional {
- ZoneScoped;
- const VoxelShapeData* shape_data = Simulator::tryGetVoxelDataConst(shape.data);
- if (shape_data == nullptr || shape_data->volume == nullptr) {
- return std::nullopt;
- }
+}
- auto& volume = *shape_data->volume;
- const glm::ivec3 voxel_dims = glm::ivec3(volume.voxelDims());
- if (glm::any(glm::lessThanEqual(voxel_dims, glm::ivec3(0)))) {
- return std::nullopt;
- }
+namespace physics {
- // Volume Bounds Intersection
- const glm::vec3 volume_max = glm::vec3(voxel_dims) * voxel::k_voxel_size;
- float entry = -std::numeric_limits::infinity();
- float exit = std::numeric_limits::infinity();
- glm::vec3 entry_normal {};
+template
+auto Simulator::castRay(const RayQuery& query, OnContact&& on_contact) const -> uint32_t {
+ const glm::vec3 inverse_direction = 1.0f / query.direction;
+ float clip = query.max_distance;
+ uint32_t tested = 0;
- // AABB Intersection To get the entry and exit
- for (int axis = 0; axis < 3; ++axis) {
- if (std::abs(dir[axis]) <= 1.0e-6f) {
- if (pos[axis] < 0.0f || pos[axis] > volume_max[axis]) {
- return std::nullopt;
- }
- continue;
+ m_broad_phase.raycast(query.origin, inverse_direction, clip, [&](ShapeID shape_id, float) -> float {
+ const Shape* shape = tryGetShape(shape_id);
+ if (shape == nullptr || not shape->enabled || shape->owner == query.ignored) {
+ return clip;
}
- float near = -pos[axis] / dir[axis];
- float far = (volume_max[axis] - pos[axis]) / dir[axis];
- glm::vec3 near_normal {};
- near_normal[axis] = -1.0f;
- if (near > far) {
- std::swap(near, far);
- near_normal[axis] = 1.0f;
+ if (query.voxels_only && shape->type != ShapeType::voxel) {
+ return clip;
}
- if (near > entry) {
- entry = near;
- entry_normal = near_normal;
+ const Body* body = tryGetBody(shape->owner);
+ if (body == nullptr || not body->enabled || (body->layers.included & query.layers) == 0) {
+ return clip;
}
- exit = std::min(exit, far);
- if (entry > exit) {
- return std::nullopt;
- }
- }
- if (exit < 0.0f) {
- return std::nullopt;
- }
- if (max_distance >= 0.0f) {
- exit = std::min(exit, max_distance);
- }
- /// End of AABB Intersection
-
- // DDA Setup
- float t = std::max(entry, 0.0f);
- const glm::vec3 initial_point = pos + dir * t;
- glm::ivec3 voxel_coord = glm::ivec3(glm::floor(initial_point / voxel::k_voxel_size));
+ ++tested;
+
+ // The ray in the space of the shape
+ const glm::vec3 center = body->position + (body->rotation * shape->getLocalCenter());
+ const glm::quat rotation = glm::normalize(body->rotation * shape->getLocalRotation());
+ const glm::quat inverse_rotation = glm::conjugate(rotation);
+ const glm::vec3 local_origin = inverse_rotation * (query.origin - center);
+ const glm::vec3 local_direction = inverse_rotation * query.direction;
+
+ const auto report = [&](float distance, glm::vec3 local_normal, bool inside, uint8_t palette_index) {
+ const glm::vec3 normal = inside ? -query.direction : glm::normalize(rotation * local_normal);
+ const RayContact contact {
+ .shape = shape_id,
+ .body = shape->owner,
+ .distance = distance,
+ .normal = normal,
+ .palette_index = palette_index,
+ };
+ clip = std::min(clip, on_contact(contact));
+ };
+ const auto report_local = [&](const std::optional& hit) {
+ if (hit) {
+ report(hit->distance, hit->normal, hit->inside, 0);
+ }
+ };
- // If starting exactly on an edge, select the voxel just inside the volume
- for (int axis = 0; axis < 3; ++axis) {
- if (voxel_coord[axis] == voxel_dims[axis] && dir[axis] < 0.0f) {
- voxel_coord[axis] = voxel_dims[axis] - 1;
- } else if (voxel_coord[axis] == -1 && dir[axis] > 0.0f) {
- voxel_coord[axis] = 0;
+ switch (shape->type) {
+ case ShapeType::sphere: report_local(raycastSphere(shape->sphere.radius, local_origin, local_direction, clip)); break;
+ case ShapeType::box: report_local(raycastBox(shape->box.size * 0.5f, local_origin, local_direction, clip)); break;
+ case ShapeType::capsule:
+ report_local(raycastCapsule(shape->capsule.radius, shape->capsule.height, local_origin, local_direction, clip));
+ break;
+ case ShapeType::voxel: {
+ const VoxelShapeData* data = tryGetVoxelDataConst(shape->voxel.data);
+ if (data == nullptr || data->volume == nullptr) {
+ break;
+ }
+ const VoxelQueryContext context {*data->volume, data->surface, data->palette, data->materials};
+ queryVoxelRay(
+ context,
+ shape->voxel.local_bounds,
+ local_origin,
+ local_direction,
+ clip,
+ query.hit_passable,
+ [&](const VoxelRayEntry& entry) {
+ report(entry.distance, entry.normal, false, entry.palette_index);
+ return clip;
+ }
+ );
+ break;
+ }
}
- }
+ return clip;
+ });
- auto outside_volume = [&voxel_dims](glm::ivec3 coord) {
- return glm::any(glm::lessThan(coord, glm::ivec3(0))) || glm::any(glm::greaterThanEqual(coord, voxel_dims));
- };
- if (outside_volume(voxel_coord)) {
- return std::nullopt;
- }
+ return tested;
+}
- // Next Boundary Distance for each axis
- const glm::ivec3 step = glm::ivec3(glm::sign(dir));
- glm::vec3 t_delta {std::numeric_limits::infinity()};
- glm::vec3 t_next {std::numeric_limits::infinity()};
- for (int axis = 0; axis < 3; ++axis) {
- if (step[axis] == 0) {
- continue;
- }
- float boundary = (step[axis] > 0 ? static_cast(voxel_coord[axis] + 1) : static_cast(voxel_coord[axis])) *
- voxel::k_voxel_size;
- t_next[axis] = t + ((boundary - initial_point[axis]) / dir[axis]);
- t_delta[axis] = voxel::k_voxel_size / std::abs(dir[axis]);
+auto Simulator::windBlocked(const glm::vec3& position, const glm::vec3& toward, float distance, BodyID ignored) const -> bool {
+ const std::optional query = makeRayQuery(position, toward, distance, ignored, k_all_layers);
+ if (not query) {
+ return false;
}
- // If starting inside the volume the normal faces the opposite ray direction
- glm::vec3 normal = entry >= 0.0f ? entry_normal : -dir;
- while (t <= exit) {
- // Return the first Solid Voxel hit
- if (volume.materialAt(voxel_coord) != voxel::k_empty_palette_index) {
- return LocalHit {.distance = t, .normal = glm::normalize(normal)};
- }
-
- // Move to next Voxel Boundary
- const float next_t = std::min({t_next.x, t_next.y, t_next.z});
- if (!std::isfinite(next_t) || next_t > exit) {
- break;
+ bool blocked = false;
+ castRay(*query, [&](const RayContact& contact) {
+ const Body* body = tryGetBody(contact.body);
+ if (body == nullptr || body->type == BodyType::dynamic_body) {
+ // Loose bodies don't shelter anything
+ return query->max_distance;
}
+ blocked = true;
+ return 0.0f; // Nothing close
+ });
+ return blocked;
+}
- normal = {};
- for (int axis = 0; axis < 3; ++axis) {
- // Move across every axis that reaches this boundary at the same time
- if (t_next[axis] > next_t + 1.0e-6f) {
- continue;
- }
- voxel_coord[axis] += step[axis];
- t_next[axis] += t_delta[axis];
- normal[axis] = -static_cast(step[axis]);
- }
- if (outside_volume(voxel_coord)) {
- break;
- }
- t = next_t;
+auto Simulator::makeRayQuery(glm::vec3 origin, glm::vec3 direction, float max_distance, BodyID ignored, uint32_t layers)
+ -> std::optional {
+ const bool finite_origin = std::isfinite(origin.x) && std::isfinite(origin.y) && std::isfinite(origin.z);
+ const float length = glm::length(direction);
+ if (not finite_origin || std::isnan(max_distance) || not(std::isfinite(length) && length > 1.0e-6f)) {
+ return std::nullopt;
}
- return std::nullopt;
+
+ return RayQuery {
+ .origin = origin,
+ .direction = direction / length,
+ .max_distance = max_distance < 0.0f ? k_no_end : max_distance,
+ .ignored = ignored,
+ .layers = layers,
+ };
}
+auto Simulator::makeRayHit(const RayQuery& query, const RayContact& contact, toast::Box node) -> RayHit {
+ return RayHit {
+ .node = std::move(node),
+ .position = query.origin + (query.direction * contact.distance),
+ .normal = contact.normal,
+ .distance = contact.distance,
+ .body = contact.body,
+ .shape = contact.shape,
+ };
}
-namespace physics {
+auto raycast(glm::vec3 origin, glm::vec3 direction, float max_distance, BodyID ignored, const std::vector& layers)
+ -> std::optional {
+ return Simulator::raycast(origin, direction, max_distance, ignored, layerMask(layers));
+}
-auto raycast(glm::vec3 pos, glm::vec3 dir, float max_distance, int max_targets) -> std::vector {
- return Simulator::raycast(pos, dir, max_distance, max_targets);
+auto raymulticast(
+ glm::vec3 origin, glm::vec3 direction, float max_distance, int max_hits, BodyID ignored, const std::vector& layers
+) -> std::vector {
+ return Simulator::raymulticast(origin, direction, max_distance, max_hits, ignored, layerMask(layers));
}
-auto Simulator::raycast(glm::vec3 pos, glm::vec3 dir, float max_distance, int max_targets) -> std::vector {
- ZoneScoped;
- if (instance == nullptr) {
- return {};
- }
- if (!std::isfinite(max_distance) || (max_distance < 0.0f && max_distance != -1.0f) || max_targets < -1 || max_targets == 0) {
- return {};
+auto linecast(glm::vec3 from, glm::vec3 to, int max_hits, BodyID ignored, const std::vector& layers) -> std::vector {
+ return Simulator::linecast(from, to, max_hits, ignored, layerMask(layers));
+}
+
+auto Simulator::raycast(glm::vec3 origin, glm::vec3 direction, float max_distance, BodyID ignored, uint32_t layers)
+ -> std::optional {
+ ZoneScopedN("physics::Raycast");
+ const std::optional query = makeRayQuery(origin, direction, max_distance, ignored, layers);
+ if (instance == nullptr || not query) {
+ return std::nullopt;
}
- // normalize and get inverse of direction
- const float length = glm::length(dir);
- if (!(std::isfinite(length) && length > 1.0e-6f)) {
- return {};
+ // Only nearest contact matters
+ std::optional nearest;
+ const uint32_t tested = instance->castRay(*query, [&](const RayContact& contact) {
+ if (not nearest || contact.distance < nearest->distance) {
+ nearest = contact;
+ }
+ return nearest->distance;
+ });
+ ZoneValue(static_cast(tested));
+
+ if (not nearest) {
+ return std::nullopt;
}
- dir /= length;
- glm::vec3 inv_dir = 1.0f / dir;
+ return makeRayHit(*query, *nearest, colliderFor(nearest->body, nearest->shape));
+}
+
+auto Simulator::linecast(glm::vec3 from, glm::vec3 to, int max_hits, BodyID ignored, uint32_t layers) -> std::vector {
+ ZoneScopedN("physics::Linecast");
+ // A segment is a ray that ends at the second point, a degenerate or non finite one is not a ray and finds nothing
+ const glm::vec3 delta = to - from;
+ return raymulticast(from, delta, glm::length(delta), max_hits, ignored, layers);
+}
- // Gather possible ray collisions from the AABB tree.
- std::vector candidates = instance->m_broad_phase.queryRay(pos, inv_dir, max_distance);
- if (candidates.empty()) {
+auto Simulator::raymulticast(
+ glm::vec3 origin, glm::vec3 direction, float max_distance, int max_hits, BodyID ignored, uint32_t layers
+) -> std::vector {
+ ZoneScopedN("physics::RayMulticast");
+ const std::optional query = makeRayQuery(origin, direction, max_distance, ignored, layers);
+ if (instance == nullptr || not query || max_hits == 0) {
return {};
}
- /// Iterate Through Potential Intersections ///
- std::vector results;
- if (max_targets <= 0) {
- results.reserve(candidates.size());
+ const auto by_distance = [](const RayContact& lhs, const RayContact& rhs) { return lhs.distance < rhs.distance; };
+ const bool limited = max_hits > 0;
+ const auto limit = static_cast(max_hits);
+ std::vector contacts;
+ if (limited) {
+ contacts.reserve(limit + 1);
}
- const bool is_target_limited = max_targets >= 0;
- auto nearer = [](const RayHit& lhs, const RayHit& rhs) { return lhs.distance < rhs.distance; };
- std::priority_queue, decltype(nearer)> nearest_hits {nearer};
+ const uint32_t tested = instance->castRay(*query, [&](const RayContact& contact) {
+ contacts.push_back(contact);
+ if (not limited) {
+ return query->max_distance;
+ }
- for (ShapeID target : candidates) {
- Shape* shape = instance->tryGetShape(target);
- if (shape == nullptr || not shape->enabled) {
- continue;
+ std::ranges::push_heap(contacts, by_distance);
+ if (contacts.size() > limit) {
+ std::ranges::pop_heap(contacts, by_distance);
+ contacts.pop_back();
}
- Body* body = instance->tryGetBody(shape->owner);
- if (body == nullptr) {
- continue;
+ return contacts.size() == limit ? contacts.front().distance : query->max_distance;
+ });
+ ZoneValue(static_cast(tested));
+
+ std::ranges::sort(contacts, {}, &RayContact::distance);
+
+ std::vector>> nodes;
+ std::vector hits;
+ hits.reserve(contacts.size());
+ for (const RayContact& contact : contacts) {
+ auto known = std::ranges::find(nodes, contact.shape, &std::pair>::first);
+ if (known == nodes.end()) {
+ nodes.emplace_back(contact.shape, colliderFor(contact.body, contact.shape));
+ known = nodes.end() - 1;
}
+ hits.push_back(makeRayHit(*query, contact, known->second));
+ }
+ return hits;
+}
- // Skip if ray doesnt hit bounding box
- auto bounds_hit = worldShapeBounds(*body, *shape).intersectRay(pos, inv_dir);
- if (!bounds_hit || bounds_hit->t_max < 0.0f || (max_distance >= 0.0f && bounds_hit->t_min > max_distance) ||
- (is_target_limited && nearest_hits.size() == static_cast(max_targets) &&
- bounds_hit->t_min > nearest_hits.top().distance)) {
- continue;
+auto Simulator::bodyFor(const toast::Node& node) -> BodyID {
+ for (toast::Box current = node.box(); current.exists(); current = current->parent()) {
+ if (const toast::Box rigidbody = current.as(); rigidbody.exists()) {
+ return rigidbody->bodyID();
}
-
- // Transform Ray to local space of the target
- glm::quat local_rotation = shape->getLocalRotation();
- glm::quat inv_body_rotation = glm::conjugate(body->rotation);
- glm::quat inv_shape_rotation = glm::conjugate(local_rotation);
- glm::quat combined_inv_rotation = inv_shape_rotation * inv_body_rotation;
-
- // Local Ray
- glm::vec3 local_pos = inv_shape_rotation * (inv_body_rotation * (pos - body->position) - shape->getLocalCenter());
- glm::vec3 local_dir = combined_inv_rotation * dir;
-
- // Calculate Raycast
- std::optional hit;
- switch (shape->type) {
- case ShapeType::sphere: hit = raycastSphere(shape->sphere, local_pos, local_dir); break;
- case ShapeType::box: hit = raycastBox(shape->box, local_pos, local_dir); break;
- case ShapeType::capsule: hit = raycastCapsule(shape->capsule, local_pos, local_dir); break;
- case ShapeType::voxel: hit = raycastVoxel(shape->voxel, local_pos, local_dir, max_distance); break;
+ if (const toast::Box voxel = current.as(); voxel.exists()) {
+ return voxel->bodyID();
}
+ }
+ return {};
+}
- if (hit && (max_distance < 0.0f || hit->distance <= max_distance)) {
- // Bring the local hit back to world space.
- glm::quat combined_rotation = body->rotation * local_rotation;
- glm::vec3 world_normal = glm::normalize(combined_rotation * hit->normal);
- glm::vec3 hit_pos = pos + dir * hit->distance;
- RayHit ray_hit {
- .node = colliderFor(shape->owner, target),
- .position = hit_pos,
- .normal = world_normal,
- .distance = hit->distance,
- };
-
- if (is_target_limited) {
- if (nearest_hits.size() < static_cast(max_targets)) {
- nearest_hits.push(std::move(ray_hit));
- } else if (ray_hit.distance < nearest_hits.top().distance) {
- nearest_hits.pop();
- nearest_hits.push(std::move(ray_hit));
- } else {
- continue;
- }
- } else {
- results.emplace_back(std::move(ray_hit));
- }
+auto Simulator::marchVoxelRay(const glm::vec3& origin, const glm::vec3& dir, float max_distance) -> std::optional {
+ const RayQuery query {
+ .origin = origin, .direction = dir, .max_distance = max_distance, .voxels_only = true, .hit_passable = true
+ };
- debug::drawLine(pos, hit_pos, {0, 0, 1, 1});
-
- results.emplace_back(
- RayHit {
- .node = colliderFor(shape->owner, target),
- .position = hit_pos,
- .normal = world_normal,
- .distance = hit->distance,
- .body = shape->owner,
- }
- );
+ std::optional nearest;
+ castRay(query, [&](const RayContact& contact) {
+ if (not nearest || contact.distance < nearest->distance) {
+ nearest = contact;
}
+ return nearest->distance;
+ });
+ if (not nearest) {
+ return std::nullopt;
}
- if (results.empty()) {
- debug::drawLine(pos, pos * dir * max_distance, {1, 0, 0, 1});
- }
-
- if (is_target_limited) {
- results.reserve(nearest_hits.size());
- while (not nearest_hits.empty()) {
- results.emplace_back(nearest_hits.top());
- nearest_hits.pop();
- }
+ const Shape* shape = tryGetShape(nearest->shape);
+ const VoxelShapeData* data = shape != nullptr ? tryGetVoxelDataConst(shape->voxel.data) : nullptr;
+ if (data == nullptr) {
+ return std::nullopt;
}
- // Sort all returned hits by distance. With a target limit, this sorts at most max_targets entries.
- std::ranges::sort(results, {}, &RayHit::distance);
- return results;
+ const uint32_t material_index = voxel::resolveMaterialIndex(data->palette, data->materials, nearest->palette_index);
+ return VoxelRayHit {
+ .shape = nearest->shape,
+ .distance = nearest->distance,
+ .shatter_radius = data->materials.materials[material_index].shatter_radius,
+ };
}
+
}
diff --git a/engine/src/toast/physics/raycast.hpp b/engine/src/toast/physics/raycast.hpp
index 5eef69da6..434f9d1fc 100644
--- a/engine/src/toast/physics/raycast.hpp
+++ b/engine/src/toast/physics/raycast.hpp
@@ -10,9 +10,12 @@
#pragma once
#include "body.hpp"
+#include "shape.hpp"
#include "toast/world/box.hpp"
#include
+#include
+#include
namespace physics {
@@ -22,9 +25,33 @@ struct RayHit {
glm::vec3 normal;
float distance;
BodyID body;
+ ShapeID shape;
};
-TOAST_API auto raycast(glm::vec3 pos, glm::vec3 dir, float max_distance = -1.0f, int max_targets = 3) -> std::vector;
+/**
+ * The nearest thing a ray hits
+ * @param ignored Shapes of this body are not hit
+ * @param layers Indices of the layers a body must include one of to be hit, every layer when empty
+ */
+TOAST_API auto raycast(
+ glm::vec3 origin, glm::vec3 direction, float max_distance = -1.0f, BodyID ignored = {}, const std::vector& layers = {}
+) -> std::optional;
+
+/**
+ * Up to @p max_hits things a ray hits
+ *
+ * Adds a hit per time a ray enters a body
+ */
+TOAST_API auto raymulticast(
+ glm::vec3 origin, glm::vec3 direction, float max_distance = -1.0f, int max_hits = -1, BodyID ignored = {},
+ const std::vector& layers = {}
+) -> std::vector;
+
+/**
+ * Like @c raymulticast from @p from until @p to
+ */
+TOAST_API auto linecast(glm::vec3 from, glm::vec3 to, int max_hits = -1, BodyID ignored = {}, const std::vector& layers = {})
+ -> std::vector;
struct SphereHit {
toast::Box node;
diff --git a/engine/src/toast/physics/shape.hpp b/engine/src/toast/physics/shape.hpp
index 91d9f4f06..31992596c 100644
--- a/engine/src/toast/physics/shape.hpp
+++ b/engine/src/toast/physics/shape.hpp
@@ -9,6 +9,7 @@
#include "aabb.hpp"
#include "body.hpp"
+#include "material_defaults.hpp"
#include
#include
@@ -18,9 +19,11 @@
namespace physics {
struct PhysicsMaterial {
- float restitution = 0.0f;
- float static_friction = 0.6f;
- float dynamic_friction = 0.4f;
+ float restitution = material_defaults::k_restitution;
+ float static_friction = material_defaults::k_static_friction;
+ float dynamic_friction = material_defaults::k_dynamic_friction;
+ float rolling_friction = material_defaults::k_rolling_friction;
+ float spin_friction = material_defaults::k_spin_friction;
};
struct ShapeID {
diff --git a/engine/src/toast/physics/shape_query.cpp b/engine/src/toast/physics/shape_query.cpp
index 52902c647..89e82ab3d 100644
--- a/engine/src/toast/physics/shape_query.cpp
+++ b/engine/src/toast/physics/shape_query.cpp
@@ -2,9 +2,14 @@
#include "manifold.hpp"
#include "physics_settings.hpp"
#include "simulator.hpp"
+#include "voxel_query.hpp"
#include
+#include
#include
+#include
+#include
+#include
#include
namespace physics {
@@ -30,13 +35,118 @@ auto probeBody(const glm::vec3& position, const glm::quat& rotation) -> Body {
return Body {.type = BodyType::kinematic_body, .position = position, .rotation = rotation};
}
+auto boundsVolume(const AABB& bounds) -> float {
+ const glm::vec3 size = bounds.max - bounds.min;
+ return size.x * size.y * size.z;
+}
+
+struct SweepVoxel {
+ glm::vec3 min;
+ uint8_t normal_index;
+};
+
+struct SweepTarget {
+ ShapeID shape;
+ BodyID body;
+ const VoxelShapeData* voxel_data = nullptr;
+ glm::mat3 basis {1.0f};
+ glm::vec3 origin {};
+ glm::vec3 local_a {};
+ glm::vec3 local_b {};
+ glm::vec3 local_delta {};
+ bool gathered = false;
+ size_t first_voxel = 0;
+ size_t voxel_count = 0;
+};
+
+struct SweepScratch {
+ std::vector targets;
+ std::vector voxels;
+ std::vector contacts;
+};
+
+auto sweepScratch() -> SweepScratch& {
+ thread_local SweepScratch scratch; // reuse instead of reallocate new vector
+ return scratch;
+}
+
+auto probeManifolds() -> std::vector& {
+ thread_local std::vector manifolds;
+ return manifolds;
+}
+
+void voxelSweepContacts(
+ const SweepTarget& target, std::span gathered, float radius, float t, std::vector& contacts
+) {
+ const glm::vec3 shift = target.local_delta * t;
+ const glm::vec3 local_a = target.local_a + shift;
+ const glm::vec3 local_b = target.local_b + shift;
+ const AABB local_bounds {
+ .min = glm::min(local_a, local_b) - glm::vec3(radius),
+ .max = glm::max(local_a, local_b) + glm::vec3(radius),
+ };
+
+ std::array, voxel::k_normal_direction_count> best_per_normal;
+ const auto consider = [&](const glm::vec3& voxel_min, uint8_t normal_index) {
+ std::optional<_detail::CapsuleVoxelContact> contact =
+ _detail::capsuleVoxelContact(local_a, local_b, radius, voxel_min, normal_index);
+ if (not contact.has_value()) {
+ return;
+ }
+ std::optional<_detail::CapsuleVoxelContact>& slot = best_per_normal[normal_index];
+ if (not slot.has_value() || slot->penetration < contact->penetration) {
+ slot = contact;
+ }
+ };
+
+ if (target.gathered) {
+ const glm::vec3 voxel_extent {voxel::k_voxel_size};
+ for (const SweepVoxel& voxel : gathered.subspan(target.first_voxel, target.voxel_count)) {
+ // the same voxels the walk over local_bounds would visit
+ if (glm::any(glm::lessThanEqual(voxel.min + voxel_extent, local_bounds.min)) ||
+ glm::any(glm::greaterThanEqual(voxel.min, local_bounds.max))) {
+ continue;
+ }
+ consider(voxel.min, voxel.normal_index);
+ }
+ } else {
+ const VoxelShapeData& data = *target.voxel_data;
+ const VoxelQueryContext context {
+ .volume = *data.volume, .surface = data.surface, .palette = data.palette, .materials = data.materials
+ };
+ queryVoxelSurface(context, local_bounds, [&](const VoxelCandidate& candidate) {
+ if (candidate.normal_index < voxel::k_normal_direction_count) {
+ consider(candidate.min, candidate.normal_index);
+ }
+ });
+ }
+
+ for (const std::optional<_detail::CapsuleVoxelContact>& best : best_per_normal) {
+ if (not best.has_value()) {
+ continue;
+ }
+ const glm::vec3 capsule_surface = target.origin + target.basis * best->local_surface;
+ const glm::vec3 voxel_surface = target.origin + target.basis * best->local_point;
+ contacts.push_back(
+ QueryContact {
+ .body = target.body,
+ .shape = target.shape,
+ .normal = -(target.basis * best->local_normal),
+ .point = (capsule_surface + voxel_surface) * 0.5f,
+ .penetration = best->penetration,
+ }
+ );
+ }
+}
+
}
auto Simulator::current() -> Simulator* {
return instance;
}
-auto Simulator::queryCandidates(BodyID ignored, const AABB& bounds) const -> std::vector {
+auto Simulator::queryCandidates(BodyID ignored, const AABB& bounds, std::optional layers) const
+ -> std::vector {
std::vector result;
// the tree is refit once per step so bodies may have drifted up to one margin since
for (const ShapeID shape_id : m_broad_phase.queryBounds(bounds.expanded(tunables().broadphase_fat_margin))) {
@@ -48,15 +158,23 @@ auto Simulator::queryCandidates(BodyID ignored, const AABB& bounds) const -> std
if (body == nullptr || not body->enabled) {
continue;
}
+ if (layers.has_value() && not layersCollide(*layers, body->layers)) {
+ continue;
+ }
result.push_back(shape_id);
}
return result;
}
+auto Simulator::layersOf(BodyID body) const -> std::optional {
+ const Body* stored = tryGetBody(body);
+ return stored != nullptr ? std::optional {stored->layers} : std::nullopt;
+}
+
void Simulator::collideCapsuleProbe(
const Body& probe_body, const Shape& probe_shape, std::span candidates, std::vector& contacts
) const {
- std::vector manifolds;
+ std::vector& manifolds = probeManifolds();
for (const ShapeID shape_id : candidates) {
const Shape* shape = tryGetShape(shape_id);
const Body* body = shape != nullptr ? tryGetBody(shape->owner) : nullptr;
@@ -117,34 +235,23 @@ void Simulator::collideCapsuleProbe(
}
void Simulator::overlapShape(
- BodyID ignored, const Shape& probe_shape, const glm::vec3& position, const glm::quat& rotation, std::vector& bodies
+ BodyID ignored, LayerFilter layers, const Shape& probe_shape, const glm::vec3& position, const glm::quat& rotation,
+ std::vector& bodies
) const {
ZoneScopedN("physics::OverlapShape");
bodies.clear();
- const Tunables& settings = tunables();
const Body probe_body = probeBody(position, rotation);
const CollisionElement probe {probe_shape, probe_body};
std::vector manifolds;
- for (const ShapeID shape_id : queryCandidates(ignored, worldShapeBounds(probe_body, probe_shape))) {
+ for (const ShapeID shape_id : queryCandidates(ignored, worldShapeBounds(probe_body, probe_shape), layers)) {
const Shape* shape = tryGetShape(shape_id);
const Body* body = shape != nullptr ? tryGetBody(shape->owner) : nullptr;
if (body == nullptr || std::ranges::find(bodies, shape->owner) != bodies.end()) {
continue;
}
- bool checked = false;
- switch (body->type) {
- case BodyType::dynamic_body: checked = settings.trigger_check_dynamic; break;
- case BodyType::kinematic_body: checked = settings.trigger_check_kinematic; break;
- default: checked = false; break;
- }
-
- if (not checked) {
- continue;
- }
-
const BroadPhasePair pair {
.a = {.body = probe_shape.owner},
.b = {.body = shape->owner, .shape = shape_id}
@@ -274,7 +381,7 @@ auto Simulator::overlapCapsule(
contacts.clear();
const Shape probe_shape = capsuleProbe(ignored, capsule);
const Body probe_body = probeBody(position, rotation);
- const std::vector candidates = queryCandidates(ignored, worldShapeBounds(probe_body, probe_shape));
+ const std::vector candidates = queryCandidates(ignored, worldShapeBounds(probe_body, probe_shape), layersOf(ignored));
collideCapsuleProbe(probe_body, probe_shape, candidates, contacts);
std::erase_if(contacts, [min_penetration](const QueryContact& contact) { return contact.penetration <= min_penetration; });
return not contacts.empty();
@@ -358,7 +465,8 @@ auto Simulator::overlapAABB(const AABB& bounds) const -> bool {
}
auto Simulator::sweepCapsule(
- BodyID ignored, const CapsuleShape& capsule, const glm::quat& rotation, const glm::vec3& from, const glm::vec3& to, float skin
+ BodyID ignored, const CapsuleShape& capsule, const glm::quat& rotation, const glm::vec3& from, const glm::vec3& to,
+ float skin, bool refine
) const -> SweepHit {
ZoneScopedN("physics::SweepCapsule");
++m_profile.character_sweep_calls;
@@ -380,57 +488,176 @@ auto Simulator::sweepCapsule(
const AABB start_bounds = worldShapeBounds(probe_body, probe_shape);
const AABB end_bounds = worldShapeBounds(probeBody(to, rotation), probe_shape);
- const std::vector candidates = queryCandidates(ignored, combine(start_bounds, end_bounds));
+ const AABB swept_bounds = combine(start_bounds, end_bounds);
+ const std::vector candidates = queryCandidates(ignored, swept_bounds, layersOf(ignored));
if (candidates.empty()) {
return result;
}
- // resting on or sliding off a surface must never block so only contacts the move digs into count
- std::vector contacts;
- const auto blocking_contact = [&](float t) -> std::optional {
+ // every sample used to walk the voxel surface again
+ // walk it once over the whole sweep unless the sweep is too long
+ constexpr float k_max_gather_volume_ratio = 4.0f;
+ const bool gather = boundsVolume(swept_bounds) <= k_max_gather_volume_ratio * boundsVolume(start_bounds);
+ const _detail::WorldCapsule start_capsule = _detail::worldCapsule(probe_body, inflated);
+ SweepScratch& scratch = sweepScratch();
+ scratch.targets.clear();
+ scratch.voxels.clear();
+ {
+ ZoneNamedN(gather_zone, "physics::SweepGather", true);
+ for (const ShapeID shape_id : candidates) {
+ const Shape* shape = tryGetShape(shape_id);
+ const Body* body = shape != nullptr ? tryGetBody(shape->owner) : nullptr;
+ if (body == nullptr) {
+ continue;
+ }
+
+ SweepTarget target {.shape = shape_id, .body = shape->owner};
+ if (shape->type == ShapeType::voxel) {
+ const VoxelShapeData* data = tryGetVoxelDataConst(shape->voxel.data);
+ if (data == nullptr || data->volume == nullptr) {
+ continue;
+ }
+
+ // same frame collideCapsuleVoxel builds
+ const glm::quat voxel_rotation = glm::normalize(body->rotation * shape->voxel.local_rotation);
+ target.voxel_data = data;
+ target.origin = body->position + body->rotation * shape->voxel.local_center;
+ target.basis = glm::mat3_cast(voxel_rotation);
+ const glm::mat3 basis_inv = glm::transpose(target.basis);
+ target.local_a = basis_inv * (start_capsule.point_a - target.origin);
+ target.local_b = basis_inv * (start_capsule.point_b - target.origin);
+ target.local_delta = basis_inv * delta;
+
+ if (gather) {
+ const glm::vec3 end_a = target.local_a + target.local_delta;
+ const glm::vec3 end_b = target.local_b + target.local_delta;
+ const AABB local_swept {
+ .min = glm::min(glm::min(target.local_a, target.local_b), glm::min(end_a, end_b)) - glm::vec3(start_capsule.radius),
+ .max = glm::max(glm::max(target.local_a, target.local_b), glm::max(end_a, end_b)) + glm::vec3(start_capsule.radius),
+ };
+ const VoxelQueryContext context {
+ .volume = *data->volume, .surface = data->surface, .palette = data->palette, .materials = data->materials
+ };
+ target.gathered = true;
+ target.first_voxel = scratch.voxels.size();
+ queryVoxelSurface(context, local_swept, [&](const VoxelCandidate& candidate) {
+ if (candidate.normal_index < voxel::k_normal_direction_count) {
+ scratch.voxels.push_back(SweepVoxel {.min = candidate.min, .normal_index = candidate.normal_index});
+ }
+ });
+ target.voxel_count = scratch.voxels.size() - target.first_voxel;
+ }
+ }
+ scratch.targets.push_back(target);
+ }
+ ZoneValueV(gather_zone, scratch.voxels.size()); // number of surface voxel the sweep tests
+ }
+
+ const auto contacts_at = [&](float t) -> std::span {
+ scratch.contacts.clear();
probe_body.position = from + delta * t;
- contacts.clear();
- collideCapsuleProbe(probe_body, probe_shape, candidates, contacts);
+ for (const SweepTarget& target : scratch.targets) {
+ if (target.voxel_data != nullptr) {
+ voxelSweepContacts(target, scratch.voxels, start_capsule.radius, t, scratch.contacts);
+ } else {
+ collideCapsuleProbe(probe_body, probe_shape, std::span {&target.shape, 1}, scratch.contacts);
+ }
+ }
+ return scratch.contacts;
+ };
+
+ const float blocking_depth = skin * 0.5f;
+ const float target_depth = skin * 0.25f;
+ const float accept_depth = skin * 0.125f;
- std::optional deepest;
- for (const QueryContact& contact : contacts) {
- if (contact.penetration <= skin * 0.5f || glm::dot(direction, contact.normal) >= -k_approach_epsilon) {
+ struct Sample {
+ float t = 0.0f;
+ std::optional blocking; // deepest contact
+ std::optional touching; // closest to blocking
+ float newton = std::numeric_limits::quiet_NaN(); // guess for where the contacts reach target_depth
+ };
+
+ const auto sample = [&](float t) -> Sample {
+ Sample out {.t = t};
+ float back = std::numeric_limits::infinity();
+ float ahead = std::numeric_limits::infinity();
+ for (const QueryContact& contact : contacts_at(t)) {
+ if (glm::dot(direction, contact.normal) >= -k_approach_epsilon) {
continue;
}
- if (not deepest.has_value() || contact.penetration > deepest->penetration) {
- deepest = contact;
+ const float rate = -glm::dot(delta, contact.normal);
+ const float root = t + ((target_depth - contact.penetration) / rate);
+ if (contact.penetration > blocking_depth) {
+ back = std::min(back, root);
+ if (not out.blocking.has_value() || contact.penetration > out.blocking->penetration) {
+ out.blocking = contact;
+ }
+ } else {
+ ahead = std::min(ahead, root);
+ if (not out.touching.has_value() || contact.penetration > out.touching->penetration) {
+ out.touching = contact;
+ }
}
}
- return deepest;
+ if (out.blocking.has_value()) {
+ out.newton = back;
+ } else if (out.touching.has_value()) {
+ out.newton = ahead;
+ }
+ return out;
};
// samples closer than the radius so thin geometry cannot fall between two of them
const float sample_step = std::max(capsule.radius * 0.5f, skin) / distance;
float clear = 0.0f;
float blocked = 1.0f;
- std::optional hit;
- for (float t = std::min(sample_step, 1.0f);; t = std::min(t + sample_step, 1.0f)) {
- hit = blocking_contact(t);
- if (hit.has_value()) {
- blocked = t;
- break;
- }
- clear = t;
- if (t >= 1.0f) {
- return result;
+ Sample last;
+ {
+ ZoneNamedN(march_zone, "physics::SweepMarch", true);
+ uint64_t samples = 0;
+ for (float t = std::min(sample_step, 1.0f);; t = std::min(t + sample_step, 1.0f)) {
+ ++samples;
+ last = sample(t);
+ if (last.blocking.has_value()) {
+ blocked = t;
+ break;
+ }
+ clear = t;
+ if (t >= 1.0f) {
+ ZoneValueV(march_zone, samples);
+ return result;
+ }
}
+ ZoneValueV(march_zone, samples);
}
- const auto refine_iterations = static_cast(tunables().sweep_refine_iterations);
- for (int iteration = 0; iteration < refine_iterations; ++iteration) {
- const float middle = 0.5f * (clear + blocked);
- if (auto contact = blocking_contact(middle)) {
- blocked = middle;
- hit = contact;
- } else {
- clear = middle;
+ ZoneNamedN(refine_zone, "physics::SweepRefine", true);
+ std::optional hit = last.blocking;
+ const float tolerance = skin * 0.25f / distance;
+ const int refine_iterations = refine ? static_cast(tunables().sweep_refine_iterations) : 0;
+ uint64_t passes = 0;
+ for (int iteration = 0; iteration < refine_iterations && blocked - clear > tolerance; ++iteration) {
+ ++passes;
+ // Newton formula
+ float guess = last.newton;
+ if (not(guess > clear && guess < blocked)) {
+ guess = 0.5f * (clear + blocked);
+ }
+
+ last = sample(guess);
+ if (last.blocking.has_value()) {
+ blocked = guess;
+ hit = last.blocking;
+ continue;
+ }
+ clear = guess;
+ if (last.touching.has_value() && last.touching->penetration >= accept_depth) {
+ // within the skin of what stops the move
+ hit = last.touching;
+ break;
}
}
+ ZoneValueV(refine_zone, passes);
return SweepHit {
.hit = true,
diff --git a/engine/src/toast/physics/simulator.cpp b/engine/src/toast/physics/simulator.cpp
index 227afd9fd..dc5f54c26 100644
--- a/engine/src/toast/physics/simulator.cpp
+++ b/engine/src/toast/physics/simulator.cpp
@@ -32,6 +32,7 @@
#include
#include
#include
+#include
#include
#include
@@ -244,6 +245,36 @@ Simulator::Simulator() {
refreshAllPalettes();
}
});
+ // A new level starts with the settings wind
+ m_listener.subscribe([](event::SetWorldRoot&) { resetWind(); }, 10);
+}
+
+void Simulator::setWind(const glm::vec3& wind) {
+ if (not instance) {
+ return;
+ }
+ if (not std::isfinite(wind.x) || not std::isfinite(wind.y) || not std::isfinite(wind.z)) {
+ TOAST_WARN("Physics", "Ignored a non-finite wind");
+ return;
+ }
+ std::scoped_lock lock(instance->m_wind_mutex);
+ instance->m_level_wind = wind;
+}
+
+auto Simulator::wind() -> glm::vec3 {
+ if (not instance) {
+ return tunables().wind;
+ }
+ std::scoped_lock lock(instance->m_wind_mutex);
+ return instance->m_level_wind.value_or(tunables().wind);
+}
+
+void Simulator::resetWind() {
+ if (not instance) {
+ return;
+ }
+ std::scoped_lock lock(instance->m_wind_mutex);
+ instance->m_level_wind.reset();
}
Simulator::~Simulator() {
@@ -281,6 +312,8 @@ void Simulator::registerRigidbody(Rigidbody& node) {
material.restitution = node.material->restitution();
material.static_friction = node.material->staticFriction();
material.dynamic_friction = node.material->dynamicFriction();
+ material.rolling_friction = node.material->rollingFriction();
+ material.spin_friction = node.material->spinFriction();
}
const BodyID body = instance->createBody(node.descriptor());
@@ -429,9 +462,13 @@ void Simulator::registerVoxelNode(toast::VoxelNode& node) {
.allow_sleep = node.allow_sleep,
.position = node.world_position,
.rotation = node.world_rotation,
- .mass = 1.0f,
+ .mass = 1.0f, // calculated later
.gravity_scale = node.gravity_scale,
- }
+ .linear_damping = node.linear_damping,
+ .angular_damping = node.angular_damping,
+ .drag_scale = node.drag_scale,
+ .layers = {.included = node.included, .excluded = node.excluded},
+ }
);
if (not instance->valid(body)) {
return;
@@ -1269,12 +1306,14 @@ auto Simulator::spawnFragmentBody(ShapeID source_shape_id, const DetachedCompone
glm::vec3 origin_local = glm::vec3(extracted.offset) * static_cast(voxel::k_brick_dim) * voxel::k_voxel_size;
glm::quat frag_rotation = glm::normalize(rotation * local_rotation);
glm::vec3 frag_pos = position + rotation * (local_center + local_rotation * origin_local);
+ const int debris_layer = component.voxel_count >= tunables().large_debris_min_voxels ? large_debris_layer : small_debris_layer;
// clang-format off
auto frag_body = createBody(BodyDescriptor {
.type = BodyType::dynamic_body,
.position = frag_pos,
.rotation = frag_rotation,
.angular_velocity = angular_velocity,
+ .layers = {.included = layerBit(default_layer) | layerBit(debris_layer), .excluded = 0},
});
// clang-format on
@@ -1409,12 +1448,14 @@ auto Simulator::spawnStaticSplitBody(ShapeID source_shape_id, const DetachedComp
const glm::vec3 origin_local = glm::vec3(extracted.offset) * static_cast(voxel::k_brick_dim) * voxel::k_voxel_size;
const glm::quat piece_rotation = glm::normalize(rotation * local_rotation);
const glm::vec3 piece_pos = position + rotation * (local_center + local_rotation * origin_local);
+ const LayerFilter source_layers = body->layers;
// clang-format off
auto split_body = createBody(BodyDescriptor {
.type = BodyType::static_body,
.position = piece_pos,
.rotation = piece_rotation,
+ .layers = source_layers,
});
// clang-format on
// A failed spawn gives the bricks back
@@ -1803,6 +1844,28 @@ void Simulator::setBodyTransform(BodyID body, const glm::vec3& position, const g
instance->setTransform(body, position, rotation);
}
+void Simulator::setBodyLayers(BodyID body, LayerFilter layers) {
+ if (instance == nullptr) {
+ return;
+ }
+ if (not onSimulatorThread()) {
+ request([body, layers] { setBodyLayers(body, layers); });
+ return;
+ }
+ runPendingRequests();
+ if (not instance->mainThreadMutationAllowed()) {
+ return;
+ }
+ Body* stored = instance->tryGetBody(body);
+ if (stored == nullptr) {
+ return;
+ }
+ stored->layers = layers;
+ // something it ignored until now may be resting on it
+ wakeBody(body);
+ instance->wakeBodiesTouching(body);
+}
+
auto Simulator::setBodyLinearVelocity(BodyID body, const glm::vec3& velocity) -> bool {
if (instance == nullptr) {
return false;
@@ -2154,35 +2217,97 @@ auto Simulator::solveNormal(Constraint& constraint, Body& body_a, Body& body_b)
}
auto Simulator::solveFriction(Constraint& constraint, Body& body_a, Body& body_b) -> bool {
- if (constraint.tangent_mass <= 0.0f) {
+ if (constraint.tangent_mass_1 <= 0.0f && constraint.tangent_mass_2 <= 0.0f) {
return true;
}
// recalcualte because the normal impulse changed velocity
- glm::vec3 relative_velocity = velocityAtPoint(body_b, constraint.r_b) - velocityAtPoint(body_a, constraint.r_a);
- float tangent_speed = glm::dot(relative_velocity, constraint.tangent);
- if (not std::isfinite(tangent_speed)) {
+ const glm::vec3 relative_velocity = velocityAtPoint(body_b, constraint.r_b) - velocityAtPoint(body_a, constraint.r_a);
+ const float speed_1 = glm::dot(relative_velocity, constraint.tangent_1);
+ const float speed_2 = glm::dot(relative_velocity, constraint.tangent_2);
+ if (not std::isfinite(speed_1) || not std::isfinite(speed_2)) {
return false;
}
- float impulse_delta = -tangent_speed * constraint.tangent_mass;
- float old_impulse = constraint.accumulated_tangent_impulse;
- float propsed_impulse = old_impulse + impulse_delta;
- float static_limit = constraint.static_friction * constraint.accumulated_normal_impulse;
- float new_impulse = 0.0f;
+ const glm::vec2 old_impulse = constraint.accumulated_tangent_impulse;
+ glm::vec2 proposed_impulse =
+ old_impulse + glm::vec2 {-speed_1 * constraint.tangent_mass_1, -speed_2 * constraint.tangent_mass_2};
- if (std::abs(propsed_impulse) <= static_limit) {
- // no slipping
- new_impulse = propsed_impulse;
- } else {
- // slipping
- float dynamic_limit = constraint.dynamic_friction * constraint.accumulated_normal_impulse;
- new_impulse = std::clamp(propsed_impulse, -dynamic_limit, dynamic_limit);
+ const float static_limit = constraint.static_friction * constraint.accumulated_normal_impulse;
+ const float length_sq = glm::dot(proposed_impulse, proposed_impulse);
+ if (length_sq > static_limit * static_limit) {
+ // slipping keeps the direction but drops to kinetic
+ const float dynamic_limit = constraint.dynamic_friction * constraint.accumulated_normal_impulse;
+ proposed_impulse *= dynamic_limit / std::sqrt(length_sq);
+ }
+
+ constraint.accumulated_tangent_impulse = proposed_impulse;
+ const glm::vec2 applied_impulse = proposed_impulse - old_impulse;
+ applyImpulse(
+ body_a,
+ body_b,
+ constraint.r_a,
+ constraint.r_b,
+ constraint.tangent_1 * applied_impulse.x + constraint.tangent_2 * applied_impulse.y
+ );
+
+ return true;
+}
+
+auto Simulator::angularMassAround(const Body& body_a, const Body& body_b, const glm::vec3& axis) -> float {
+ float inverse_mass = 0.0f;
+ if (movable(body_a)) {
+ inverse_mass += glm::dot(axis, body_a.inverse_inertia_world * axis);
+ }
+ if (movable(body_b)) {
+ inverse_mass += glm::dot(axis, body_b.inverse_inertia_world * axis);
+ }
+ return std::isfinite(inverse_mass) && inverse_mass > 1.0e-8f ? 1.0f / inverse_mass : 0.0f;
+}
+
+void Simulator::applyAngularImpulse(Body& body_a, Body& body_b, const glm::vec3& impulse) {
+ if (movable(body_a)) {
+ body_a.angular_velocity -= body_a.inverse_inertia_world * impulse;
+ }
+ if (movable(body_b)) {
+ body_b.angular_velocity += body_b.inverse_inertia_world * impulse;
+ }
+}
+
+auto Simulator::solveRolling(Constraint& constraint, Body& body_a, Body& body_b) -> bool {
+ // only turning matters here and a body that cannot move does not turn either
+ const glm::vec3 angular_a = movable(body_a) ? body_a.angular_velocity : glm::vec3 {0.0f};
+ const glm::vec3 angular_b = movable(body_b) ? body_b.angular_velocity : glm::vec3 {0.0f};
+ const glm::vec3 relative = angular_b - angular_a;
+ const float roll_1 = glm::dot(relative, constraint.tangent_1);
+ const float roll_2 = glm::dot(relative, constraint.tangent_2);
+ const float spin = glm::dot(relative, constraint.normal);
+ if (not std::isfinite(roll_1) || not std::isfinite(roll_2) || not std::isfinite(spin)) {
+ return false;
}
- constraint.accumulated_tangent_impulse = new_impulse;
- float applied_impulse = new_impulse - old_impulse;
- applyImpulse(body_a, body_b, constraint.r_a, constraint.r_b, constraint.tangent * applied_impulse);
+ // rolling is limited to a disc like friction so it resists every rolling direction the same
+ const glm::vec2 old_roll = constraint.accumulated_rolling_impulse;
+ glm::vec2 roll = old_roll + glm::vec2 {-roll_1 * constraint.rolling_mass_1, -roll_2 * constraint.rolling_mass_2};
+ const float roll_limit = constraint.rolling_friction * constraint.accumulated_normal_impulse;
+ const float roll_length_sq = glm::dot(roll, roll);
+ if (roll_length_sq > roll_limit * roll_limit) {
+ roll *= roll_limit / std::sqrt(roll_length_sq);
+ }
+ constraint.accumulated_rolling_impulse = roll;
+ const glm::vec2 applied_roll = roll - old_roll;
+
+ // spin is a single axis so a segment is enough
+ const float old_spin = constraint.accumulated_spin_impulse;
+ const float spin_limit = constraint.spin_friction * constraint.accumulated_normal_impulse;
+ const float new_spin = std::clamp(old_spin - (spin * constraint.spin_mass), -spin_limit, spin_limit);
+ constraint.accumulated_spin_impulse = new_spin;
+
+ applyAngularImpulse(
+ body_a,
+ body_b,
+ constraint.tangent_1 * applied_roll.x + constraint.tangent_2 * applied_roll.y + constraint.normal * (new_spin - old_spin)
+ );
return true;
}
@@ -2727,15 +2852,26 @@ void Simulator::integrate(float dt) {
}
const glm::vec3 gravity = tunables().gravity;
- const auto integrate_range = [this, gravity, dt](size_t begin, size_t end) {
- for (size_t index = begin; index < end; ++index) {
- BodySlot& slot = m_bodies[index];
- if (not slot.occupied) {
- continue;
- }
- integrateBody(BodyID {.slot = static_cast(index), .generation = slot.generation}, slot.body, gravity, dt);
- }
- };
+ const glm::vec3 wind = Simulator::wind();
+ const float air_density = tunables().air_density;
+ const float shelter_distance = tunables().wind_shelter_distance;
+ const bool shelter_checks = shelter_distance > 0.0f && air_density > 0.0f && glm::dot(wind, wind) > 1.0e-12f;
+ const glm::vec3 upwind = shelter_checks ? -glm::normalize(wind) : glm::vec3 {0.0f};
+ const auto integrate_range =
+ [this, gravity, wind, air_density, shelter_checks, shelter_distance, upwind, dt](size_t begin, size_t end) {
+ for (size_t index = begin; index < end; ++index) {
+ BodySlot& slot = m_bodies[index];
+ if (not slot.occupied) {
+ continue;
+ }
+ auto body_id = BodyID {.slot = static_cast(index), .generation = slot.generation};
+ // Anything with solid geometry nearby stops the wind
+ const bool sheltered = shelter_checks && slot.body.type == BodyType::dynamic_body && slot.body.awake &&
+ slot.body.drag_coefficient > 0.0f &&
+ windBlocked(slot.body.worldCenterOfMass(), upwind, shelter_distance, body_id);
+ integrateBody(body_id, slot.body, gravity, air_density, sheltered ? glm::vec3 {0.0f} : wind, dt);
+ }
+ };
const size_t minimum_bodies_per_job = tunables().min_bodies_per_job;
const size_t worker_count = std::max(toast::ThreadPool::workerCount(), static_cast(1));
@@ -2766,7 +2902,9 @@ void Simulator::integrate(float dt) {
}
}
-void Simulator::integrateBody(BodyID id, Body& body, const glm::vec3& gravity, float dt) {
+void Simulator::integrateBody(
+ BodyID id, Body& body, const glm::vec3& gravity, float air_density, const glm::vec3& wind, float dt
+) {
ZoneScopedN("physics::IntegrateBody");
ZoneValue(static_cast(id.slot));
if (not body.enabled) {
@@ -2789,6 +2927,33 @@ void Simulator::integrateBody(BodyID id, Body& body, const glm::vec3& gravity, f
// linear integration
body.linear_velocity += gravity * body.gravity_scale * dt;
+
+ // drag
+ if (air_density > 0.0f && body.drag_coefficient > 0.0f) {
+ glm::vec3 rel_wind = body.linear_velocity - wind;
+ float speed = glm::length(rel_wind);
+ if (speed > 1.0e-6) {
+ glm::vec3 local_dir = glm::conjugate(body.rotation) * (rel_wind / speed);
+ float area = body.drag_area_base + glm::dot(body.drag_area_axis, glm::abs(local_dir));
+ float k = 0.5f * air_density * body.drag_coefficient * area * body.inverse_mass;
+ body.linear_velocity = wind + rel_wind / (1.0f + (k * speed * dt));
+ }
+
+ float spin = glm::length(body.angular_velocity);
+ if (spin > 1.0e-6) {
+ glm::vec3 axis = body.angular_velocity / spin;
+ float inverse_inertia_about_axis = glm::dot(axis, body.inverse_inertia_world * axis);
+ float area = body.drag_area_base + ((body.drag_area_axis.x + body.drag_area_axis.y + body.drag_area_axis.z) / 3.0f);
+ float r3 = body.extent_radius * body.extent_radius * body.extent_radius;
+ float k_spin = 0.5f * air_density * body.drag_coefficient * 0.4f * area * r3 * inverse_inertia_about_axis;
+ body.angular_velocity /= (1.0f + (k_spin * spin * dt));
+ }
+ }
+
+ body.linear_velocity *= std::exp(-body.linear_damping * dt);
+ body.angular_velocity *= std::exp(-body.angular_damping * dt);
+
+ // locks
if (body.lock_position.x) {
body.linear_velocity.x = 0.0f;
}
@@ -2800,7 +2965,6 @@ void Simulator::integrateBody(BodyID id, Body& body, const glm::vec3& gravity, f
}
const glm::vec3 center_of_mass = body.worldCenterOfMass() + body.linear_velocity * dt;
- // angular integration
if (body.lock_rotation.x) {
body.angular_velocity.x = 0.0f;
}
@@ -2947,6 +3111,9 @@ auto Simulator::createBody(const BodyDescriptor& descriptor) -> BodyID {
const glm::quat rotation = glm::normalize(descriptor.rotation);
const float inverse_mass = descriptor.type == BodyType::dynamic_body ? 1.0f / descriptor.mass : 0.0f;
+ // a negative damping would add energy every tick
+ const auto clamped_damping = [](float damping) { return std::isfinite(damping) ? std::max(damping, 0.0f) : 0.0f; };
+
Body body {
.type = descriptor.type,
.allow_sleep = descriptor.type == BodyType::dynamic_body && descriptor.allow_sleep,
@@ -2959,7 +3126,11 @@ auto Simulator::createBody(const BodyDescriptor& descriptor) -> BodyID {
.inverse_mass = inverse_mass,
.gravity_scale = descriptor.gravity_scale,
.lock_position = descriptor.lock_position,
- .lock_rotation = descriptor.lock_rotation
+ .lock_rotation = descriptor.lock_rotation,
+ .linear_damping = clamped_damping(descriptor.linear_damping),
+ .angular_damping = clamped_damping(descriptor.angular_damping),
+ .drag_scale = descriptor.drag_scale,
+ .layers = descriptor.layers,
};
if (not m_free_body_slots.empty()) {
@@ -3320,7 +3491,8 @@ void Simulator::checkTriggers() {
if (const auto body = trigger->parent().as(); body.exists()) {
ignored = body->bodyID();
}
- overlapShape(ignored, *probe, trigger->world_position, trigger->world_rotation, overlapping);
+ const LayerFilter layers {.included = trigger->included, .excluded = trigger->excluded};
+ overlapShape(ignored, layers, *probe, trigger->world_position, trigger->world_rotation, overlapping);
}
trigger->updateOverlaps(overlapping);
}
@@ -3466,6 +3638,14 @@ void Simulator::destroyVoxelData(VoxelDataID data) {
m_free_voxel_shape_slots.emplace_back(data.slot);
}
+namespace {
+
+auto dragBoxAreaAxis(const glm::vec3& h) -> glm::vec3 {
+ return 4.0f * glm::vec3 {h.y * h.z, h.x * h.z, h.x * h.y};
+}
+
+}
+
void Simulator::rebuildMassProperties(BodyID id) {
ZoneScopedN("physics::RebuildMassProperties");
ZoneValue(static_cast(id.slot));
@@ -3479,6 +3659,9 @@ void Simulator::rebuildMassProperties(BodyID id) {
const glm::vec3 previous_center_of_mass = body->local_center_of_mass;
body->inverse_inertia_local = {0.0f};
body->inverse_inertia_world = {0.0f};
+ body->drag_area_base = 0;
+ body->drag_area_axis = {};
+ body->drag_coefficient = 0.0f;
body->local_center_of_mass = {};
if (body->inverse_mass == 0.0f) {
// static and kinematic bodies we just set inertia to 0
@@ -3529,6 +3712,13 @@ void Simulator::rebuildMassProperties(BodyID id) {
body->inverse_inertia_local = {inverse_inertia};
glm::mat3 rotation = glm::mat3_cast(body->rotation);
body->inverse_inertia_world = rotation * body->inverse_inertia_local * glm::transpose(rotation);
+
+ body->extent_radius = glm::length(shape->sphere.local_center) + shape->sphere.radius;
+
+ // drag
+ body->drag_area_base = glm::pi() * radius_sq;
+ body->drag_coefficient = 0.47f;
+
break;
}
case ShapeType::box: {
@@ -3562,6 +3752,13 @@ void Simulator::rebuildMassProperties(BodyID id) {
body->inverse_inertia_local = local_rotation * inverse_inertia * glm::transpose(local_rotation);
glm::mat3 rotation = glm::mat3_cast(body->rotation);
body->inverse_inertia_world = rotation * body->inverse_inertia_local * glm::transpose(rotation);
+
+ body->extent_radius = glm::length(shape->box.local_center) + (0.5f * glm::length(shape->box.size));
+
+ // Drag
+ body->drag_area_axis = dragBoxAreaAxis(shape->box.size * 0.5f);
+ body->drag_coefficient = 1.05f;
+
break;
}
case ShapeType::voxel: {
@@ -3597,6 +3794,27 @@ void Simulator::rebuildMassProperties(BodyID id) {
body->inverse_inertia_local = inverse_inertia;
const glm::mat3 rotation = glm::mat3_cast(body->rotation);
body->inverse_inertia_world = rotation * body->inverse_inertia_local * glm::transpose(rotation);
+
+ using namespace glm;
+ const vec3 center = inverse(shape->voxel.local_rotation) * (body->local_center_of_mass - shape->voxel.local_center);
+ const AABB& bounds = shape->voxel.local_bounds;
+ float farthest_squared = 0.0f;
+ for (int corner = 0; corner < 8; ++corner) {
+ const glm::vec3 point {
+ (corner & 1) != 0 ? bounds.max.x : bounds.min.x,
+ (corner & 2) != 0 ? bounds.max.y : bounds.min.y,
+ (corner & 4) != 0 ? bounds.max.z : bounds.min.z,
+ };
+ const vec3 offset = point - center;
+ farthest_squared = std::max(farthest_squared, glm::dot(offset, offset));
+ }
+ body->extent_radius = std::sqrt(farthest_squared);
+
+ // Drag
+ // the value from the AABB is good enough
+ body->drag_area_axis = dragBoxAreaAxis((bounds.max - bounds.min) * 0.5f);
+ body->drag_coefficient = 1.05f;
+
break;
}
case ShapeType::capsule: {
@@ -3630,6 +3848,14 @@ void Simulator::rebuildMassProperties(BodyID id) {
body->inverse_inertia_local = local_rotation * inverse_inertia * glm::transpose(local_rotation);
glm::mat3 rotation = glm::mat3_cast(body->rotation);
body->inverse_inertia_world = rotation * body->inverse_inertia_local * glm::transpose(rotation);
+
+ body->extent_radius = glm::length(shape->capsule.local_center) + (0.5f * shape->capsule.height) + shape->capsule.radius;
+
+ // Drag
+ // just treat like a box ngl
+ body->drag_area_axis = dragBoxAreaAxis({shape->capsule.radius, shape->capsule.radius, 0.5f * shape->capsule.height});
+ body->drag_coefficient = 0.82;
+
break;
}
default: {
@@ -3638,35 +3864,9 @@ void Simulator::rebuildMassProperties(BodyID id) {
}
}
- switch (shape->type) {
- case ShapeType::sphere: body->extent_radius = glm::length(shape->sphere.local_center) + shape->sphere.radius; break;
- case ShapeType::box:
- body->extent_radius = glm::length(shape->box.local_center) + (0.5f * glm::length(shape->box.size));
- break;
- case ShapeType::capsule:
- body->extent_radius = glm::length(shape->capsule.local_center) + (0.5f * shape->capsule.height) + shape->capsule.radius;
- break;
- case ShapeType::voxel: {
- const glm::vec3 center =
- glm::inverse(shape->voxel.local_rotation) * (body->local_center_of_mass - shape->voxel.local_center);
- const AABB& bounds = shape->voxel.local_bounds;
- float farthest_squared = 0.0f;
- for (int corner = 0; corner < 8; ++corner) {
- const glm::vec3 point {
- (corner & 1) != 0 ? bounds.max.x : bounds.min.x,
- (corner & 2) != 0 ? bounds.max.y : bounds.min.y,
- (corner & 4) != 0 ? bounds.max.z : bounds.min.z,
- };
- const glm::vec3 offset = point - center;
- farthest_squared = std::max(farthest_squared, glm::dot(offset, offset));
- }
- body->extent_radius = std::sqrt(farthest_squared);
- break;
- }
- }
-
const glm::vec3 center_shift = body->rotation * (body->local_center_of_mass - previous_center_of_mass);
body->linear_velocity += glm::cross(body->angular_velocity, center_shift);
+ body->drag_coefficient *= body->drag_scale;
}
auto Simulator::valid(BodyID body) const -> bool {
@@ -4081,85 +4281,6 @@ auto Simulator::cutVoxel(
return smashShape(hit->shape, tube, CapsuleSmash {.energy = energy}, false);
}
-auto Simulator::marchVoxelRay(const glm::vec3& origin, const glm::vec3& dir, float max_distance) -> std::optional {
- const glm::vec3 inv_dir = 1.0f / dir;
- const glm::vec3 end = origin + dir * max_distance;
- const AABB sweep_bounds {.min = glm::min(origin, end), .max = glm::max(origin, end)};
-
- struct Candidate {
- ShapeID shape;
- float t_min;
- float t_max;
- };
-
- std::vector candidates;
-
- for (const ShapeID shape_id : m_broad_phase.queryBounds(sweep_bounds)) {
- const Shape* shape = tryGetShape(shape_id);
- if (shape == nullptr || shape->type != ShapeType::voxel || not shape->enabled) {
- continue;
- }
- const Body* body = tryGetBody(shape->owner);
- if (body == nullptr) {
- continue;
- }
- const std::optional hit = worldShapeBounds(*body, *shape).intersectRay(origin, inv_dir);
- if (not hit) {
- continue;
- }
- candidates.push_back(Candidate {.shape = shape_id, .t_min = hit->t_min, .t_max = hit->t_max});
- }
-
- std::ranges::sort(candidates, {}, &Candidate::t_min);
-
- const float march_step = voxel::k_voxel_size * 0.25f;
-
- for (const Candidate& candidate : candidates) {
- const Shape* shape = tryGetShape(candidate.shape);
- if (shape == nullptr) {
- continue;
- }
- VoxelShapeData* data = tryGetVoxelData(shape->voxel.data);
- if (data == nullptr || data->volume == nullptr) {
- continue;
- }
- const Body* body = tryGetBody(shape->owner);
- if (body == nullptr) {
- continue;
- }
-
- const glm::quat inv_body_rotation = glm::inverse(body->rotation);
- const glm::quat inv_local_rotation = glm::inverse(shape->voxel.local_rotation);
- const glm::vec3 origin_local =
- inv_local_rotation * (inv_body_rotation * (origin - body->position) - shape->voxel.local_center);
- const glm::vec3 dir_local = inv_local_rotation * (inv_body_rotation * dir);
-
- const glm::ivec3 voxel_dims = glm::ivec3(data->volume->voxelDims());
- const float t_end = std::min(candidate.t_max, max_distance);
-
- for (float t = std::max(candidate.t_min, 0.0f); t <= t_end; t += march_step) {
- const glm::vec3 local_point = origin_local + dir_local * t;
- const glm::ivec3 voxel_coord = glm::ivec3(glm::floor(local_point / voxel::k_voxel_size));
- if (glm::any(glm::lessThan(voxel_coord, glm::ivec3(0))) || glm::any(glm::greaterThanEqual(voxel_coord, voxel_dims))) {
- continue;
- }
- const uint8_t palette_index = data->volume->materialAt(voxel_coord);
- if (palette_index == voxel::k_empty_palette_index) {
- continue;
- }
-
- const uint32_t material_index = voxel::resolveMaterialIndex(data->palette, data->materials, palette_index);
- return VoxelRayHit {
- .shape = candidate.shape,
- .distance = t,
- .shatter_radius = data->materials.materials[material_index].shatter_radius,
- };
- }
- }
-
- return std::nullopt;
-}
-
auto Simulator::smashCapsule(
const CapsuleShape& capsule, const glm::vec3& position, const glm::quat& rotation, const glm::vec3& sweep,
const CapsuleSmash& smash
@@ -4571,7 +4692,8 @@ auto Simulator::prepareConstraints(const std::vector& manifolds) -> st
m_profile.constraints = constraints.size();
m_profile.rejected_constraints = rejected_contact_count;
m_profile.warm_started_constraints = std::ranges::count_if(constraints, [](const Constraint& constraint) {
- return constraint.accumulated_normal_impulse > 0.0f || constraint.accumulated_tangent_impulse != 0.0f;
+ return constraint.accumulated_normal_impulse > 0.0f ||
+ glm::dot(constraint.accumulated_tangent_impulse, constraint.accumulated_tangent_impulse) != 0.0f;
});
ZoneValue(static_cast(constraints.size()));
@@ -4809,38 +4931,47 @@ auto Simulator::prepareConstraint(const Manifold& manifold, const ContactPoint&
const glm::vec3 tangent_velocity = relative_velocity - manifold.normal * initial_normal_speed;
const float tangent_length_sq = glm::dot(tangent_velocity, tangent_velocity);
- glm::vec3 tangent = {};
- float tangent_mass = 0.0f;
-
- if (tangent_length_sq > 1.0e-10f) {
- tangent = tangent_velocity / std::sqrt(tangent_length_sq);
- } else if (cached_contact) {
- const glm::vec3 projected_tangent =
- cached_contact->tangent_impulse - manifold.normal * glm::dot(cached_contact->tangent_impulse, manifold.normal);
- const float projected_length_sq = glm::dot(projected_tangent, projected_tangent);
- if (projected_length_sq > 1.0e-10f && std::isfinite(projected_length_sq)) {
- tangent = projected_tangent / std::sqrt(projected_length_sq);
- }
- }
-
- if (glm::dot(tangent, tangent) > 0.0f) {
- const auto calculated_tangent_mass = effectiveMassAlong(*body_a, *body_b, r_a, r_b, tangent);
- if (calculated_tangent_mass.has_value()) {
- tangent_mass = calculated_tangent_mass.value();
- }
+ // a body resting without any slip still gets grip aligned with the slip
+ const glm::vec3 tangent_1 =
+ tangent_length_sq > 1.0e-10f ? tangent_velocity / std::sqrt(tangent_length_sq) : _detail::perpendicularTo(manifold.normal);
+ const glm::vec3 tangent_2 = glm::cross(manifold.normal, tangent_1);
+ const float tangent_mass_1 = effectiveMassAlong(*body_a, *body_b, r_a, r_b, tangent_1).value_or(0.0f);
+ const float tangent_mass_2 = effectiveMassAlong(*body_a, *body_b, r_a, r_b, tangent_2).value_or(0.0f);
+
+ // kinetic friction cannot be higher than static
+ const float static_friction = contact.material.static_friction;
+ const float dynamic_friction = std::min(contact.material.dynamic_friction, static_friction);
+
+ // the comparison is false for NaN so a bad material counts as none, and most contacts skip the masses entirely
+ const float rolling_friction = contact.material.rolling_friction > 0.0f ? contact.material.rolling_friction : 0.0f;
+ const float spin_friction = contact.material.spin_friction > 0.0f ? contact.material.spin_friction : 0.0f;
+ float rolling_mass_1 = 0.0f;
+ float rolling_mass_2 = 0.0f;
+ float spin_mass = 0.0f;
+ if (rolling_friction > 0.0f) {
+ rolling_mass_1 = angularMassAround(*body_a, *body_b, tangent_1);
+ rolling_mass_2 = angularMassAround(*body_a, *body_b, tangent_2);
+ }
+ if (spin_friction > 0.0f) {
+ spin_mass = angularMassAround(*body_a, *body_b, manifold.normal);
}
float accumulated_normal_impulse = 0.0f;
- float accumulated_tangent_impulse = 0.0f;
+ glm::vec2 accumulated_tangent_impulse = {};
if (cached_contact && std::isfinite(cached_contact->normal_impulse)) {
accumulated_normal_impulse = std::max(cached_contact->normal_impulse, 0.0f);
const bool tangent_impulse_is_finite = std::isfinite(cached_contact->tangent_impulse.x) &&
std::isfinite(cached_contact->tangent_impulse.y) &&
std::isfinite(cached_contact->tangent_impulse.z);
- if (tangent_mass > 0.0f && tangent_impulse_is_finite) {
- accumulated_tangent_impulse = glm::dot(cached_contact->tangent_impulse, tangent);
- const float static_limit = contact.material.static_friction * accumulated_normal_impulse;
- accumulated_tangent_impulse = std::clamp(accumulated_tangent_impulse, -static_limit, static_limit);
+ if (tangent_impulse_is_finite) {
+ accumulated_tangent_impulse = {
+ glm::dot(cached_contact->tangent_impulse, tangent_1), glm::dot(cached_contact->tangent_impulse, tangent_2)
+ };
+ const float static_limit = static_friction * accumulated_normal_impulse;
+ const float length = glm::length(accumulated_tangent_impulse);
+ if (length > static_limit) {
+ accumulated_tangent_impulse *= static_limit / length;
+ }
}
}
@@ -4853,17 +4984,24 @@ auto Simulator::prepareConstraint(const Manifold& manifold, const ContactPoint&
.body_b = manifold.pair.b.body,
.contact_point = contact.position,
.normal = manifold.normal,
- .tangent = tangent,
+ .tangent_1 = tangent_1,
+ .tangent_2 = tangent_2,
.r_a = r_a,
.r_b = r_b,
.penetration = contact.penetration,
.normal_mass = normal_mass.value_or(0.0f),
- .tangent_mass = tangent_mass,
+ .tangent_mass_1 = tangent_mass_1,
+ .tangent_mass_2 = tangent_mass_2,
.restitution_bias = restitution_bias,
- .static_friction = contact.material.static_friction,
- .dynamic_friction = contact.material.dynamic_friction,
+ .static_friction = static_friction,
+ .dynamic_friction = dynamic_friction,
.accumulated_normal_impulse = accumulated_normal_impulse,
.accumulated_tangent_impulse = accumulated_tangent_impulse,
+ .rolling_friction = rolling_friction,
+ .spin_friction = spin_friction,
+ .rolling_mass_1 = rolling_mass_1,
+ .rolling_mass_2 = rolling_mass_2,
+ .spin_mass = spin_mass,
};
}
@@ -4877,8 +5015,9 @@ void Simulator::warmStartConstraints(std::span