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 constraints) { continue; } - const glm::vec3 impulse = - constraint.normal * constraint.accumulated_normal_impulse + constraint.tangent * constraint.accumulated_tangent_impulse; + const glm::vec3 impulse = constraint.normal * constraint.accumulated_normal_impulse + + constraint.tangent_1 * constraint.accumulated_tangent_impulse.x + + constraint.tangent_2 * constraint.accumulated_tangent_impulse.y; const bool impulse_is_finite = std::isfinite(impulse.x) && std::isfinite(impulse.y) && std::isfinite(impulse.z); if (impulse_is_finite) { applyImpulse(*body_a, *body_b, constraint.r_a, constraint.r_b, impulse); @@ -4897,7 +5036,8 @@ void Simulator::storeConstraintImpulses(std::span constraints) } cached->normal_impulse = constraint.accumulated_normal_impulse; - cached->tangent_impulse = constraint.tangent * constraint.accumulated_tangent_impulse; + cached->tangent_impulse = constraint.tangent_1 * constraint.accumulated_tangent_impulse.x + + constraint.tangent_2 * constraint.accumulated_tangent_impulse.y; } } @@ -5038,7 +5178,15 @@ auto Simulator::solveConstraint(Constraint& constraint) -> bool { return false; } - return solveFriction(constraint, *body_a, *body_b); + if (!solveFriction(constraint, *body_a, *body_b)) { + return false; + } + + // most materials leave both at zero and skip this + if (constraint.rolling_friction > 0.0f || constraint.spin_friction > 0.0f) { + return solveRolling(constraint, *body_a, *body_b); + } + return true; } } diff --git a/engine/src/toast/physics/simulator.hpp b/engine/src/toast/physics/simulator.hpp index 383bf66c2..a14e78a29 100644 --- a/engine/src/toast/physics/simulator.hpp +++ b/engine/src/toast/physics/simulator.hpp @@ -26,6 +26,7 @@ #include #include #include +#include #include #include #include @@ -82,7 +83,19 @@ class TOAST_API Simulator { auto createBody(const BodyDescriptor& descriptor) -> BodyID; void destroyBody(BodyID body); - static auto raycast(glm::vec3 pos, glm::vec3 dir, float max_distance = -1.0f, int max_targets = 3) -> std::vector; + /// @param layers Only bodies including one of these layers are hit + static auto raycast( + glm::vec3 origin, glm::vec3 direction, float max_distance = -1.0f, BodyID ignored = {}, uint32_t layers = k_all_layers + ) -> std::optional; + static auto raymulticast( + glm::vec3 origin, glm::vec3 direction, float max_distance = -1.0f, int max_hits = -1, BodyID ignored = {}, + uint32_t layers = k_all_layers + ) -> std::vector; + static auto linecast(glm::vec3 from, glm::vec3 to, int max_hits = -1, BodyID ignored = {}, uint32_t layers = k_all_layers) + -> std::vector; + + [[nodiscard]] + static auto bodyFor(const toast::Node& node) -> BodyID; [[nodiscard]] auto valid(BodyID body) const -> bool; @@ -286,10 +299,11 @@ class TOAST_API Simulator { [[nodiscard]] auto overlapAABB(const AABB& bounds) const -> bool; /// Moves a capsule until it would dig into something + /// @param refine false stops at first iteration, only position is not accurate [[nodiscard]] auto sweepCapsule( BodyID ignored, const CapsuleShape& capsule, const glm::quat& rotation, const glm::vec3& from, const glm::vec3& to, - float skin + float skin, bool refine = true ) const -> SweepHit; /// Resizes the capsule collider of a body auto setCapsuleShape(BodyID body, const CapsuleShape& capsule) -> bool; @@ -304,6 +318,11 @@ class TOAST_API Simulator { [[nodiscard]] static auto voxelFragmentRecords() -> std::span; + static void setWind(const glm::vec3& wind); + [[nodiscard]] + static auto wind() -> glm::vec3; + static void resetWind(); + private: enum class SimulationPhase : uint8_t { idle, @@ -371,6 +390,38 @@ class TOAST_API Simulator { std::optional push; }; + struct RayQuery { + glm::vec3 origin = {}; + glm::vec3 direction = {}; // normalized + float max_distance = std::numeric_limits::infinity(); // inf for no end + BodyID ignored; + uint32_t layers = k_all_layers; // bodies including none of these are not hit + bool voxels_only = false; + bool hit_passable = false; // passable materials count as solid + }; + + struct RayContact { + ShapeID shape; + BodyID body; + float distance = 0.0f; + glm::vec3 normal = {}; + uint8_t palette_index = 0; + }; + + template + auto castRay(const RayQuery& query, OnContact&& on_contact) const -> uint32_t; + + /// @returns true if a static body is within @p distance of @p position along @p toward + [[nodiscard]] + auto windBlocked(const glm::vec3& position, const glm::vec3& toward, float distance, BodyID ignored) const -> bool; + + [[nodiscard]] + static auto makeRayQuery(glm::vec3 origin, glm::vec3 direction, float max_distance, BodyID ignored, uint32_t layers) + -> std::optional; + + [[nodiscard]] + static auto makeRayHit(const RayQuery& query, const RayContact& contact, toast::Box node) -> RayHit; + struct VoxelRayHit { ShapeID shape; float distance = 0.0f; @@ -434,17 +485,21 @@ class TOAST_API Simulator { [[nodiscard]] auto tryGetBody(BodyID body) const -> const Body*; - static void integrateBody(BodyID id, Body& body, const glm::vec3& gravity, float dt); + static void integrateBody(BodyID id, Body& body, const glm::vec3& gravity, float air_density, const glm::vec3& wind, float dt); static void setBodyEnabled(BodyID body, bool enabled); static void setBodyTransform(BodyID body, const glm::vec3& position, const glm::quat& rotation); + static void setBodyLayers(BodyID body, LayerFilter layers); static auto setBodyLinearVelocity(BodyID body, const glm::vec3& velocity) -> bool; static void setShapeEnabled(ShapeID shape, bool enabled); void syncEnabledState(); [[nodiscard]] - auto queryCandidates(BodyID ignored, const AABB& bounds) const -> std::vector; + auto queryCandidates(BodyID ignored, const AABB& bounds, std::optional layers = std::nullopt) const + -> std::vector; + [[nodiscard]] + auto layersOf(BodyID body) const -> std::optional; void collideCapsuleProbe( const Body& probe_body, const Shape& probe_shape, std::span candidates, std::vector& contacts ) const; @@ -515,9 +570,12 @@ class TOAST_API Simulator { static auto effectiveMassAlong( const Body& body_a, const Body& body_b, const glm::vec3& r_a, const glm::vec3& r_b, const glm::vec3& direction ) -> std::optional; + static auto angularMassAround(const Body& body_a, const Body& body_b, const glm::vec3& axis) -> float; static void applyImpulse(Body& body_a, Body& body_b, const glm::vec3& r_a, const glm::vec3& r_b, const glm::vec3& impulse); + static void applyAngularImpulse(Body& body_a, Body& body_b, const glm::vec3& impulse); static auto solveNormal(Constraint& constraint, Body& body_a, Body& body_b) -> bool; static auto solveFriction(Constraint& constraint, Body& body_a, Body& body_b) -> bool; + static auto solveRolling(Constraint& constraint, Body& body_a, Body& body_b) -> bool; [[nodiscard]] auto correctPositions(std::span manifold_indices) -> size_t; void publishProfile(std::span islands); @@ -559,11 +617,15 @@ class TOAST_API Simulator { void checkTriggers(); void overlapShape( - BodyID ignored, const Shape& probe, const glm::vec3& position, const glm::quat& rotation, std::vector& bodies + BodyID ignored, LayerFilter layers, const Shape& probe, const glm::vec3& position, const glm::quat& rotation, + std::vector& bodies ) const; inline static Simulator* instance = nullptr; + std::mutex m_wind_mutex; + std::optional m_level_wind; + mutable std::thread::id m_owner_thread; std::atomic m_phase = SimulationPhase::idle; diff --git a/engine/src/toast/physics/voxel_query.hpp b/engine/src/toast/physics/voxel_query.hpp index 486f0c3c4..f4348e566 100644 --- a/engine/src/toast/physics/voxel_query.hpp +++ b/engine/src/toast/physics/voxel_query.hpp @@ -8,7 +8,11 @@ #pragma once #include +#include +#include +#include #include +#include #include #include #include @@ -196,4 +200,195 @@ void queryVoxelSurface(const VoxelQueryContext& context, const AABB& aabb, Callb } } +struct VoxelRayEntry { + float distance = 0.0f; + glm::vec3 normal = {}; + glm::ivec3 voxel = {}; + uint8_t palette_index = voxel::k_empty_palette_index; +}; + +namespace _detail { + +inline constexpr float k_ray_tie_epsilon = 1.0e-6f; + +[[nodiscard]] +inline auto rayTieSlack(float t) -> float { + return k_ray_tie_epsilon * std::max(1.0f, std::abs(t)); +} + +} + +/** + * @param bounds Where the voxels are + * @param hit_passable whether voxels of passable materials count as solid + * @param on_entry is given each @c VoxelRayEntry and returns how far the walk may go on in metres + */ +template +void queryVoxelRay( + const VoxelQueryContext& context, const AABB& bounds, glm::vec3 origin, glm::vec3 direction, float max_distance, + bool hit_passable, OnEntry&& on_entry +) { + constexpr float k_infinity = std::numeric_limits::infinity(); + constexpr auto k_brick = static_cast(voxel::k_brick_dim); + constexpr float k_voxel = voxel::k_voxel_size; + + const voxel::Volume& volume = context.volume; + const glm::ivec3 dims = glm::ivec3(volume.voxelDims()); + + // from here everything is in voxel size + const glm::vec3 start = origin / k_voxel; + const glm::vec3 box_min = glm::max(glm::floor(bounds.min / k_voxel - 1.0e-3f), glm::vec3(0.0f)); + const glm::vec3 box_max = glm::min(glm::ceil(bounds.max / k_voxel + 1.0e-3f), glm::vec3(dims)); + float limit = max_distance / k_voxel; + + std::array entry_times {-k_infinity, -k_infinity, -k_infinity}; + glm::vec3 inverse(0.0f); + glm::vec3 sign(0.0f); + float t_exit = k_infinity; + + for (int axis = 0; axis < 3; ++axis) { + if (direction[axis] == 0.0f) { + if (start[axis] < box_min[axis] || start[axis] > box_max[axis]) { + return; + } + continue; + } + + inverse[axis] = 1.0f / direction[axis]; + sign[axis] = direction[axis] > 0.0f ? 1.0f : -1.0f; + const float t0 = (box_min[axis] - start[axis]) * inverse[axis]; + const float t1 = (box_max[axis] - start[axis]) * inverse[axis]; + entry_times[axis] = std::min(t0, t1); + t_exit = std::min(t_exit, std::max(t0, t1)); + } + + // the face it enters the box through + float t = -k_infinity; + int axis = -1; + for (int candidate = 0; candidate < 3; ++candidate) { + if (entry_times[candidate] > t) { + t = entry_times[candidate]; + axis = candidate; + } + } + + // axes the ray crosses a plane of at t + uint32_t crossed = 0; + if (t < 0.0f) { + t = 0.0f; + axis = -1; + } else { + for (int candidate = 0; candidate < 3; ++candidate) { + if (sign[candidate] != 0.0f && entry_times[candidate] >= t - _detail::rayTieSlack(t)) { + crossed |= 1u << candidate; + } + } + } + + // moves t to where the ray leaves the cell + const auto leave = [&](glm::ivec3 cell_min, int32_t size) -> bool { + std::array plane_times {k_infinity, k_infinity, k_infinity}; + float next = k_infinity; + int next_axis = axis; + for (int candidate = 0; candidate < 3; ++candidate) { + if (sign[candidate] == 0.0f) { + continue; + } + const float plane = static_cast(sign[candidate] > 0.0f ? cell_min[candidate] + size : cell_min[candidate]); + plane_times[candidate] = (plane - start[candidate]) * inverse[candidate]; + if (plane_times[candidate] < next) { + next = plane_times[candidate]; + next_axis = candidate; + } + } + if (!std::isfinite(next)) { + return false; + } + + crossed = 0; + for (int candidate = 0; candidate < 3; ++candidate) { + if (plane_times[candidate] <= next + _detail::rayTieSlack(next)) { + crossed |= 1u << candidate; + } + } + t = std::max(next, t); + axis = next_axis; + return true; + }; + + const auto collides = [&](uint8_t palette_index) { + const uint32_t material = voxel::resolveMaterialIndex(context.palette, context.materials, palette_index); + return context.materials.materials[material].collides(); + }; + + // whatever happens a ray cannot cross more cells than these + const int32_t max_steps = (3 * (dims.x + dims.y + dims.z)) + 16; + bool in_solid = false; + + for (int32_t step = 0; step < max_steps; ++step) { + if (t > std::min(t_exit, limit)) { + return; + } + + const glm::vec3 position = start + (direction * t); + glm::ivec3 cell; + for (int candidate = 0; candidate < 3; ++candidate) { + if ((crossed & (1u << candidate)) != 0) { + const auto plane = static_cast(std::lround(position[candidate])); + cell[candidate] = sign[candidate] > 0.0f ? plane : plane - 1; + } else if (sign[candidate] < 0.0f) { + // a ray that is going down is in the cell below it + cell[candidate] = static_cast(std::ceil(position[candidate])) - 1; + } else { + cell[candidate] = static_cast(std::floor(position[candidate])); + } + } + if (glm::any(glm::lessThan(cell, glm::ivec3(0))) || glm::any(glm::greaterThanEqual(cell, dims))) { + return; + } + + const glm::ivec3 brick = cell >> 3; + const glm::ivec3 brick_min = brick * k_brick; + const voxel::BrickEntry entry = volume.entryAt(brick); + + if (entry.tag() == voxel::BrickTag::empty) { + in_solid = false; + if (!leave(brick_min, k_brick)) { + return; + } + continue; + } + + // A uniform brick is one material all through + const bool uniform = entry.tag() == voxel::BrickTag::uniform; + uint8_t palette_index = voxel::k_empty_palette_index; + if (uniform) { + palette_index = static_cast(entry.payload()); + } else { + const voxel::BrickOccupancy* occupancy = volume.occupancyPointer(brick); + const glm::uvec3 local = glm::uvec3(cell - brick_min); + if (occupancy != nullptr && voxel::isSolid(*occupancy, local.x, local.y, local.z)) { + palette_index = volume.materialAt(cell); + } + } + + const bool solid = palette_index != voxel::k_empty_palette_index && (hit_passable || collides(palette_index)); + if (solid && !in_solid) { + glm::vec3 normal = -direction; + if (axis >= 0) { + normal = glm::vec3(0.0f); + normal[axis] = -sign[axis]; + } + const float reach = + on_entry(VoxelRayEntry {.distance = t * k_voxel, .normal = normal, .voxel = cell, .palette_index = palette_index}); + limit = std::min(limit, reach / k_voxel); + } + in_solid = solid; + + if (!(uniform ? leave(brick_min, k_brick) : leave(cell, 1))) { + return; + } + } +} + } diff --git a/engine/src/toast/scripting/lua_state.cpp b/engine/src/toast/scripting/lua_state.cpp index bc6d99f7f..d0c1d5413 100644 --- a/engine/src/toast/scripting/lua_state.cpp +++ b/engine/src/toast/scripting/lua_state.cpp @@ -9,7 +9,9 @@ #include "node_proxy.hpp" #include "script_runtime.hpp" #include "signal_proxy.hpp" +#include "toast/physics/layers.hpp" #include "toast/physics/raycast.hpp" +#include "toast/physics/simulator.hpp" #include "ui_binds_proxy.hpp" #include @@ -1120,6 +1122,18 @@ void LuaState::registerApi(lua_State* state) noexcept { .endNamespace() .beginNamespace("Physics") + .addFunction( + "setWind", +[](const glm::vec3& wind) { physics::Simulator::setWind(wind); } + ) + .addFunction( + "wind", +[]() -> glm::vec3 { return physics::Simulator::wind(); } + ) + .addFunction( + "resetWind", +[]() { physics::Simulator::resetWind(); } + ) + .addFunction( + "getLayer", +[](const std::string& name) -> int { return physics::getLayer(name); } + ) .addFunction( "shootVoxel", +[](const glm::vec3& origin, const glm::vec3& direction, float max_distance, float energy, float min_radius) -> bool { diff --git a/engine/src/toast/scripting/node_proxy.cpp b/engine/src/toast/scripting/node_proxy.cpp index ef338a6bc..35cec25e9 100644 --- a/engine/src/toast/scripting/node_proxy.cpp +++ b/engine/src/toast/scripting/node_proxy.cpp @@ -89,6 +89,27 @@ auto luaArgToAny(lua_State* l, const luabridge::LuaRef& v, std::string_view cpp_ return std::any {std::in_place_type, v}; } + if (cpp_type.contains("vector")) { + std::vector values; + if (v.isNumber()) { + values.push_back(static_cast(v.unsafe_cast())); + return values; + } + if (!v.isTable()) { + luaL_error(l, "argument '%s': expected an array of integers", param_name); + } + const int length = v.length(); + values.reserve(static_cast(length)); + for (int i = 1; i <= length; ++i) { + const luabridge::LuaRef element = v[i]; + if (!element.isNumber()) { + luaL_error(l, "argument '%s': element %d is not an integer", param_name, i); + } + values.push_back(static_cast(element.unsafe_cast())); + } + return values; + } + const bool is_input_action_event = cpp_type.contains("input::ActionEvent"); const bool is_input_action = !is_input_action_event && cpp_type.contains("input::Action"); const bool is_input_bind = cpp_type.contains("input::Bind"); diff --git a/engine/src/toast/voxel/assets/voxel_palette.cpp b/engine/src/toast/voxel/assets/voxel_palette.cpp index 641f66dbc..538f8c3e8 100644 --- a/engine/src/toast/voxel/assets/voxel_palette.cpp +++ b/engine/src/toast/voxel/assets/voxel_palette.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -39,6 +40,8 @@ auto VoxelPalette::materialLibrary() const -> const voxel::MaterialLibrary& { if (m_physics[i].hasValue()) { out.static_friction = m_physics[i]->staticFriction(); out.dynamic_friction = m_physics[i]->dynamicFriction(); + out.rolling_friction = m_physics[i]->rollingFriction(); + out.spin_friction = m_physics[i]->spinFriction(); out.restitution = m_physics[i]->restitution(); } } @@ -65,7 +68,7 @@ auto VoxelPalette::materialLibrary() const -> const voxel::MaterialLibrary& { } if (out.density == 0) { - out.density = 1000; + out.density = static_cast(physics::material_defaults::k_density); } } diff --git a/engine/src/toast/voxel/nodes/voxel_node.cpp b/engine/src/toast/voxel/nodes/voxel_node.cpp index 2994d79c0..b056fccbc 100644 --- a/engine/src/toast/voxel/nodes/voxel_node.cpp +++ b/engine/src/toast/voxel/nodes/voxel_node.cpp @@ -435,6 +435,24 @@ void VoxelNode::carveBox(glm::vec3 min, glm::vec3 max) { fillBox(min, max, voxel::k_empty_palette_index); } +void VoxelNode::setLayers(int included_layers, int excluded_layers) { + included = static_cast(included_layers); + excluded = static_cast(excluded_layers); + physics::Simulator::setBodyLayers(m_body, {.included = included, .excluded = excluded}); +} + +void VoxelNode::includeLayer(int layer, bool value) { + physics::LayerFilter layers {.included = included, .excluded = excluded}; + physics::includeLayer(layers, layer, value); + setLayers(static_cast(layers.included), static_cast(layers.excluded)); +} + +void VoxelNode::excludeLayer(int layer, bool value) { + physics::LayerFilter layers {.included = included, .excluded = excluded}; + physics::excludeLayer(layers, layer, value); + setLayers(static_cast(layers.included), static_cast(layers.excluded)); +} + void VoxelNode::carveSphere(glm::vec3 center, float radius) { fillSphere(center, radius, voxel::k_empty_palette_index); } diff --git a/engine/src/toast/voxel/nodes/voxel_node.hpp b/engine/src/toast/voxel/nodes/voxel_node.hpp index 17b561a79..905102e84 100644 --- a/engine/src/toast/voxel/nodes/voxel_node.hpp +++ b/engine/src/toast/voxel/nodes/voxel_node.hpp @@ -219,6 +219,15 @@ class [[ToastNode, Icon("BoxMesh")]] TOAST_API VoxelNode : public Voxel { [[Reflect]] void carveSphere(glm::vec3 center, float radius); + [[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); + /** Empties a flat ended cylinder from a to b */ [[Reflect]] void carveCylinder(glm::vec3 a, glm::vec3 b, float radius); @@ -478,6 +487,12 @@ class [[ToastNode, Icon("BoxMesh")]] TOAST_API VoxelNode : public Voxel { [[Reflect, Group("Physics")]] bool render_only = false; + [[Reflect, Group("Physics"), Layers("excluded"), Name("Layers")]] + uint32_t included = 1; // Default + + [[Reflect, Group("Physics"), LayersExcluded]] + uint32_t excluded = 0; + [[Reflect, Group("Physics")]] bool allow_sleep = true; @@ -497,6 +512,13 @@ class [[ToastNode, Icon("BoxMesh")]] TOAST_API VoxelNode : public Voxel { [[Reflect, Group("Physics"), Unit("kg•m²"), ReadOnly("auto_mass_center")]] glm::vec3 inertia = {}; + [[Reflect, Group("Physics"), Subgroup("Drag"), Unit("1/s")]] + float linear_damping = 0.0f; + [[Reflect, Group("Physics"), Subgroup("Drag"), Unit("1/s")]] + float angular_damping = 0.0f; + [[Reflect, Group("Physics"), Subgroup("Drag")]] + float drag_scale = 1.0f; + [[Reflect, Group("Physics"), Subgroup("Velocities"), Unit("m/s"), ReadOnly]] glm::vec3 linear_velocity = {}; [[Reflect, Group("Physics"), Subgroup("Velocities"), Unit("rad/s"), ReadOnly]] diff --git a/engine/src/toast/voxel/palette.hpp b/engine/src/toast/voxel/palette.hpp index 86925d05a..204aefedf 100644 --- a/engine/src/toast/voxel/palette.hpp +++ b/engine/src/toast/voxel/palette.hpp @@ -9,6 +9,7 @@ #include #include +#include #include namespace voxel { @@ -90,20 +91,22 @@ inline constexpr uint8_t k_material_flammable = 1u << 2; struct PhysicalMaterial { /// kg/m³ never zero and integer so MassMoments stays exact - uint16_t density = 1000; + uint16_t density = static_cast(physics::material_defaults::k_density); uint8_t flags = 0; - /// Uncalibrated joules so only ordering matters - float toughness = 1000.0f; + /// Impulse in N*s one contact has to carry to break a voxel + float toughness = physics::material_defaults::k_toughness; - float structural_strength = 1000.0f; + float structural_strength = physics::material_defaults::k_structural_strength; - float shatter_radius = 0.5f; + float shatter_radius = physics::material_defaults::k_shatter_radius; - float static_friction = 0.6f; - float dynamic_friction = 0.5f; - float restitution = 0.1f; + float static_friction = physics::material_defaults::k_static_friction; + float dynamic_friction = physics::material_defaults::k_dynamic_friction; + float restitution = physics::material_defaults::k_restitution; + float rolling_friction = physics::material_defaults::k_rolling_friction; + float spin_friction = physics::material_defaults::k_spin_friction; float ignition_energy = 0.0f; float burn_rate = 0.0f; @@ -192,8 +195,8 @@ inline auto validateLibrary(const MaterialLibrary& library) -> std::vector 1.0f || negative(m.ignition_energy) || - negative(m.burn_rate) || negative(m.fuel)) { + negative(m.dynamic_friction) || negative(m.rolling_friction) || negative(m.spin_friction) || negative(m.restitution) || + m.restitution > 1.0f || negative(m.ignition_energy) || negative(m.burn_rate) || negative(m.fuel)) { out.push_back({TableIssue::out_of_range, i}); } } diff --git a/engine/src/toast/world/node_3d.cpp b/engine/src/toast/world/node_3d.cpp index 151719200..6bc0409fb 100644 --- a/engine/src/toast/world/node_3d.cpp +++ b/engine/src/toast/world/node_3d.cpp @@ -26,16 +26,35 @@ void decomposeTransform(const glm::mat4& transform, glm::vec3& position, glm::qu } -auto Node3D::raycasts(glm::vec3 pos, glm::vec3 dir, float max_distance, int max_targets) const -> std::vector { - return physics::Simulator::raycast(world_position + pos, world_rotation * dir, max_distance, max_targets); -} - -auto Node3D::raycast(glm::vec3 pos, glm::vec3 dir, float max_distance) const -> std::optional { - auto hits = physics::Simulator::raycast(world_position + pos, world_rotation * dir, max_distance, 1); - if (hits.empty()) { - return std::nullopt; - } - return hits[0]; +auto Node3D::raycast(glm::vec3 dir, glm::vec3 offset, float max_distance, bool ignore_self, const std::vector& layers) const + -> std::optional { + return physics::Simulator::raycast( + world_position + offset, + world_rotation * dir, + max_distance, + ignore_self ? physics::Simulator::bodyFor(*this) : physics::BodyID {}, + physics::layerMask(layers) + ); +} + +auto Node3D::raymulticast( + glm::vec3 dir, glm::vec3 offset, float max_distance, int max_hits, bool ignore_self, const std::vector& layers +) const -> std::vector { + return physics::Simulator::raymulticast( + world_position + offset, + world_rotation * dir, + max_distance, + max_hits, + ignore_self ? physics::Simulator::bodyFor(*this) : physics::BodyID {}, + physics::layerMask(layers) + ); +} + +auto Node3D::linecast(glm::vec3 from, glm::vec3 to, int max_hits, bool ignore_self, const std::vector& layers) const + -> std::vector { + return physics::Simulator::linecast( + from, to, max_hits, ignore_self ? physics::Simulator::bodyFor(*this) : physics::BodyID {}, physics::layerMask(layers) + ); } void Node3D::lookAt(glm::vec3 target, glm::vec3 up) { diff --git a/engine/src/toast/world/node_3d.hpp b/engine/src/toast/world/node_3d.hpp index 5ceae9436..3deffacb2 100644 --- a/engine/src/toast/world/node_3d.hpp +++ b/engine/src/toast/world/node_3d.hpp @@ -36,11 +36,40 @@ class [[ToastNode, Color("Red")]] TOAST_API Node3D : public Node { [[Reflect, NoSerialize, Group("World Transform")]] alignas(16) mutable glm::vec3 world_scale = glm::vec3(1.0f); // clang-format on + /** + * @brief The nearest thing a ray from this node hits + * @param dir Where the ray goes + * @param offset Where the ray starts + * @param max_distance How far the ray goes + * @param ignore_self Ignores colision from this node or a children + */ [[Reflect]] - auto raycasts(glm::vec3 pos, glm::vec3 dir, float max_distance = -1.0f, int max_targets = 3) const - -> std::vector; + auto raycast( + glm::vec3 dir, glm::vec3 offset = glm::vec3(0.0f), float max_distance = -1.0f, bool ignore_self = true, + const std::vector& layers = std::vector {} + ) const -> std::optional; + + /** + * @brief Up to @p max_hits things collisions + * @see raycast for the other arguments + */ + [[Reflect]] + auto raymulticast( + glm::vec3 dir, glm::vec3 offset = glm::vec3(0.0f), float max_distance = -1.0f, int max_hits = -1, bool ignore_self = true, + const std::vector& layers = std::vector {} + ) const -> std::vector; + + /** + * @brief Everything on the line between two points, nearest to @p from first + * @param from Start of the line in world space + * @param to End of the line in world space + * @param max_hits How many to look for + */ [[Reflect]] - auto raycast(glm::vec3 pos, glm::vec3 dir, float max_distance = -1.0f) const -> std::optional; + auto linecast( + glm::vec3 from, glm::vec3 to, int max_hits = -1, bool ignore_self = true, + const std::vector& layers = std::vector {} + ) const -> std::vector; /** * @brief Orients the node so its forward axis points at a world-space target