This commit is contained in:
bird_egop
2026-06-05 20:47:57 +03:00
parent 195e6d71bd
commit 614787789e
11 changed files with 446 additions and 42 deletions
+69 -7
View File
@@ -33,7 +33,8 @@ public sealed class ViewportPanel : IImGuiPanel
}
DrawSelectionStatus();
DrawGridToggle();
DrawViewportToolbar();
DrawDebugControls();
var imageSize = ImGui.GetContentRegionAvail();
if (imageSize.X < 32 || imageSize.Y < 32)
@@ -56,6 +57,7 @@ public sealed class ViewportPanel : IImGuiPanel
new Vector2(1, 0));
var viewportHovered = ImGui.IsItemHovered();
var viewportFocused = ImGui.IsWindowFocused(ImGuiFocusedFlags.RootAndChildWindows);
var imageMin = ImGui.GetItemRectMin();
DrawViewportHelpOverlay();
@@ -64,6 +66,7 @@ public sealed class ViewportPanel : IImGuiPanel
_scene,
_camera,
viewportHovered,
viewportFocused,
imageMin,
imageSize);
@@ -79,17 +82,71 @@ public sealed class ViewportPanel : IImGuiPanel
ImGui.TextDisabled("Selected: none");
}
private void DrawGridToggle()
private void DrawViewportToolbar()
{
if (ImGui.Button("Reset view"))
_camera.Reset();
ImGui.SameLine();
if (ImGui.Button("Frame selected"))
{
if (_scene.TryGetSelectedPieceWorldBounds(out var selectedBounds))
_camera.FrameBounds(selectedBounds.Min, selectedBounds.Max);
}
ImGui.SameLine();
if (ImGui.Button("Frame all"))
{
if (_scene.TryGetSceneWorldBounds(out var sceneBounds))
_camera.FrameBounds(sceneBounds.Min, sceneBounds.Max);
else
_camera.Reset();
}
}
private void DrawDebugControls()
{
var grid = _scene.Grid;
if (grid == null)
return;
if (grid != null)
{
var isVisible = grid.IsVisible;
if (ImGui.Checkbox("Show grid", ref isVisible))
if (ImGui.Checkbox("Grid", ref isVisible))
grid.IsVisible = isVisible;
}
ImGui.SameLine();
var showOriginAxes = _scene.Debug.ShowOriginAxes;
if (ImGui.Checkbox("Origin axes", ref showOriginAxes))
_scene.Debug.ShowOriginAxes = showOriginAxes;
ImGui.SameLine();
var showPieceOrigins = _scene.Debug.ShowPieceOrigins;
if (ImGui.Checkbox("Piece axes", ref showPieceOrigins))
_scene.Debug.ShowPieceOrigins = showPieceOrigins;
ImGui.SameLine();
var showSelectedBounds = _scene.Debug.ShowSelectedBounds;
if (ImGui.Checkbox("Selected bounds", ref showSelectedBounds))
_scene.Debug.ShowSelectedBounds = showSelectedBounds;
ImGui.SameLine();
var showSceneBounds = _scene.Debug.ShowSceneBounds;
if (ImGui.Checkbox("Scene bounds", ref showSceneBounds))
_scene.Debug.ShowSceneBounds = showSceneBounds;
ImGui.SameLine();
var wireframe = _scene.Debug.Wireframe;
if (ImGui.Checkbox("Wireframe", ref wireframe))
_scene.Debug.Wireframe = wireframe;
}
private static void DrawViewportHelpOverlay()
{
var drawList = ImGui.GetWindowDrawList();
@@ -97,7 +154,12 @@ public sealed class ViewportPanel : IImGuiPanel
var imageMin = ImGui.GetItemRectMin();
var imageMax = ImGui.GetItemRectMax();
const string helpText = "LMB click: select / deselect\nMMB drag: rotate\nMouse wheel: zoom";
const string helpText =
"LMB click: select / deselect\n" +
"MMB drag: rotate\n" +
"RMB drag or Alt+MMB: pan\n" +
"Mouse wheel: zoom\n" +
"F: frame selected Home: frame all";
var textSize = ImGui.CalcTextSize(helpText);
var padding = new Vector2(8.0f, 6.0f);
+1 -1
View File
@@ -28,7 +28,7 @@ public sealed class GpuMesh
PrimitiveType = primitiveType;
}
public void Draw()
public unsafe void Draw()
{
_gl.BindVertexArray(VertexArrayObject);
_gl.DrawElements(PrimitiveType, IndexCount, DrawElementsType.UnsignedInt, null);
@@ -132,6 +132,59 @@ public static unsafe class PrimitiveMeshes
return CreateIndexedMesh(gl, vertices.ToArray(), indices.ToArray(), PrimitiveType.Lines);
}
public static GpuMesh CreateUnitWireBox(GL gl)
{
float[] vertices =
{
-1, -1, -1, 1.0f, 0.82f, 0.15f,
1, -1, -1, 1.0f, 0.82f, 0.15f,
1, 1, -1, 1.0f, 0.82f, 0.15f,
-1, 1, -1, 1.0f, 0.82f, 0.15f,
-1, -1, 1, 1.0f, 0.82f, 0.15f,
1, -1, 1, 1.0f, 0.82f, 0.15f,
1, 1, 1, 1.0f, 0.82f, 0.15f,
-1, 1, 1, 1.0f, 0.82f, 0.15f,
};
uint[] indices =
{
0, 1, 1, 2, 2, 3, 3, 0,
4, 5, 5, 6, 6, 7, 7, 4,
0, 4, 1, 5, 2, 6, 3, 7,
};
return CreateIndexedMesh(gl, vertices, indices, PrimitiveType.Lines);
}
public static GpuMesh CreateAxes(GL gl, float length = 1.0f)
{
if (length <= 0.0f)
throw new ArgumentOutOfRangeException(nameof(length));
float[] vertices =
{
0, 0, 0, 1.0f, 0.25f, 0.25f,
length, 0, 0, 1.0f, 0.25f, 0.25f,
0, 0, 0, 0.25f, 1.0f, 0.25f,
0, length, 0, 0.25f, 1.0f, 0.25f,
0, 0, 0, 0.25f, 0.45f, 1.0f,
0, 0, length, 0.25f, 0.45f, 1.0f,
};
uint[] indices =
{
0, 1,
2, 3,
4, 5,
};
return CreateIndexedMesh(gl, vertices, indices, PrimitiveType.Lines);
}
private static GpuMesh CreateIndexedMesh(
GL gl,
float[] vertices,
@@ -25,7 +25,7 @@ public sealed class MsaaFramebuffer
_samples = samples;
}
public void EnsureSize(int width, int height)
public unsafe void EnsureSize(int width, int height)
{
width = Math.Max(1, width);
height = Math.Max(1, height);
@@ -0,0 +1,18 @@
using System.Numerics;
namespace NResUI.Rendering.Viewport;
public readonly struct ViewportBounds
{
public Vector3 Min { get; }
public Vector3 Max { get; }
public ViewportBounds(Vector3 min, Vector3 max)
{
Min = min;
Max = max;
}
public Vector3 Center => (Min + Max) * 0.5f;
public Vector3 Size => Max - Min;
}
+81 -8
View File
@@ -4,31 +4,104 @@ namespace NResUI.Rendering.Viewport;
public sealed class ViewportCamera
{
public const float FieldOfViewDegrees = 60.0f;
public float YawDegrees { get; set; } = 35.0f;
public float PitchDegrees { get; set; } = 25.0f;
public float Distance { get; set; } = 4.0f;
public Vector3 Target { get; set; } = Vector3.Zero;
public Matrix4x4 GetSceneRotationMatrix()
{
return Matrix4x4.CreateRotationY(ToRadians(YawDegrees)) *
Matrix4x4.CreateRotationX(ToRadians(PitchDegrees));
// Kept as an explicit hook for future scene-wide transforms.
// Camera orbit is handled by GetViewMatrix().
return Matrix4x4.Identity;
}
public Matrix4x4 GetViewMatrix()
{
return Matrix4x4.CreateLookAt(
new Vector3(0.0f, 0.0f, Distance),
Vector3.Zero,
Vector3.UnitY);
return Matrix4x4.CreateLookAt(GetEyePosition(), Target, Vector3.UnitY);
}
public Matrix4x4 GetProjectionMatrix(float aspectRatio)
{
return Matrix4x4.CreatePerspectiveFieldOfView(
ToRadians(60.0f),
ToRadians(FieldOfViewDegrees),
aspectRatio,
0.01f,
100.0f);
1000.0f);
}
public Vector3 GetEyePosition()
{
return Target + GetOrbitDirection() * Distance;
}
public Vector3 GetForwardDirection()
{
return Vector3.Normalize(Target - GetEyePosition());
}
public Vector3 GetRightDirection()
{
var forward = GetForwardDirection();
var right = Vector3.Cross(forward, Vector3.UnitY);
if (right.LengthSquared() < 1e-8f)
return Vector3.UnitX;
return Vector3.Normalize(right);
}
public Vector3 GetUpDirection()
{
var right = GetRightDirection();
var forward = GetForwardDirection();
return Vector3.Normalize(Vector3.Cross(right, forward));
}
public void Pan(Vector2 mouseDelta, Vector2 viewportSize)
{
if (viewportSize.Y <= 1.0f)
return;
var worldUnitsPerPixel = 2.0f * Distance * MathF.Tan(ToRadians(FieldOfViewDegrees) * 0.5f) / viewportSize.Y;
var right = GetRightDirection();
var up = GetUpDirection();
Target -= right * (mouseDelta.X * worldUnitsPerPixel);
Target += up * (mouseDelta.Y * worldUnitsPerPixel);
}
public void Reset()
{
YawDegrees = 35.0f;
PitchDegrees = 25.0f;
Distance = 4.0f;
Target = Vector3.Zero;
}
public void FrameBounds(Vector3 boundsMin, Vector3 boundsMax)
{
var center = (boundsMin + boundsMax) * 0.5f;
var extents = (boundsMax - boundsMin) * 0.5f;
var radius = MathF.Max(extents.Length(), 0.25f);
Target = center;
Distance = Math.Clamp(radius * 2.8f, 1.5f, 200.0f);
}
private Vector3 GetOrbitDirection()
{
var yaw = ToRadians(YawDegrees);
var pitch = ToRadians(PitchDegrees);
var cosPitch = MathF.Cos(pitch);
return Vector3.Normalize(new Vector3(
MathF.Sin(yaw) * cosPitch,
MathF.Sin(pitch),
MathF.Cos(yaw) * cosPitch));
}
public static float ToRadians(float degrees)
@@ -0,0 +1,10 @@
namespace NResUI.Rendering.Viewport;
public sealed class ViewportDebugSettings
{
public bool ShowOriginAxes { get; set; } = true;
public bool ShowPieceOrigins { get; set; } = true;
public bool ShowSelectedBounds { get; set; } = true;
public bool ShowSceneBounds { get; set; }
public bool Wireframe { get; set; }
}
@@ -9,33 +9,49 @@ public sealed class ViewportInputController
private const float MouseZoomSpeed = 0.35f;
private bool _isRotatingViewport;
private bool _isPanningViewport;
public void Handle(
ViewportScene scene,
ViewportCamera camera,
bool viewportHovered,
bool viewportFocused,
Vector2 imageMin,
Vector2 imageSize)
{
var io = ImGui.GetIO();
if (viewportHovered && ImGui.IsMouseClicked(ImGuiMouseButton.Middle))
var wantsPanWithMiddle = io.KeyAlt && ImGui.IsMouseClicked(ImGuiMouseButton.Middle);
var wantsPanWithRight = ImGui.IsMouseClicked(ImGuiMouseButton.Right);
if (viewportHovered && wantsPanWithMiddle)
_isPanningViewport = true;
else if (viewportHovered && ImGui.IsMouseClicked(ImGuiMouseButton.Middle))
_isRotatingViewport = true;
if (viewportHovered && wantsPanWithRight)
_isPanningViewport = true;
if (!ImGui.IsMouseDown(ImGuiMouseButton.Middle))
_isRotatingViewport = false;
if (!ImGui.IsMouseDown(ImGuiMouseButton.Right) && !(io.KeyAlt && ImGui.IsMouseDown(ImGuiMouseButton.Middle)))
_isPanningViewport = false;
if (_isRotatingViewport)
{
camera.YawDegrees += io.MouseDelta.X * MouseRotationSpeed;
camera.YawDegrees += -io.MouseDelta.X * MouseRotationSpeed;
camera.PitchDegrees += io.MouseDelta.Y * MouseRotationSpeed;
camera.PitchDegrees = Math.Clamp(camera.PitchDegrees, -89.0f, 89.0f);
}
if (_isPanningViewport)
camera.Pan(io.MouseDelta, imageSize);
if (viewportHovered && Math.Abs(io.MouseWheel) > float.Epsilon)
{
camera.Distance -= io.MouseWheel * MouseZoomSpeed;
camera.Distance = Math.Clamp(camera.Distance, 1.5f, 40.0f);
camera.Distance -= io.MouseWheel * MouseZoomSpeed * MathF.Max(1.0f, camera.Distance * 0.15f);
camera.Distance = Math.Clamp(camera.Distance, 0.25f, 400.0f);
}
if (viewportHovered && ImGui.IsMouseClicked(ImGuiMouseButton.Left))
@@ -49,5 +65,27 @@ public sealed class ViewportInputController
localMouse,
imageSize);
}
if (viewportFocused || viewportHovered)
HandleKeyboardShortcuts(scene, camera);
}
private static void HandleKeyboardShortcuts(ViewportScene scene, ViewportCamera camera)
{
if (ImGui.IsKeyPressed(ImGuiKey.F))
{
if (scene.TryGetSelectedPieceWorldBounds(out var selectedBounds))
camera.FrameBounds(selectedBounds.Min, selectedBounds.Max);
else if (scene.TryGetSceneWorldBounds(out var sceneBounds))
camera.FrameBounds(sceneBounds.Min, sceneBounds.Max);
}
if (ImGui.IsKeyPressed(ImGuiKey.Home))
{
if (scene.TryGetSceneWorldBounds(out var sceneBounds))
camera.FrameBounds(sceneBounds.Min, sceneBounds.Max);
else
camera.Reset();
}
}
}
+91 -9
View File
@@ -1,4 +1,5 @@
using System.Numerics;
using NResUI.Rendering.Viewport.Meshes;
using NResUI.Rendering.Viewport.OpenGL;
using Silk.NET.OpenGL;
using Silk.NET.Windowing;
@@ -14,6 +15,9 @@ public sealed class ViewportRenderer
private ShaderProgram? _meshShader;
private ShaderProgram? _outlineShader;
private GpuMesh? _unitWireBoxMesh;
private GpuMesh? _axesMesh;
private int _meshMvpLocation;
private int _outlineMvpLocation;
private int _outlineColorLocation;
@@ -55,7 +59,9 @@ public sealed class ViewportRenderer
DrawGrid(scene, sceneRotation, view, projection);
DrawScene(scene, sceneRotation, view, projection);
DrawDebugOverlays(scene, sceneRotation, view, projection);
_gl.PolygonMode(TriangleFace.FrontAndBack, PolygonMode.Fill);
_framebuffer.Resolve();
_gl.Disable(EnableCap.CullFace);
@@ -71,7 +77,6 @@ public sealed class ViewportRenderer
return _framebuffer.ColorTexture;
}
private void DrawGrid(
ViewportScene scene,
Matrix4x4 sceneRotation,
@@ -90,6 +95,7 @@ public sealed class ViewportRenderer
_gl.Disable(EnableCap.StencilTest);
_gl.Disable(EnableCap.CullFace);
_gl.PolygonMode(TriangleFace.FrontAndBack, PolygonMode.Fill);
_meshShader.Use();
_meshShader.SetMatrix4(_meshMvpLocation, mvp);
@@ -109,6 +115,9 @@ public sealed class ViewportRenderer
_gl.Disable(EnableCap.StencilTest);
_meshShader.Use();
_gl.PolygonMode(
TriangleFace.FrontAndBack,
scene.Debug.Wireframe ? PolygonMode.Line : PolygonMode.Fill);
foreach (var piece in scene.Pieces)
{
@@ -120,7 +129,10 @@ public sealed class ViewportRenderer
var selectedPiece = scene.SelectedPiece;
if (selectedPiece == null)
{
_gl.PolygonMode(TriangleFace.FrontAndBack, PolygonMode.Fill);
return;
}
_gl.Enable(EnableCap.StencilTest);
_gl.StencilMask(0xFF);
@@ -130,6 +142,7 @@ public sealed class ViewportRenderer
_meshShader.Use();
DrawPiece(selectedPiece, sceneRotation, view, projection);
_gl.PolygonMode(TriangleFace.FrontAndBack, PolygonMode.Fill);
_gl.StencilFunc(StencilFunction.Notequal, 1, 0xFF);
_gl.StencilMask(0x00);
_gl.Disable(EnableCap.DepthTest);
@@ -141,21 +154,64 @@ public sealed class ViewportRenderer
_gl.Disable(EnableCap.StencilTest);
}
private void DrawDebugOverlays(
ViewportScene scene,
Matrix4x4 sceneRotation,
Matrix4x4 view,
Matrix4x4 projection)
{
if (_meshShader == null || _unitWireBoxMesh == null || _axesMesh == null)
throw new InvalidOperationException("Viewport debug resources are not initialized.");
_gl.Disable(EnableCap.StencilTest);
_gl.Disable(EnableCap.CullFace);
_gl.Disable(EnableCap.DepthTest);
_gl.PolygonMode(TriangleFace.FrontAndBack, PolygonMode.Fill);
_gl.LineWidth(2.0f);
_meshShader.Use();
if (scene.Debug.ShowOriginAxes)
DrawMesh(_axesMesh, Matrix4x4.CreateScale(1.25f) * sceneRotation, view, projection);
if (scene.Debug.ShowPieceOrigins)
{
foreach (var piece in scene.Pieces)
{
var axesModel = Matrix4x4.CreateScale(0.45f) * piece.LocalTransform * sceneRotation;
DrawMesh(_axesMesh, axesModel, view, projection);
}
}
if (scene.Debug.ShowSelectedBounds && scene.SelectedPiece != null)
{
var selectedPiece = scene.SelectedPiece;
var boundsModel = BuildLocalBoundsModel(selectedPiece.BoundsMin, selectedPiece.BoundsMax) *
selectedPiece.LocalTransform *
sceneRotation;
DrawMesh(_unitWireBoxMesh, boundsModel, view, projection);
}
if (scene.Debug.ShowSceneBounds && scene.TryGetSceneWorldBounds(out var sceneBounds))
{
var sceneBoundsModel = BuildLocalBoundsModel(sceneBounds.Min, sceneBounds.Max) * sceneRotation;
DrawMesh(_unitWireBoxMesh, sceneBoundsModel, view, projection);
}
_gl.LineWidth(1.0f);
_gl.Enable(EnableCap.DepthTest);
_gl.Enable(EnableCap.CullFace);
}
private void DrawPiece(
ViewportPiece piece,
Matrix4x4 sceneRotation,
Matrix4x4 view,
Matrix4x4 projection)
{
if (_meshShader == null)
throw new InvalidOperationException("Viewport mesh shader is not initialized.");
var model = piece.LocalTransform * sceneRotation;
var mvp = model * view * projection;
_meshShader.Use();
_meshShader.SetMatrix4(_meshMvpLocation, mvp);
piece.Mesh.Draw();
DrawMesh(piece.Mesh, model, view, projection);
}
private void DrawPieceOutline(
@@ -179,12 +235,38 @@ public sealed class ViewportRenderer
piece.Mesh.Draw();
}
private void DrawMesh(
GpuMesh mesh,
Matrix4x4 model,
Matrix4x4 view,
Matrix4x4 projection)
{
if (_meshShader == null)
throw new InvalidOperationException("Viewport mesh shader is not initialized.");
var mvp = model * view * projection;
_meshShader.Use();
_meshShader.SetMatrix4(_meshMvpLocation, mvp);
mesh.Draw();
}
private static Matrix4x4 BuildLocalBoundsModel(Vector3 boundsMin, Vector3 boundsMax)
{
var center = (boundsMin + boundsMax) * 0.5f;
var size = boundsMax - boundsMin;
return Matrix4x4.CreateScale(size * 0.5f) * Matrix4x4.CreateTranslation(center);
}
private void EnsureInitialized()
{
if (_initialized)
return;
CreateShaders();
_unitWireBoxMesh = PrimitiveMeshes.CreateUnitWireBox(_gl);
_axesMesh = PrimitiveMeshes.CreateAxes(_gl);
_initialized = true;
}
@@ -1,3 +1,4 @@
using System.Numerics;
using NResUI.Rendering.Viewport.Meshes;
namespace NResUI.Rendering.Viewport;
@@ -10,6 +11,8 @@ public sealed class ViewportScene
public ViewportGrid? Grid { get; set; }
public ViewportDebugSettings Debug { get; } = new();
public int SelectedPieceId { get; set; } = -1;
public ViewportPiece? SelectedPiece
@@ -39,6 +42,43 @@ public sealed class ViewportScene
SelectedPieceId = -1;
}
public bool TryGetPieceWorldBounds(ViewportPiece piece, out ViewportBounds bounds)
{
return TryTransformBounds(piece.BoundsMin, piece.BoundsMax, piece.LocalTransform, out bounds);
}
public bool TryGetSelectedPieceWorldBounds(out ViewportBounds bounds)
{
var selectedPiece = SelectedPiece;
if (selectedPiece == null)
{
bounds = default;
return false;
}
return TryGetPieceWorldBounds(selectedPiece, out bounds);
}
public bool TryGetSceneWorldBounds(out ViewportBounds bounds)
{
var hasBounds = false;
var min = new Vector3(float.PositiveInfinity, float.PositiveInfinity, float.PositiveInfinity);
var max = new Vector3(float.NegativeInfinity, float.NegativeInfinity, float.NegativeInfinity);
foreach (var piece in _pieces)
{
if (!TryGetPieceWorldBounds(piece, out var pieceBounds))
continue;
min = Vector3.Min(min, pieceBounds.Min);
max = Vector3.Max(max, pieceBounds.Max);
hasBounds = true;
}
bounds = hasBounds ? new ViewportBounds(min, max) : default;
return hasBounds;
}
public static ViewportScene CreateDefaultCubeScene(GpuMesh cubeMesh, GpuMesh gridMesh)
{
var scene = new ViewportScene
@@ -50,4 +90,36 @@ public sealed class ViewportScene
return scene;
}
private static bool TryTransformBounds(
Vector3 localMin,
Vector3 localMax,
Matrix4x4 transform,
out ViewportBounds bounds)
{
var corners = new[]
{
new Vector3(localMin.X, localMin.Y, localMin.Z),
new Vector3(localMax.X, localMin.Y, localMin.Z),
new Vector3(localMin.X, localMax.Y, localMin.Z),
new Vector3(localMax.X, localMax.Y, localMin.Z),
new Vector3(localMin.X, localMin.Y, localMax.Z),
new Vector3(localMax.X, localMin.Y, localMax.Z),
new Vector3(localMin.X, localMax.Y, localMax.Z),
new Vector3(localMax.X, localMax.Y, localMax.Z),
};
var min = new Vector3(float.PositiveInfinity, float.PositiveInfinity, float.PositiveInfinity);
var max = new Vector3(float.NegativeInfinity, float.NegativeInfinity, float.NegativeInfinity);
foreach (var corner in corners)
{
var world = Vector3.Transform(corner, transform);
min = Vector3.Min(min, world);
max = Vector3.Max(max, world);
}
bounds = new ViewportBounds(min, max);
return true;
}
}
+7 -11
View File
@@ -14,28 +14,24 @@ public static class ViewportSelection
return -1;
var aspect = viewportSize.X / viewportSize.Y;
var fovRadians = ViewportCamera.ToRadians(60.0f);
var fovRadians = ViewportCamera.ToRadians(ViewportCamera.FieldOfViewDegrees);
var tanHalfFov = MathF.Tan(fovRadians * 0.5f);
var ndcX = (2.0f * localMouse.X / viewportSize.X) - 1.0f;
var ndcY = 1.0f - (2.0f * localMouse.Y / viewportSize.Y);
var rayOriginWorld = new Vector3(0.0f, 0.0f, camera.Distance);
var rayDirectionWorld = Vector3.Normalize(new Vector3(
ndcX * aspect * tanHalfFov,
ndcY * tanHalfFov,
-1.0f));
var sceneRotation = camera.GetSceneRotationMatrix();
var rayOriginWorld = camera.GetEyePosition();
var rayDirectionWorld = Vector3.Normalize(
camera.GetForwardDirection() +
camera.GetRightDirection() * (ndcX * aspect * tanHalfFov) +
camera.GetUpDirection() * (ndcY * tanHalfFov));
var bestPieceId = -1;
var bestDistance = float.PositiveInfinity;
foreach (var piece in scene.Pieces)
{
var model = piece.LocalTransform * sceneRotation;
if (!Matrix4x4.Invert(model, out var inverseModel))
if (!Matrix4x4.Invert(piece.LocalTransform, out var inverseModel))
continue;
var rayOriginLocal = Vector3.Transform(rayOriginWorld, inverseModel);