From f54f2540bd1f8d0f913470a127ef721a5b047122 Mon Sep 17 00:00:00 2001 From: Roman Likhadievski Date: Tue, 29 Sep 2026 09:49:04 +0300 Subject: [PATCH 01/13] refactor: container api --- .../{LineContainer.meta => Container.meta} | 0 .../Runtime/Container/WireframeContainer.cs | 98 +++++++ .../WireframeContainer.cs.meta} | 0 .../Container/WireframeContainerSettings.cs | 109 ++++++++ .../WireframeContainerSettings.cs.meta | 2 + UnityPackage/Runtime/Core/MeshChunk.cs | 18 +- UnityPackage/Runtime/Core/MeshProxy.cs | 10 +- UnityPackage/Runtime/Core/RectangleShape.cs | 6 + UnityPackage/Runtime/Core/ShapeDefaults.cs | 11 + .../Runtime/Core/ShapeDefaults.cs.meta | 2 + .../Runtime/LineContainer/ILineContainer.cs | 15 - .../LineContainer/ILineContainer.cs.meta | 2 - .../Runtime/LineContainer/LineContainer.cs | 76 ----- UnityPackage/Runtime/Shapes/Box/BoxFactory.cs | 40 +++ ...ntainer.Box.cs.meta => BoxFactory.cs.meta} | 0 .../Runtime/Shapes/Box/ILineContainer.Box.cs | 22 -- .../Shapes/Box/ILineContainer.Box.cs.meta | 2 - .../Runtime/Shapes/Box/LineContainer.Box.cs | 28 -- .../Runtime/Shapes/Capsule/CapsuleFactory.cs | 98 +++++++ ...Capsule.cs.meta => CapsuleFactory.cs.meta} | 0 .../Shapes/Capsule/ILineContainer.Capsule.cs | 54 ---- .../Capsule/ILineContainer.Capsule.cs.meta | 2 - .../Shapes/Capsule/LineContainer.Capsule.cs | 50 ---- .../Runtime/Shapes/Circle/CircleFactory.cs | 64 +++++ ...r.Circle.cs.meta => CircleFactory.cs.meta} | 0 .../Shapes/Circle/ILineContainer.Circle.cs | 29 -- .../Circle/ILineContainer.Circle.cs.meta | 2 - .../Shapes/Circle/LineContainer.Circle.cs | 32 --- .../Runtime/Shapes/Cone/ConeFactory.cs | 75 +++++ ...ainer.Cone.cs.meta => ConeFactory.cs.meta} | 0 .../Shapes/Cone/ILineContainer.Cone.cs | 33 --- .../Shapes/Cone/ILineContainer.Cone.cs.meta | 2 - .../Runtime/Shapes/Cone/LineContainer.Cone.cs | 32 --- .../Shapes/Cylinder/CylinderFactory.cs | 75 +++++ ...linder.cs.meta => CylinderFactory.cs.meta} | 0 .../Cylinder/ILineContainer.Cylinder.cs | 33 --- .../Cylinder/ILineContainer.Cylinder.cs.meta | 2 - .../Shapes/Cylinder/LineContainer.Cylinder.cs | 31 --- .../Runtime/Shapes/Ellipse/EllipseFactory.cs | 85 ++++++ ...Ellipse.cs.meta => EllipseFactory.cs.meta} | 0 .../Shapes/Ellipse/ILineContainer.Ellipse.cs | 41 --- .../Ellipse/ILineContainer.Ellipse.cs.meta | 2 - .../Shapes/Ellipse/LineContainer.Ellipse.cs | 39 --- .../Shapes/Ellipsoid/EllipsoidFactory.cs | 68 +++++ ...psoid.cs.meta => EllipsoidFactory.cs.meta} | 0 .../Ellipsoid/ILineContainer.Ellipsoid.cs | 33 --- .../ILineContainer.Ellipsoid.cs.meta | 2 - .../Ellipsoid/LineContainer.Ellipsoid.cs | 34 --- .../Runtime/Shapes/Frustum/FrustumFactory.cs | 79 ++++++ ...Frustum.cs.meta => FrustumFactory.cs.meta} | 0 .../Shapes/Frustum/ILineContainer.Frustum.cs | 36 --- .../Frustum/ILineContainer.Frustum.cs.meta | 2 - .../Shapes/Frustum/LineContainer.Frustum.cs | 35 --- UnityPackage/Runtime/Shapes/IShape.cs | 3 +- .../Shapes/Line/ILineContainer.Line.cs | 19 -- .../Shapes/Line/ILineContainer.Line.cs.meta | 2 - .../Runtime/Shapes/Line/LineContainer.Line.cs | 22 -- .../Runtime/Shapes/Line/LineFactory.cs | 29 ++ ...ainer.Line.cs.meta => LineFactory.cs.meta} | 0 .../Polyline/ILineContainer.Polyline.cs | 41 --- .../Polyline/ILineContainer.Polyline.cs.meta | 2 - .../Shapes/Polyline/LineContainer.Polyline.cs | 48 ---- .../Shapes/Polyline/PolylineFactory.cs | 68 +++++ ...lyline.cs.meta => PolylineFactory.cs.meta} | 0 .../Shapes/Pyramid/ILineContainer.Pyramid.cs | 32 --- .../Pyramid/ILineContainer.Pyramid.cs.meta | 2 - .../Shapes/Pyramid/LineContainer.Pyramid.cs | 29 -- .../Runtime/Shapes/Pyramid/PyramidFactory.cs | 63 +++++ ...Pyramid.cs.meta => PyramidFactory.cs.meta} | 0 .../Rectangle/ILineContainer.Rectangle.cs | 25 -- .../ILineContainer.Rectangle.cs.meta | 2 - .../Rectangle/LineContainer.Rectangle.cs | 35 --- .../Shapes/Rectangle/RectangleFactory.cs | 46 +++ ...angle.cs.meta => RectangleFactory.cs.meta} | 0 .../ILineContainer.RoundedRectangle.cs | 46 --- .../ILineContainer.RoundedRectangle.cs.meta | 2 - .../LineContainer.RoundedRectangle.cs | 41 --- .../RoundedRectangleFactory.cs | 81 ++++++ ...s.meta => RoundedRectangleFactory.cs.meta} | 0 .../Shapes/Sphere/ILineContainer.Sphere.cs | 25 -- .../Sphere/ILineContainer.Sphere.cs.meta | 2 - .../Shapes/Sphere/LineContainer.Sphere.cs | 27 -- .../Runtime/Shapes/Sphere/SphereFactory.cs | 51 ++++ ...r.Sphere.cs.meta => SphereFactory.cs.meta} | 0 .../ILineContainer.SpikedSphere.cs | 30 -- .../ILineContainer.SpikedSphere.cs.meta | 2 - .../LineContainer.SpikedSphere.cs | 32 --- .../SpikedSphere/SpikedSphereFactory.cs | 61 ++++ ...re.cs.meta => SpikedSphereFactory.cs.meta} | 0 .../Shapes/Stadium/ILineContainer.Stadium.cs | 68 ----- .../Stadium/ILineContainer.Stadium.cs.meta | 2 - .../Shapes/Stadium/LineContainer.Stadium.cs | 61 ---- .../Runtime/Shapes/Stadium/StadiumFactory.cs | 116 ++++++++ ...Stadium.cs.meta => StadiumFactory.cs.meta} | 0 .../Shapes/Star/ILineContainer.Star.cs | 34 --- .../Shapes/Star/ILineContainer.Star.cs.meta | 2 - .../Runtime/Shapes/Star/LineContainer.Star.cs | 31 --- .../Runtime/Shapes/Star/StarFactory.cs | 68 +++++ ...ainer.Star.cs.meta => StarFactory.cs.meta} | 0 .../Samples~/ShapeGallery/ShapeGallery.cs | 4 +- .../Samples~/StressTest/StressTest.cs | 4 +- UnityPackage/Tests/Runtime/BoxTests.cs | 12 +- UnityPackage/Tests/Runtime/CapsuleTests.cs | 12 +- UnityPackage/Tests/Runtime/CircleTests.cs | 10 +- UnityPackage/Tests/Runtime/ConeTests.cs | 6 +- UnityPackage/Tests/Runtime/CylinderTests.cs | 6 +- .../Tests/Runtime/DegenerateShapeTests.cs | 4 +- UnityPackage/Tests/Runtime/EllipseTests.cs | 8 +- UnityPackage/Tests/Runtime/EllipsoidTests.cs | 6 +- UnityPackage/Tests/Runtime/FrustumTests.cs | 8 +- .../Tests/Runtime/LineContainerTests.cs | 118 -------- UnityPackage/Tests/Runtime/LineTests.cs | 12 +- UnityPackage/Tests/Runtime/MeshBufferTests.cs | 10 +- .../Tests/Runtime/PerformanceTests.cs | 12 +- UnityPackage/Tests/Runtime/PolylineTests.cs | 14 +- UnityPackage/Tests/Runtime/PyramidTests.cs | 6 +- UnityPackage/Tests/Runtime/RectangleTests.cs | 4 +- UnityPackage/Tests/Runtime/RigidShapeTests.cs | 6 +- .../Tests/Runtime/RoundedRectangleTests.cs | 8 +- UnityPackage/Tests/Runtime/SphereTests.cs | 6 +- .../Tests/Runtime/SpikedSphereTests.cs | 6 +- UnityPackage/Tests/Runtime/StadiumTests.cs | 12 +- UnityPackage/Tests/Runtime/StarTests.cs | 6 +- .../Tests/Runtime/WireframeContainerTests.cs | 261 ++++++++++++++++++ ...s.meta => WireframeContainerTests.cs.meta} | 0 .../Tests/Runtime/WireframesTestBase.cs | 16 +- 126 files changed, 1778 insertions(+), 1560 deletions(-) rename UnityPackage/Runtime/{LineContainer.meta => Container.meta} (100%) create mode 100644 UnityPackage/Runtime/Container/WireframeContainer.cs rename UnityPackage/Runtime/{LineContainer/LineContainer.cs.meta => Container/WireframeContainer.cs.meta} (100%) create mode 100644 UnityPackage/Runtime/Container/WireframeContainerSettings.cs create mode 100644 UnityPackage/Runtime/Container/WireframeContainerSettings.cs.meta create mode 100644 UnityPackage/Runtime/Core/ShapeDefaults.cs create mode 100644 UnityPackage/Runtime/Core/ShapeDefaults.cs.meta delete mode 100644 UnityPackage/Runtime/LineContainer/ILineContainer.cs delete mode 100644 UnityPackage/Runtime/LineContainer/ILineContainer.cs.meta delete mode 100644 UnityPackage/Runtime/LineContainer/LineContainer.cs create mode 100644 UnityPackage/Runtime/Shapes/Box/BoxFactory.cs rename UnityPackage/Runtime/Shapes/Box/{LineContainer.Box.cs.meta => BoxFactory.cs.meta} (100%) delete mode 100644 UnityPackage/Runtime/Shapes/Box/ILineContainer.Box.cs delete mode 100644 UnityPackage/Runtime/Shapes/Box/ILineContainer.Box.cs.meta delete mode 100644 UnityPackage/Runtime/Shapes/Box/LineContainer.Box.cs create mode 100644 UnityPackage/Runtime/Shapes/Capsule/CapsuleFactory.cs rename UnityPackage/Runtime/Shapes/Capsule/{LineContainer.Capsule.cs.meta => CapsuleFactory.cs.meta} (100%) delete mode 100644 UnityPackage/Runtime/Shapes/Capsule/ILineContainer.Capsule.cs delete mode 100644 UnityPackage/Runtime/Shapes/Capsule/ILineContainer.Capsule.cs.meta delete mode 100644 UnityPackage/Runtime/Shapes/Capsule/LineContainer.Capsule.cs create mode 100644 UnityPackage/Runtime/Shapes/Circle/CircleFactory.cs rename UnityPackage/Runtime/Shapes/Circle/{LineContainer.Circle.cs.meta => CircleFactory.cs.meta} (100%) delete mode 100644 UnityPackage/Runtime/Shapes/Circle/ILineContainer.Circle.cs delete mode 100644 UnityPackage/Runtime/Shapes/Circle/ILineContainer.Circle.cs.meta delete mode 100644 UnityPackage/Runtime/Shapes/Circle/LineContainer.Circle.cs create mode 100644 UnityPackage/Runtime/Shapes/Cone/ConeFactory.cs rename UnityPackage/Runtime/Shapes/Cone/{LineContainer.Cone.cs.meta => ConeFactory.cs.meta} (100%) delete mode 100644 UnityPackage/Runtime/Shapes/Cone/ILineContainer.Cone.cs delete mode 100644 UnityPackage/Runtime/Shapes/Cone/ILineContainer.Cone.cs.meta delete mode 100644 UnityPackage/Runtime/Shapes/Cone/LineContainer.Cone.cs create mode 100644 UnityPackage/Runtime/Shapes/Cylinder/CylinderFactory.cs rename UnityPackage/Runtime/Shapes/Cylinder/{LineContainer.Cylinder.cs.meta => CylinderFactory.cs.meta} (100%) delete mode 100644 UnityPackage/Runtime/Shapes/Cylinder/ILineContainer.Cylinder.cs delete mode 100644 UnityPackage/Runtime/Shapes/Cylinder/ILineContainer.Cylinder.cs.meta delete mode 100644 UnityPackage/Runtime/Shapes/Cylinder/LineContainer.Cylinder.cs create mode 100644 UnityPackage/Runtime/Shapes/Ellipse/EllipseFactory.cs rename UnityPackage/Runtime/Shapes/Ellipse/{LineContainer.Ellipse.cs.meta => EllipseFactory.cs.meta} (100%) delete mode 100644 UnityPackage/Runtime/Shapes/Ellipse/ILineContainer.Ellipse.cs delete mode 100644 UnityPackage/Runtime/Shapes/Ellipse/ILineContainer.Ellipse.cs.meta delete mode 100644 UnityPackage/Runtime/Shapes/Ellipse/LineContainer.Ellipse.cs create mode 100644 UnityPackage/Runtime/Shapes/Ellipsoid/EllipsoidFactory.cs rename UnityPackage/Runtime/Shapes/Ellipsoid/{LineContainer.Ellipsoid.cs.meta => EllipsoidFactory.cs.meta} (100%) delete mode 100644 UnityPackage/Runtime/Shapes/Ellipsoid/ILineContainer.Ellipsoid.cs delete mode 100644 UnityPackage/Runtime/Shapes/Ellipsoid/ILineContainer.Ellipsoid.cs.meta delete mode 100644 UnityPackage/Runtime/Shapes/Ellipsoid/LineContainer.Ellipsoid.cs create mode 100644 UnityPackage/Runtime/Shapes/Frustum/FrustumFactory.cs rename UnityPackage/Runtime/Shapes/Frustum/{LineContainer.Frustum.cs.meta => FrustumFactory.cs.meta} (100%) delete mode 100644 UnityPackage/Runtime/Shapes/Frustum/ILineContainer.Frustum.cs delete mode 100644 UnityPackage/Runtime/Shapes/Frustum/ILineContainer.Frustum.cs.meta delete mode 100644 UnityPackage/Runtime/Shapes/Frustum/LineContainer.Frustum.cs delete mode 100644 UnityPackage/Runtime/Shapes/Line/ILineContainer.Line.cs delete mode 100644 UnityPackage/Runtime/Shapes/Line/ILineContainer.Line.cs.meta delete mode 100644 UnityPackage/Runtime/Shapes/Line/LineContainer.Line.cs create mode 100644 UnityPackage/Runtime/Shapes/Line/LineFactory.cs rename UnityPackage/Runtime/Shapes/Line/{LineContainer.Line.cs.meta => LineFactory.cs.meta} (100%) delete mode 100644 UnityPackage/Runtime/Shapes/Polyline/ILineContainer.Polyline.cs delete mode 100644 UnityPackage/Runtime/Shapes/Polyline/ILineContainer.Polyline.cs.meta delete mode 100644 UnityPackage/Runtime/Shapes/Polyline/LineContainer.Polyline.cs create mode 100644 UnityPackage/Runtime/Shapes/Polyline/PolylineFactory.cs rename UnityPackage/Runtime/Shapes/Polyline/{LineContainer.Polyline.cs.meta => PolylineFactory.cs.meta} (100%) delete mode 100644 UnityPackage/Runtime/Shapes/Pyramid/ILineContainer.Pyramid.cs delete mode 100644 UnityPackage/Runtime/Shapes/Pyramid/ILineContainer.Pyramid.cs.meta delete mode 100644 UnityPackage/Runtime/Shapes/Pyramid/LineContainer.Pyramid.cs create mode 100644 UnityPackage/Runtime/Shapes/Pyramid/PyramidFactory.cs rename UnityPackage/Runtime/Shapes/Pyramid/{LineContainer.Pyramid.cs.meta => PyramidFactory.cs.meta} (100%) delete mode 100644 UnityPackage/Runtime/Shapes/Rectangle/ILineContainer.Rectangle.cs delete mode 100644 UnityPackage/Runtime/Shapes/Rectangle/ILineContainer.Rectangle.cs.meta delete mode 100644 UnityPackage/Runtime/Shapes/Rectangle/LineContainer.Rectangle.cs create mode 100644 UnityPackage/Runtime/Shapes/Rectangle/RectangleFactory.cs rename UnityPackage/Runtime/Shapes/Rectangle/{LineContainer.Rectangle.cs.meta => RectangleFactory.cs.meta} (100%) delete mode 100644 UnityPackage/Runtime/Shapes/RoundedRectangle/ILineContainer.RoundedRectangle.cs delete mode 100644 UnityPackage/Runtime/Shapes/RoundedRectangle/ILineContainer.RoundedRectangle.cs.meta delete mode 100644 UnityPackage/Runtime/Shapes/RoundedRectangle/LineContainer.RoundedRectangle.cs create mode 100644 UnityPackage/Runtime/Shapes/RoundedRectangle/RoundedRectangleFactory.cs rename UnityPackage/Runtime/Shapes/RoundedRectangle/{LineContainer.RoundedRectangle.cs.meta => RoundedRectangleFactory.cs.meta} (100%) delete mode 100644 UnityPackage/Runtime/Shapes/Sphere/ILineContainer.Sphere.cs delete mode 100644 UnityPackage/Runtime/Shapes/Sphere/ILineContainer.Sphere.cs.meta delete mode 100644 UnityPackage/Runtime/Shapes/Sphere/LineContainer.Sphere.cs create mode 100644 UnityPackage/Runtime/Shapes/Sphere/SphereFactory.cs rename UnityPackage/Runtime/Shapes/Sphere/{LineContainer.Sphere.cs.meta => SphereFactory.cs.meta} (100%) delete mode 100644 UnityPackage/Runtime/Shapes/SpikedSphere/ILineContainer.SpikedSphere.cs delete mode 100644 UnityPackage/Runtime/Shapes/SpikedSphere/ILineContainer.SpikedSphere.cs.meta delete mode 100644 UnityPackage/Runtime/Shapes/SpikedSphere/LineContainer.SpikedSphere.cs create mode 100644 UnityPackage/Runtime/Shapes/SpikedSphere/SpikedSphereFactory.cs rename UnityPackage/Runtime/Shapes/SpikedSphere/{LineContainer.SpikedSphere.cs.meta => SpikedSphereFactory.cs.meta} (100%) delete mode 100644 UnityPackage/Runtime/Shapes/Stadium/ILineContainer.Stadium.cs delete mode 100644 UnityPackage/Runtime/Shapes/Stadium/ILineContainer.Stadium.cs.meta delete mode 100644 UnityPackage/Runtime/Shapes/Stadium/LineContainer.Stadium.cs create mode 100644 UnityPackage/Runtime/Shapes/Stadium/StadiumFactory.cs rename UnityPackage/Runtime/Shapes/Stadium/{LineContainer.Stadium.cs.meta => StadiumFactory.cs.meta} (100%) delete mode 100644 UnityPackage/Runtime/Shapes/Star/ILineContainer.Star.cs delete mode 100644 UnityPackage/Runtime/Shapes/Star/ILineContainer.Star.cs.meta delete mode 100644 UnityPackage/Runtime/Shapes/Star/LineContainer.Star.cs create mode 100644 UnityPackage/Runtime/Shapes/Star/StarFactory.cs rename UnityPackage/Runtime/Shapes/Star/{LineContainer.Star.cs.meta => StarFactory.cs.meta} (100%) delete mode 100644 UnityPackage/Tests/Runtime/LineContainerTests.cs create mode 100644 UnityPackage/Tests/Runtime/WireframeContainerTests.cs rename UnityPackage/Tests/Runtime/{LineContainerTests.cs.meta => WireframeContainerTests.cs.meta} (100%) diff --git a/UnityPackage/Runtime/LineContainer.meta b/UnityPackage/Runtime/Container.meta similarity index 100% rename from UnityPackage/Runtime/LineContainer.meta rename to UnityPackage/Runtime/Container.meta diff --git a/UnityPackage/Runtime/Container/WireframeContainer.cs b/UnityPackage/Runtime/Container/WireframeContainer.cs new file mode 100644 index 0000000..1d60474 --- /dev/null +++ b/UnityPackage/Runtime/Container/WireframeContainer.cs @@ -0,0 +1,98 @@ +using System; +using UnityEngine; +using Object = UnityEngine.Object; + +namespace reromanlee.Wireframes +{ + /// + /// Creates shapes and draws all of them with one GPU-skinned mesh. Disposing the container, or unloading the + /// scene it was created in, disposes every shape it created. + /// + /// + /// Main thread only. Each shape type adds its Create methods as extension methods, declared in a factory class next + /// to the shape, such as . The container creates a GameObject in the active scene; edits + /// made to its shapes are uploaded once per frame at the end of LateUpdate. + /// + public sealed class WireframeContainer : IDisposable + { + private readonly MeshProxy _proxy; + private bool _isDisposed; + + /// Creates a container with the default settings. + public WireframeContainer() : this(null) + { + } + + /// + /// Creates a container from , or from the default settings when it is null. The + /// settings are read once, so changing them later doesn't affect the container. + /// + /// A setting is outside its valid range. + public WireframeContainer(WireframeContainerSettings settings) + { + settings ??= new WireframeContainerSettings(); + settings.Validate(nameof(settings)); + + GameObject proxyObject = new(settings.ResolvedName) { hideFlags = HideFlags.NotEditable, layer = settings.Layer }; + try + { + // Otherwise the proxy lives in the active scene, and unloading that scene disposes the container. + if (settings.PersistAcrossScenes && Application.isPlaying) + { + Object.DontDestroyOnLoad(proxyObject); + } + _proxy = proxyObject.AddComponent(); + _proxy.Initialize(this, settings); + } + catch + { + UnityObjects.Destroy(proxyObject); + throw; + } + } + + /// True once the container was disposed or its scene was unloaded. + public bool IsDisposed + { + get => _isDisposed; + } + + internal MeshProxy Proxy + { + get + { + if (_isDisposed) + { + throw new ObjectDisposedException(nameof(WireframeContainer)); + } + return _proxy; + } + } + + /// Removes the container and disposes every shape it created. Calling it again does nothing. + public void Dispose() + { + if (_isDisposed) + { + return; + } + _proxy.Shutdown(); + UnityObjects.Destroy(_proxy.gameObject); + } + + /// The proxy that a factory adds a new shape to, after checking . + internal static MeshProxy ProxyOf(WireframeContainer container) + { + if (container == null) + { + throw new ArgumentNullException(nameof(container)); + } + return container.Proxy; + } + + internal void OnProxyShutdown() + { + _isDisposed = true; + } + } +} diff --git a/UnityPackage/Runtime/LineContainer/LineContainer.cs.meta b/UnityPackage/Runtime/Container/WireframeContainer.cs.meta similarity index 100% rename from UnityPackage/Runtime/LineContainer/LineContainer.cs.meta rename to UnityPackage/Runtime/Container/WireframeContainer.cs.meta diff --git a/UnityPackage/Runtime/Container/WireframeContainerSettings.cs b/UnityPackage/Runtime/Container/WireframeContainerSettings.cs new file mode 100644 index 0000000..771d68b --- /dev/null +++ b/UnityPackage/Runtime/Container/WireframeContainerSettings.cs @@ -0,0 +1,109 @@ +using System; +using UnityEngine; + +namespace reromanlee.Wireframes +{ + /// + /// How a is created. It is serializable, so a script can expose it in the Inspector. + /// + [Serializable] + public sealed class WireframeContainerSettings + { + private const string DefaultName = "Wireframes"; + private const int MaxLayer = 31; + + [Tooltip("Name of the container's GameObject in the Hierarchy.")] + [SerializeField] private string _name = DefaultName; + + [Tooltip("Material to draw with. None uses the package's vertex color material. The container never destroys it.")] + [SerializeField] private Material _material; + + [Tooltip("Layer of the container's GameObjects, so cameras can include or exclude wireframes with their culling mask.")] + [SerializeField, Range(0, MaxLayer)] private int _layer; + + [Tooltip("Keeps the container when other scenes load, instead of disposing it with the scene it was created in.")] + [SerializeField] private bool _persistAcrossScenes; + + [Tooltip("Vertices reserved up front, so a load known in advance never grows the buffers later.")] + [SerializeField, Min(0)] private int _vertexCapacity; + + [Tooltip("Edges reserved up front, so a load known in advance never grows the buffers later.")] + [SerializeField, Min(0)] private int _edgeCapacity; + + /// Name of the container's GameObject in the Hierarchy. Null or empty uses "Wireframes". + public string Name + { + get => _name; + set => _name = value; + } + + /// + /// Material to draw with, or null for the package's vertex color material. The container never destroys a + /// material passed here. + /// + public Material Material + { + get => _material; + set => _material = value; + } + + /// + /// Layer of the container's GameObjects, from 0 to 31, so cameras can include or exclude wireframes with their + /// culling mask. + /// + public int Layer + { + get => _layer; + set => _layer = value; + } + + /// + /// True keeps the container when other scenes load in Play mode, like Object.DontDestroyOnLoad. False, the + /// default, disposes it with the scene that was active when it was created. + /// + public bool PersistAcrossScenes + { + get => _persistAcrossScenes; + set => _persistAcrossScenes = value; + } + + /// Vertices reserved up front, 0 or more, so a load known in advance never grows the buffers later. + public int VertexCapacity + { + get => _vertexCapacity; + set => _vertexCapacity = value; + } + + /// Edges reserved up front, 0 or more, so a load known in advance never grows the buffers later. + public int EdgeCapacity + { + get => _edgeCapacity; + set => _edgeCapacity = value; + } + + internal string ResolvedName + { + get => string.IsNullOrEmpty(_name) ? DefaultName : _name; + } + + /// Throws for settings outside their valid range, naming as the argument. + internal void Validate(string parameterName) + { + if (_layer < 0 || _layer > MaxLayer) + { + throw new ArgumentOutOfRangeException( + parameterName, _layer, $"{nameof(Layer)} must be between 0 and {MaxLayer}."); + } + if (_vertexCapacity < 0) + { + throw new ArgumentOutOfRangeException( + parameterName, _vertexCapacity, $"{nameof(VertexCapacity)} can't be negative."); + } + if (_edgeCapacity < 0) + { + throw new ArgumentOutOfRangeException( + parameterName, _edgeCapacity, $"{nameof(EdgeCapacity)} can't be negative."); + } + } + } +} diff --git a/UnityPackage/Runtime/Container/WireframeContainerSettings.cs.meta b/UnityPackage/Runtime/Container/WireframeContainerSettings.cs.meta new file mode 100644 index 0000000..91de0cf --- /dev/null +++ b/UnityPackage/Runtime/Container/WireframeContainerSettings.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7865d9499286d508fb152c91efeb703b \ No newline at end of file diff --git a/UnityPackage/Runtime/Core/MeshChunk.cs b/UnityPackage/Runtime/Core/MeshChunk.cs index 7526415..708c77b 100644 --- a/UnityPackage/Runtime/Core/MeshChunk.cs +++ b/UnityPackage/Runtime/Core/MeshChunk.cs @@ -68,9 +68,13 @@ internal sealed class MeshChunk : IDisposable private IndexFormat _meshIndexFormat; private int _meshEdgeCount = -1; - internal MeshChunk(Transform parent, Material material) + internal MeshChunk(Transform parent, Material material, WireframeContainerSettings settings) { - GameObject chunkObject = new(ObjectName) { hideFlags = HideFlags.NotEditable }; + // Reserved before anything is created, so a device limit leaves nothing behind. + EnsureVertexCapacity(settings.VertexCapacity); + EnsureEdgeCapacity(settings.EdgeCapacity); + + GameObject chunkObject = new(ObjectName) { hideFlags = HideFlags.NotEditable, layer = settings.Layer }; Transform root = chunkObject.transform; root.SetParent(parent, false); @@ -128,6 +132,16 @@ internal int EdgeCount get => _edges.Count; } + internal int VertexCapacity + { + get => _positions.Length; + } + + internal int EdgeCapacity + { + get => _edges.Capacity; + } + internal void Add(Shape shape) { // Capacity is checked before anything changes, so a device limit leaves the chunk untouched. diff --git a/UnityPackage/Runtime/Core/MeshProxy.cs b/UnityPackage/Runtime/Core/MeshProxy.cs index 4b4f5be..8bc1c05 100644 --- a/UnityPackage/Runtime/Core/MeshProxy.cs +++ b/UnityPackage/Runtime/Core/MeshProxy.cs @@ -18,7 +18,7 @@ internal sealed class MeshProxy : MonoBehaviour private const string ShaderName = "reromanlee/Wireframes/VertexColors"; private readonly List _chunks = new(); - private LineContainer _container; + private WireframeContainer _container; private Material _material; private bool _ownsMaterial; private bool _isShutDown; @@ -28,12 +28,12 @@ internal IReadOnlyList Chunks get => _chunks; } - internal void Initialize(LineContainer container, Material material) + internal void Initialize(WireframeContainer container, WireframeContainerSettings settings) { _container = container; - if (material != null) + if (settings.Material != null) { - _material = material; + _material = settings.Material; } else { @@ -46,7 +46,7 @@ internal void Initialize(LineContainer container, Material material) _material = new Material(shader) { name = ShaderName }; _ownsMaterial = true; } - _chunks.Add(new MeshChunk(transform, _material)); + _chunks.Add(new MeshChunk(transform, _material, settings)); } /// Adds to a chunk and returns that chunk. diff --git a/UnityPackage/Runtime/Core/RectangleShape.cs b/UnityPackage/Runtime/Core/RectangleShape.cs index 700f430..9a1776b 100644 --- a/UnityPackage/Runtime/Core/RectangleShape.cs +++ b/UnityPackage/Runtime/Core/RectangleShape.cs @@ -95,6 +95,12 @@ public Vector3 WorldCornerB } } + /// Size of the XZ rectangle spanned by two corners; their difference in height is ignored. + internal static Vector2 FlatSpan(Vector3 cornerA, Vector3 cornerB) + { + return new Vector2(cornerB.x - cornerA.x, cornerB.z - cornerA.z); + } + protected override void ScaleSizes(float factor) { _size *= factor; diff --git a/UnityPackage/Runtime/Core/ShapeDefaults.cs b/UnityPackage/Runtime/Core/ShapeDefaults.cs new file mode 100644 index 0000000..4a4c6e0 --- /dev/null +++ b/UnityPackage/Runtime/Core/ShapeDefaults.cs @@ -0,0 +1,11 @@ +namespace reromanlee.Wireframes +{ + /// + /// Sizes of shapes created without arguments, matching Unity's primitives: 1 unit across and 1 unit long. + /// + internal static class ShapeDefaults + { + internal const float Size = 1f; + internal const float Radius = 0.5f; + } +} diff --git a/UnityPackage/Runtime/Core/ShapeDefaults.cs.meta b/UnityPackage/Runtime/Core/ShapeDefaults.cs.meta new file mode 100644 index 0000000..63da80a --- /dev/null +++ b/UnityPackage/Runtime/Core/ShapeDefaults.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7279dde30578b9b0f8420674950dbb39 \ No newline at end of file diff --git a/UnityPackage/Runtime/LineContainer/ILineContainer.cs b/UnityPackage/Runtime/LineContainer/ILineContainer.cs deleted file mode 100644 index b9791f5..0000000 --- a/UnityPackage/Runtime/LineContainer/ILineContainer.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System; - -namespace reromanlee.Wireframes -{ - /// - /// Creates shapes and draws all of them with one GPU-skinned mesh. Disposing the container, or unloading the - /// scene it was created in, disposes every shape it created. - /// - /// Each shape type declares its factory methods in a partial file next to the shape. - public partial interface ILineContainer : IDisposable - { - /// True once the container was disposed or its scene was unloaded. - bool IsDisposed { get; } - } -} diff --git a/UnityPackage/Runtime/LineContainer/ILineContainer.cs.meta b/UnityPackage/Runtime/LineContainer/ILineContainer.cs.meta deleted file mode 100644 index 540f313..0000000 --- a/UnityPackage/Runtime/LineContainer/ILineContainer.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: fbdffc2373483be41af8a9f0a76dc889 \ No newline at end of file diff --git a/UnityPackage/Runtime/LineContainer/LineContainer.cs b/UnityPackage/Runtime/LineContainer/LineContainer.cs deleted file mode 100644 index 740bf47..0000000 --- a/UnityPackage/Runtime/LineContainer/LineContainer.cs +++ /dev/null @@ -1,76 +0,0 @@ -using System; -using UnityEngine; - -namespace reromanlee.Wireframes -{ - /// - /// - /// Main thread only. The container creates a GameObject in the active scene; edits made to its shapes are - /// uploaded once per frame at the end of LateUpdate. - /// - public sealed partial class LineContainer : ILineContainer - { - private const string ProxyName = "Wireframes"; - - // Shapes created without arguments are sized like Unity's primitives: 1 unit across and 1 unit long. - private const float UnitSize = 1f; - private const float UnitRadius = 0.5f; - - private readonly MeshProxy _proxy; - private bool _isDisposed; - - /// Creates a container that draws with the package's vertex color material. - public LineContainer() : this(null) - { - } - - /// Creates a container that draws with , which stays owned by the caller. - public LineContainer(Material material) - { - // The proxy lives in the active scene, so unloading that scene disposes the container. - GameObject proxyObject = new(ProxyName) { hideFlags = HideFlags.NotEditable }; - try - { - _proxy = proxyObject.AddComponent(); - _proxy.Initialize(this, material); - } - catch - { - UnityObjects.Destroy(proxyObject); - throw; - } - } - - public bool IsDisposed - { - get => _isDisposed; - } - - internal MeshProxy Proxy - { - get - { - if (_isDisposed) - { - throw new ObjectDisposedException(nameof(LineContainer)); - } - return _proxy; - } - } - - public void Dispose() - { - if (_isDisposed) - { - return; - } - _proxy.Shutdown(); - UnityObjects.Destroy(_proxy.gameObject); - } - - internal void OnProxyShutdown() - { - _isDisposed = true; - } - } -} diff --git a/UnityPackage/Runtime/Shapes/Box/BoxFactory.cs b/UnityPackage/Runtime/Shapes/Box/BoxFactory.cs new file mode 100644 index 0000000..bfd8d5f --- /dev/null +++ b/UnityPackage/Runtime/Shapes/Box/BoxFactory.cs @@ -0,0 +1,40 @@ +using UnityEngine; + +namespace reromanlee.Wireframes +{ + /// Creates boxes in a . + public static class BoxFactory + { + /// Creates a white, world-aligned unit cube centered on the world origin. + public static IBox CreateBox(this WireframeContainer container) + { + MeshProxy proxy = WireframeContainer.ProxyOf(container); + return new Box(proxy, null, Vector3.zero, Quaternion.identity, Vector3.one * ShapeDefaults.Size); + } + + /// Creates a white, world-aligned box spanned by two opposite world-space corners. + public static IBox CreateBox(this WireframeContainer container, Vector3 cornerA, Vector3 cornerB) + { + MeshProxy proxy = WireframeContainer.ProxyOf(container); + return new Box(proxy, null, (cornerA + cornerB) * 0.5f, Quaternion.identity, cornerB - cornerA); + } + + /// + /// Creates a white box that follows , aligned to the bone's axes and spanned by two + /// opposite corners in the bone's local space. + /// + public static IBox CreateBox( + this WireframeContainer container, Transform bone, Vector3 localCornerA, Vector3 localCornerB) + { + MeshProxy proxy = WireframeContainer.ProxyOf(container); + Vector3 center = (localCornerA + localCornerB) * 0.5f; + return new Box(proxy, bone, center, Quaternion.identity, localCornerB - localCornerA); + } + + /// Creates a white box in world space from its center, rotation and size. + public static IBox CreateBox(this WireframeContainer container, Vector3 center, Quaternion rotation, Vector3 size) + { + return new Box(WireframeContainer.ProxyOf(container), null, center, rotation, size); + } + } +} diff --git a/UnityPackage/Runtime/Shapes/Box/LineContainer.Box.cs.meta b/UnityPackage/Runtime/Shapes/Box/BoxFactory.cs.meta similarity index 100% rename from UnityPackage/Runtime/Shapes/Box/LineContainer.Box.cs.meta rename to UnityPackage/Runtime/Shapes/Box/BoxFactory.cs.meta diff --git a/UnityPackage/Runtime/Shapes/Box/ILineContainer.Box.cs b/UnityPackage/Runtime/Shapes/Box/ILineContainer.Box.cs deleted file mode 100644 index c648566..0000000 --- a/UnityPackage/Runtime/Shapes/Box/ILineContainer.Box.cs +++ /dev/null @@ -1,22 +0,0 @@ -using UnityEngine; - -namespace reromanlee.Wireframes -{ - public partial interface ILineContainer - { - /// Creates a white, world-aligned unit cube centered on the world origin. - IBox CreateBox(); - - /// Creates a white, world-aligned box spanned by two opposite world-space corners. - IBox CreateBox(Vector3 cornerA, Vector3 cornerB); - - /// - /// Creates a white box that follows , aligned to the bone's axes and spanned by two - /// opposite corners in the bone's local space. - /// - IBox CreateBox(Transform bone, Vector3 localCornerA, Vector3 localCornerB); - - /// Creates a white box in world space from its center, rotation and size. - IBox CreateBox(Vector3 center, Quaternion rotation, Vector3 size); - } -} diff --git a/UnityPackage/Runtime/Shapes/Box/ILineContainer.Box.cs.meta b/UnityPackage/Runtime/Shapes/Box/ILineContainer.Box.cs.meta deleted file mode 100644 index 8fca1d0..0000000 --- a/UnityPackage/Runtime/Shapes/Box/ILineContainer.Box.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: eadf2f41fe8646e4bf183547aa1d3139 diff --git a/UnityPackage/Runtime/Shapes/Box/LineContainer.Box.cs b/UnityPackage/Runtime/Shapes/Box/LineContainer.Box.cs deleted file mode 100644 index 2fbbb87..0000000 --- a/UnityPackage/Runtime/Shapes/Box/LineContainer.Box.cs +++ /dev/null @@ -1,28 +0,0 @@ -using UnityEngine; - -namespace reromanlee.Wireframes -{ - public sealed partial class LineContainer - { - public IBox CreateBox() - { - return new Box(Proxy, null, Vector3.zero, Quaternion.identity, Vector3.one * UnitSize); - } - - public IBox CreateBox(Vector3 cornerA, Vector3 cornerB) - { - return new Box(Proxy, null, (cornerA + cornerB) * 0.5f, Quaternion.identity, cornerB - cornerA); - } - - public IBox CreateBox(Transform bone, Vector3 localCornerA, Vector3 localCornerB) - { - Vector3 center = (localCornerA + localCornerB) * 0.5f; - return new Box(Proxy, bone, center, Quaternion.identity, localCornerB - localCornerA); - } - - public IBox CreateBox(Vector3 center, Quaternion rotation, Vector3 size) - { - return new Box(Proxy, null, center, rotation, size); - } - } -} diff --git a/UnityPackage/Runtime/Shapes/Capsule/CapsuleFactory.cs b/UnityPackage/Runtime/Shapes/Capsule/CapsuleFactory.cs new file mode 100644 index 0000000..c205369 --- /dev/null +++ b/UnityPackage/Runtime/Shapes/Capsule/CapsuleFactory.cs @@ -0,0 +1,98 @@ +using UnityEngine; + +namespace reromanlee.Wireframes +{ + /// Creates capsules in a . + public static class CapsuleFactory + { + /// + /// Creates a white capsule with spheres of radius 0.5 centered on the world origin and 1 unit along +Z. + /// + public static ICapsule CreateCapsule(this WireframeContainer container) + { + MeshProxy proxy = WireframeContainer.ProxyOf(container); + return new Capsule( + proxy, + null, + Vector3.zero, + Quaternion.identity, + ShapeDefaults.Size, + ShapeDefaults.Radius, + ShapeDefaults.Radius, + Ring.DefaultSegments); + } + + /// + /// Creates a white capsule around two spheres of the same radius, centered on two world positions, like the + /// capsule of Physics.CapsuleCast. Its spin around the axis keeps the capsule's +Y as close to world up + /// as it can. + /// + /// Number of straight pieces each ring is drawn with, a positive multiple of 4. + public static ICapsule CreateCapsule( + this WireframeContainer container, + Vector3 centerA, + Vector3 centerB, + float radius, + int segments = Ring.DefaultSegments) + { + return container.CreateCapsule(centerA, centerB, radius, radius, segments); + } + + /// + /// Creates a white capsule around two spheres centered on two world positions, each with its own radius. Its + /// spin around the axis keeps the capsule's +Y as close to world up as it can. + /// + /// Number of straight pieces each ring is drawn with, a positive multiple of 4. + public static ICapsule CreateCapsule( + this WireframeContainer container, + Vector3 centerA, + Vector3 centerB, + float radiusA, + float radiusB, + int segments = Ring.DefaultSegments) + { + MeshProxy proxy = WireframeContainer.ProxyOf(container); + Quaternion rotation = ShapeRotations.FromAxis(centerB - centerA, Vector3.up); + float length = Vector3.Distance(centerA, centerB); + return new Capsule(proxy, null, centerA, rotation, length, radiusA, radiusB, segments); + } + + /// + /// Creates a white capsule that follows , around two spheres centered on positions in the + /// bone's local space. Its spin around the axis keeps the capsule's +Y as close to the bone's up as it can. + /// + /// Number of straight pieces each ring is drawn with, a positive multiple of 4. + public static ICapsule CreateCapsule( + this WireframeContainer container, + Transform bone, + Vector3 localCenterA, + Vector3 localCenterB, + float radiusA, + float radiusB, + int segments = Ring.DefaultSegments) + { + MeshProxy proxy = WireframeContainer.ProxyOf(container); + Quaternion rotation = ShapeRotations.FromAxis(localCenterB - localCenterA, Vector3.up); + float length = Vector3.Distance(localCenterA, localCenterB); + return new Capsule(proxy, bone, localCenterA, rotation, length, radiusA, radiusB, segments); + } + + /// + /// Creates a white capsule in world space whose first sphere is centered on and + /// whose second sphere is centered along the +Z axis of . + /// + /// Number of straight pieces each ring is drawn with, a positive multiple of 4. + public static ICapsule CreateCapsule( + this WireframeContainer container, + Vector3 position, + Quaternion rotation, + float length, + float radiusA, + float radiusB, + int segments = Ring.DefaultSegments) + { + MeshProxy proxy = WireframeContainer.ProxyOf(container); + return new Capsule(proxy, null, position, rotation, length, radiusA, radiusB, segments); + } + } +} diff --git a/UnityPackage/Runtime/Shapes/Capsule/LineContainer.Capsule.cs.meta b/UnityPackage/Runtime/Shapes/Capsule/CapsuleFactory.cs.meta similarity index 100% rename from UnityPackage/Runtime/Shapes/Capsule/LineContainer.Capsule.cs.meta rename to UnityPackage/Runtime/Shapes/Capsule/CapsuleFactory.cs.meta diff --git a/UnityPackage/Runtime/Shapes/Capsule/ILineContainer.Capsule.cs b/UnityPackage/Runtime/Shapes/Capsule/ILineContainer.Capsule.cs deleted file mode 100644 index 01d0200..0000000 --- a/UnityPackage/Runtime/Shapes/Capsule/ILineContainer.Capsule.cs +++ /dev/null @@ -1,54 +0,0 @@ -using UnityEngine; - -namespace reromanlee.Wireframes -{ - public partial interface ILineContainer - { - /// - /// Creates a white capsule with spheres of radius 0.5 centered on the world origin and 1 unit along +Z. - /// - ICapsule CreateCapsule(); - - /// - /// Creates a white capsule around two spheres of the same radius, centered on two world positions, like the - /// capsule of Physics.CapsuleCast. Its spin around the axis keeps the capsule's +Y as close to world up - /// as it can. - /// - /// Number of straight pieces each ring is drawn with, a positive multiple of 4. - ICapsule CreateCapsule(Vector3 centerA, Vector3 centerB, float radius, int segments = Ring.DefaultSegments); - - /// - /// Creates a white capsule around two spheres centered on two world positions, each with its own radius. Its - /// spin around the axis keeps the capsule's +Y as close to world up as it can. - /// - /// Number of straight pieces each ring is drawn with, a positive multiple of 4. - ICapsule CreateCapsule( - Vector3 centerA, Vector3 centerB, float radiusA, float radiusB, int segments = Ring.DefaultSegments); - - /// - /// Creates a white capsule that follows , around two spheres centered on positions in the - /// bone's local space. Its spin around the axis keeps the capsule's +Y as close to the bone's up as it can. - /// - /// Number of straight pieces each ring is drawn with, a positive multiple of 4. - ICapsule CreateCapsule( - Transform bone, - Vector3 localCenterA, - Vector3 localCenterB, - float radiusA, - float radiusB, - int segments = Ring.DefaultSegments); - - /// - /// Creates a white capsule in world space whose first sphere is centered on and - /// whose second sphere is centered along the +Z axis of . - /// - /// Number of straight pieces each ring is drawn with, a positive multiple of 4. - ICapsule CreateCapsule( - Vector3 position, - Quaternion rotation, - float length, - float radiusA, - float radiusB, - int segments = Ring.DefaultSegments); - } -} diff --git a/UnityPackage/Runtime/Shapes/Capsule/ILineContainer.Capsule.cs.meta b/UnityPackage/Runtime/Shapes/Capsule/ILineContainer.Capsule.cs.meta deleted file mode 100644 index 3656b98..0000000 --- a/UnityPackage/Runtime/Shapes/Capsule/ILineContainer.Capsule.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 95fb1496714de0868333a8c002f372ce \ No newline at end of file diff --git a/UnityPackage/Runtime/Shapes/Capsule/LineContainer.Capsule.cs b/UnityPackage/Runtime/Shapes/Capsule/LineContainer.Capsule.cs deleted file mode 100644 index 72517d8..0000000 --- a/UnityPackage/Runtime/Shapes/Capsule/LineContainer.Capsule.cs +++ /dev/null @@ -1,50 +0,0 @@ -using UnityEngine; - -namespace reromanlee.Wireframes -{ - public sealed partial class LineContainer - { - public ICapsule CreateCapsule() - { - return new Capsule( - Proxy, null, Vector3.zero, Quaternion.identity, UnitSize, UnitRadius, UnitRadius, Ring.DefaultSegments); - } - - public ICapsule CreateCapsule(Vector3 centerA, Vector3 centerB, float radius, int segments = Ring.DefaultSegments) - { - return CreateCapsule(centerA, centerB, radius, radius, segments); - } - - public ICapsule CreateCapsule( - Vector3 centerA, Vector3 centerB, float radiusA, float radiusB, int segments = Ring.DefaultSegments) - { - Quaternion rotation = ShapeRotations.FromAxis(centerB - centerA, Vector3.up); - float length = Vector3.Distance(centerA, centerB); - return new Capsule(Proxy, null, centerA, rotation, length, radiusA, radiusB, segments); - } - - public ICapsule CreateCapsule( - Transform bone, - Vector3 localCenterA, - Vector3 localCenterB, - float radiusA, - float radiusB, - int segments = Ring.DefaultSegments) - { - Quaternion rotation = ShapeRotations.FromAxis(localCenterB - localCenterA, Vector3.up); - float length = Vector3.Distance(localCenterA, localCenterB); - return new Capsule(Proxy, bone, localCenterA, rotation, length, radiusA, radiusB, segments); - } - - public ICapsule CreateCapsule( - Vector3 position, - Quaternion rotation, - float length, - float radiusA, - float radiusB, - int segments = Ring.DefaultSegments) - { - return new Capsule(Proxy, null, position, rotation, length, radiusA, radiusB, segments); - } - } -} diff --git a/UnityPackage/Runtime/Shapes/Circle/CircleFactory.cs b/UnityPackage/Runtime/Shapes/Circle/CircleFactory.cs new file mode 100644 index 0000000..dda8889 --- /dev/null +++ b/UnityPackage/Runtime/Shapes/Circle/CircleFactory.cs @@ -0,0 +1,64 @@ +using UnityEngine; + +namespace reromanlee.Wireframes +{ + /// Creates circles in a . + public static class CircleFactory + { + /// Creates a white circle of radius 0.5 around the world origin, lying flat in the XZ plane. + public static ICircle CreateCircle(this WireframeContainer container) + { + MeshProxy proxy = WireframeContainer.ProxyOf(container); + return new Circle(proxy, null, Vector3.zero, Quaternion.identity, ShapeDefaults.Radius, Ring.DefaultSegments); + } + + /// Creates a white circle around a world position, lying flat in the world's XZ plane. + /// Number of straight pieces the circle is drawn with, at least 3. + public static ICircle CreateCircle( + this WireframeContainer container, Vector3 center, float radius, int segments = Ring.DefaultSegments) + { + return new Circle(WireframeContainer.ProxyOf(container), null, center, Quaternion.identity, radius, segments); + } + + /// + /// Creates a white circle that follows , around a position in the bone's local space + /// and lying flat in the bone's XZ plane. + /// + /// Number of straight pieces the circle is drawn with, at least 3. + public static ICircle CreateCircle( + this WireframeContainer container, + Transform bone, + Vector3 localCenter, + float radius, + int segments = Ring.DefaultSegments) + { + MeshProxy proxy = WireframeContainer.ProxyOf(container); + return new Circle(proxy, bone, localCenter, Quaternion.identity, radius, segments); + } + + /// Creates a white circle in world space, lying in the XZ plane of . + /// Number of straight pieces the circle is drawn with, at least 3. + public static ICircle CreateCircle( + this WireframeContainer container, + Vector3 center, + Quaternion rotation, + float radius, + int segments = Ring.DefaultSegments) + { + return new Circle(WireframeContainer.ProxyOf(container), null, center, rotation, radius, segments); + } + + /// Creates a white circle around a world position, facing . + /// Number of straight pieces the circle is drawn with, at least 3. + public static ICircle CreateCircle( + this WireframeContainer container, + Vector3 center, + Vector3 normal, + float radius, + int segments = Ring.DefaultSegments) + { + MeshProxy proxy = WireframeContainer.ProxyOf(container); + return new Circle(proxy, null, center, ShapeRotations.FromNormal(normal), radius, segments); + } + } +} diff --git a/UnityPackage/Runtime/Shapes/Circle/LineContainer.Circle.cs.meta b/UnityPackage/Runtime/Shapes/Circle/CircleFactory.cs.meta similarity index 100% rename from UnityPackage/Runtime/Shapes/Circle/LineContainer.Circle.cs.meta rename to UnityPackage/Runtime/Shapes/Circle/CircleFactory.cs.meta diff --git a/UnityPackage/Runtime/Shapes/Circle/ILineContainer.Circle.cs b/UnityPackage/Runtime/Shapes/Circle/ILineContainer.Circle.cs deleted file mode 100644 index 8f083bb..0000000 --- a/UnityPackage/Runtime/Shapes/Circle/ILineContainer.Circle.cs +++ /dev/null @@ -1,29 +0,0 @@ -using UnityEngine; - -namespace reromanlee.Wireframes -{ - public partial interface ILineContainer - { - /// Creates a white circle of radius 0.5 around the world origin, lying flat in the XZ plane. - ICircle CreateCircle(); - - /// Creates a white circle around a world position, lying flat in the world's XZ plane. - /// Number of straight pieces the circle is drawn with, at least 3. - ICircle CreateCircle(Vector3 center, float radius, int segments = Ring.DefaultSegments); - - /// - /// Creates a white circle that follows , around a position in the bone's local space - /// and lying flat in the bone's XZ plane. - /// - /// Number of straight pieces the circle is drawn with, at least 3. - ICircle CreateCircle(Transform bone, Vector3 localCenter, float radius, int segments = Ring.DefaultSegments); - - /// Creates a white circle in world space, lying in the XZ plane of . - /// Number of straight pieces the circle is drawn with, at least 3. - ICircle CreateCircle(Vector3 center, Quaternion rotation, float radius, int segments = Ring.DefaultSegments); - - /// Creates a white circle around a world position, facing . - /// Number of straight pieces the circle is drawn with, at least 3. - ICircle CreateCircle(Vector3 center, Vector3 normal, float radius, int segments = Ring.DefaultSegments); - } -} diff --git a/UnityPackage/Runtime/Shapes/Circle/ILineContainer.Circle.cs.meta b/UnityPackage/Runtime/Shapes/Circle/ILineContainer.Circle.cs.meta deleted file mode 100644 index ead2564..0000000 --- a/UnityPackage/Runtime/Shapes/Circle/ILineContainer.Circle.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 9fbd924ae6778a91e32b922e586b1674 \ No newline at end of file diff --git a/UnityPackage/Runtime/Shapes/Circle/LineContainer.Circle.cs b/UnityPackage/Runtime/Shapes/Circle/LineContainer.Circle.cs deleted file mode 100644 index e2461a4..0000000 --- a/UnityPackage/Runtime/Shapes/Circle/LineContainer.Circle.cs +++ /dev/null @@ -1,32 +0,0 @@ -using UnityEngine; - -namespace reromanlee.Wireframes -{ - public sealed partial class LineContainer - { - public ICircle CreateCircle() - { - return new Circle(Proxy, null, Vector3.zero, Quaternion.identity, UnitRadius, Ring.DefaultSegments); - } - - public ICircle CreateCircle(Vector3 center, float radius, int segments = Ring.DefaultSegments) - { - return new Circle(Proxy, null, center, Quaternion.identity, radius, segments); - } - - public ICircle CreateCircle(Transform bone, Vector3 localCenter, float radius, int segments = Ring.DefaultSegments) - { - return new Circle(Proxy, bone, localCenter, Quaternion.identity, radius, segments); - } - - public ICircle CreateCircle(Vector3 center, Quaternion rotation, float radius, int segments = Ring.DefaultSegments) - { - return new Circle(Proxy, null, center, rotation, radius, segments); - } - - public ICircle CreateCircle(Vector3 center, Vector3 normal, float radius, int segments = Ring.DefaultSegments) - { - return new Circle(Proxy, null, center, ShapeRotations.FromNormal(normal), radius, segments); - } - } -} diff --git a/UnityPackage/Runtime/Shapes/Cone/ConeFactory.cs b/UnityPackage/Runtime/Shapes/Cone/ConeFactory.cs new file mode 100644 index 0000000..a84eb02 --- /dev/null +++ b/UnityPackage/Runtime/Shapes/Cone/ConeFactory.cs @@ -0,0 +1,75 @@ +using UnityEngine; + +namespace reromanlee.Wireframes +{ + /// Creates cones in a . + public static class ConeFactory + { + /// Creates a white cone with its tip at the world origin and a base of radius 0.5, 1 unit along +Z. + public static ICone CreateCone(this WireframeContainer container) + { + MeshProxy proxy = WireframeContainer.ProxyOf(container); + return new Cone( + proxy, + null, + Vector3.zero, + Quaternion.identity, + ShapeDefaults.Size, + ShapeDefaults.Radius, + Ring.DefaultSegments); + } + + /// + /// Creates a white cone from a tip to the center of its base, both world positions. Its spin around the axis + /// keeps the cone's +Y as close to world up as it can. + /// + /// Number of straight pieces the base ring is drawn with, a positive multiple of 4. + public static ICone CreateCone( + this WireframeContainer container, + Vector3 tip, + Vector3 baseCenter, + float radius, + int segments = Ring.DefaultSegments) + { + MeshProxy proxy = WireframeContainer.ProxyOf(container); + Quaternion rotation = ShapeRotations.FromAxis(baseCenter - tip, Vector3.up); + return new Cone(proxy, null, tip, rotation, Vector3.Distance(tip, baseCenter), radius, segments); + } + + /// + /// Creates a white cone that follows , from a tip to the center of its base, both in the + /// bone's local space. Its spin around the axis keeps the cone's +Y as close to the bone's up as it can. + /// + /// Number of straight pieces the base ring is drawn with, a positive multiple of 4. + public static ICone CreateCone( + this WireframeContainer container, + Transform bone, + Vector3 localTip, + Vector3 localBaseCenter, + float radius, + int segments = Ring.DefaultSegments) + { + MeshProxy proxy = WireframeContainer.ProxyOf(container); + Quaternion rotation = ShapeRotations.FromAxis(localBaseCenter - localTip, Vector3.up); + float length = Vector3.Distance(localTip, localBaseCenter); + return new Cone(proxy, bone, localTip, rotation, length, radius, segments); + } + + /// + /// Creates a white cone in world space with its tip at and its base + /// along the +Z axis of . + /// + /// Number of straight pieces the base ring is drawn with, a positive multiple of 4. + public static ICone CreateCone( + this WireframeContainer container, + Vector3 position, + Quaternion rotation, + float length, + float radius, + int segments = Ring.DefaultSegments) + { + MeshProxy proxy = WireframeContainer.ProxyOf(container); + return new Cone(proxy, null, position, rotation, length, radius, segments); + } + } +} diff --git a/UnityPackage/Runtime/Shapes/Cone/LineContainer.Cone.cs.meta b/UnityPackage/Runtime/Shapes/Cone/ConeFactory.cs.meta similarity index 100% rename from UnityPackage/Runtime/Shapes/Cone/LineContainer.Cone.cs.meta rename to UnityPackage/Runtime/Shapes/Cone/ConeFactory.cs.meta diff --git a/UnityPackage/Runtime/Shapes/Cone/ILineContainer.Cone.cs b/UnityPackage/Runtime/Shapes/Cone/ILineContainer.Cone.cs deleted file mode 100644 index cb33597..0000000 --- a/UnityPackage/Runtime/Shapes/Cone/ILineContainer.Cone.cs +++ /dev/null @@ -1,33 +0,0 @@ -using UnityEngine; - -namespace reromanlee.Wireframes -{ - public partial interface ILineContainer - { - /// Creates a white cone with its tip at the world origin and a base of radius 0.5, 1 unit along +Z. - ICone CreateCone(); - - /// - /// Creates a white cone from a tip to the center of its base, both world positions. Its spin around the axis - /// keeps the cone's +Y as close to world up as it can. - /// - /// Number of straight pieces the base ring is drawn with, a positive multiple of 4. - ICone CreateCone(Vector3 tip, Vector3 baseCenter, float radius, int segments = Ring.DefaultSegments); - - /// - /// Creates a white cone that follows , from a tip to the center of its base, both in the - /// bone's local space. Its spin around the axis keeps the cone's +Y as close to the bone's up as it can. - /// - /// Number of straight pieces the base ring is drawn with, a positive multiple of 4. - ICone CreateCone( - Transform bone, Vector3 localTip, Vector3 localBaseCenter, float radius, int segments = Ring.DefaultSegments); - - /// - /// Creates a white cone in world space with its tip at and its base - /// along the +Z axis of . - /// - /// Number of straight pieces the base ring is drawn with, a positive multiple of 4. - ICone CreateCone( - Vector3 position, Quaternion rotation, float length, float radius, int segments = Ring.DefaultSegments); - } -} diff --git a/UnityPackage/Runtime/Shapes/Cone/ILineContainer.Cone.cs.meta b/UnityPackage/Runtime/Shapes/Cone/ILineContainer.Cone.cs.meta deleted file mode 100644 index 644018f..0000000 --- a/UnityPackage/Runtime/Shapes/Cone/ILineContainer.Cone.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 1d518634536946723ec2ea5c8aeeb67b \ No newline at end of file diff --git a/UnityPackage/Runtime/Shapes/Cone/LineContainer.Cone.cs b/UnityPackage/Runtime/Shapes/Cone/LineContainer.Cone.cs deleted file mode 100644 index 26f7fb8..0000000 --- a/UnityPackage/Runtime/Shapes/Cone/LineContainer.Cone.cs +++ /dev/null @@ -1,32 +0,0 @@ -using UnityEngine; - -namespace reromanlee.Wireframes -{ - public sealed partial class LineContainer - { - public ICone CreateCone() - { - return new Cone(Proxy, null, Vector3.zero, Quaternion.identity, UnitSize, UnitRadius, Ring.DefaultSegments); - } - - public ICone CreateCone(Vector3 tip, Vector3 baseCenter, float radius, int segments = Ring.DefaultSegments) - { - Quaternion rotation = ShapeRotations.FromAxis(baseCenter - tip, Vector3.up); - return new Cone(Proxy, null, tip, rotation, Vector3.Distance(tip, baseCenter), radius, segments); - } - - public ICone CreateCone( - Transform bone, Vector3 localTip, Vector3 localBaseCenter, float radius, int segments = Ring.DefaultSegments) - { - Quaternion rotation = ShapeRotations.FromAxis(localBaseCenter - localTip, Vector3.up); - float length = Vector3.Distance(localTip, localBaseCenter); - return new Cone(Proxy, bone, localTip, rotation, length, radius, segments); - } - - public ICone CreateCone( - Vector3 position, Quaternion rotation, float length, float radius, int segments = Ring.DefaultSegments) - { - return new Cone(Proxy, null, position, rotation, length, radius, segments); - } - } -} diff --git a/UnityPackage/Runtime/Shapes/Cylinder/CylinderFactory.cs b/UnityPackage/Runtime/Shapes/Cylinder/CylinderFactory.cs new file mode 100644 index 0000000..db65858 --- /dev/null +++ b/UnityPackage/Runtime/Shapes/Cylinder/CylinderFactory.cs @@ -0,0 +1,75 @@ +using UnityEngine; + +namespace reromanlee.Wireframes +{ + /// Creates cylinders in a . + public static class CylinderFactory + { + /// Creates a white cylinder of radius 0.5 from the world origin, 1 unit along +Z. + public static ICylinder CreateCylinder(this WireframeContainer container) + { + MeshProxy proxy = WireframeContainer.ProxyOf(container); + return new Cylinder( + proxy, + null, + Vector3.zero, + Quaternion.identity, + ShapeDefaults.Size, + ShapeDefaults.Radius, + Ring.DefaultSegments); + } + + /// + /// Creates a white cylinder between two world positions. Its spin around the axis keeps the cylinder's +Y as + /// close to world up as it can. + /// + /// Number of straight pieces each ring is drawn with, a positive multiple of 4. + public static ICylinder CreateCylinder( + this WireframeContainer container, + Vector3 endA, + Vector3 endB, + float radius, + int segments = Ring.DefaultSegments) + { + MeshProxy proxy = WireframeContainer.ProxyOf(container); + Quaternion rotation = ShapeRotations.FromAxis(endB - endA, Vector3.up); + return new Cylinder(proxy, null, endA, rotation, Vector3.Distance(endA, endB), radius, segments); + } + + /// + /// Creates a white cylinder that follows , between two positions in the bone's local + /// space. Its spin around the axis keeps the cylinder's +Y as close to the bone's up as it can. + /// + /// Number of straight pieces each ring is drawn with, a positive multiple of 4. + public static ICylinder CreateCylinder( + this WireframeContainer container, + Transform bone, + Vector3 localEndA, + Vector3 localEndB, + float radius, + int segments = Ring.DefaultSegments) + { + MeshProxy proxy = WireframeContainer.ProxyOf(container); + Quaternion rotation = ShapeRotations.FromAxis(localEndB - localEndA, Vector3.up); + float length = Vector3.Distance(localEndA, localEndB); + return new Cylinder(proxy, bone, localEndA, rotation, length, radius, segments); + } + + /// + /// Creates a white cylinder in world space that starts at and runs + /// along the +Z axis of . + /// + /// Number of straight pieces each ring is drawn with, a positive multiple of 4. + public static ICylinder CreateCylinder( + this WireframeContainer container, + Vector3 position, + Quaternion rotation, + float length, + float radius, + int segments = Ring.DefaultSegments) + { + MeshProxy proxy = WireframeContainer.ProxyOf(container); + return new Cylinder(proxy, null, position, rotation, length, radius, segments); + } + } +} diff --git a/UnityPackage/Runtime/Shapes/Cylinder/LineContainer.Cylinder.cs.meta b/UnityPackage/Runtime/Shapes/Cylinder/CylinderFactory.cs.meta similarity index 100% rename from UnityPackage/Runtime/Shapes/Cylinder/LineContainer.Cylinder.cs.meta rename to UnityPackage/Runtime/Shapes/Cylinder/CylinderFactory.cs.meta diff --git a/UnityPackage/Runtime/Shapes/Cylinder/ILineContainer.Cylinder.cs b/UnityPackage/Runtime/Shapes/Cylinder/ILineContainer.Cylinder.cs deleted file mode 100644 index 2e4d85b..0000000 --- a/UnityPackage/Runtime/Shapes/Cylinder/ILineContainer.Cylinder.cs +++ /dev/null @@ -1,33 +0,0 @@ -using UnityEngine; - -namespace reromanlee.Wireframes -{ - public partial interface ILineContainer - { - /// Creates a white cylinder of radius 0.5 from the world origin, 1 unit along +Z. - ICylinder CreateCylinder(); - - /// - /// Creates a white cylinder between two world positions. Its spin around the axis keeps the cylinder's +Y as - /// close to world up as it can. - /// - /// Number of straight pieces each ring is drawn with, a positive multiple of 4. - ICylinder CreateCylinder(Vector3 endA, Vector3 endB, float radius, int segments = Ring.DefaultSegments); - - /// - /// Creates a white cylinder that follows , between two positions in the bone's local - /// space. Its spin around the axis keeps the cylinder's +Y as close to the bone's up as it can. - /// - /// Number of straight pieces each ring is drawn with, a positive multiple of 4. - ICylinder CreateCylinder( - Transform bone, Vector3 localEndA, Vector3 localEndB, float radius, int segments = Ring.DefaultSegments); - - /// - /// Creates a white cylinder in world space that starts at and runs - /// along the +Z axis of . - /// - /// Number of straight pieces each ring is drawn with, a positive multiple of 4. - ICylinder CreateCylinder( - Vector3 position, Quaternion rotation, float length, float radius, int segments = Ring.DefaultSegments); - } -} diff --git a/UnityPackage/Runtime/Shapes/Cylinder/ILineContainer.Cylinder.cs.meta b/UnityPackage/Runtime/Shapes/Cylinder/ILineContainer.Cylinder.cs.meta deleted file mode 100644 index 8e7edd5..0000000 --- a/UnityPackage/Runtime/Shapes/Cylinder/ILineContainer.Cylinder.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 6fc91468e22e591f8418e1e1464de003 \ No newline at end of file diff --git a/UnityPackage/Runtime/Shapes/Cylinder/LineContainer.Cylinder.cs b/UnityPackage/Runtime/Shapes/Cylinder/LineContainer.Cylinder.cs deleted file mode 100644 index fca950c..0000000 --- a/UnityPackage/Runtime/Shapes/Cylinder/LineContainer.Cylinder.cs +++ /dev/null @@ -1,31 +0,0 @@ -using UnityEngine; - -namespace reromanlee.Wireframes -{ - public sealed partial class LineContainer - { - public ICylinder CreateCylinder() - { - return new Cylinder(Proxy, null, Vector3.zero, Quaternion.identity, UnitSize, UnitRadius, Ring.DefaultSegments); - } - - public ICylinder CreateCylinder(Vector3 endA, Vector3 endB, float radius, int segments = Ring.DefaultSegments) - { - Quaternion rotation = ShapeRotations.FromAxis(endB - endA, Vector3.up); - return new Cylinder(Proxy, null, endA, rotation, Vector3.Distance(endA, endB), radius, segments); - } - - public ICylinder CreateCylinder( - Transform bone, Vector3 localEndA, Vector3 localEndB, float radius, int segments = Ring.DefaultSegments) - { - Quaternion rotation = ShapeRotations.FromAxis(localEndB - localEndA, Vector3.up); - return new Cylinder(Proxy, bone, localEndA, rotation, Vector3.Distance(localEndA, localEndB), radius, segments); - } - - public ICylinder CreateCylinder( - Vector3 position, Quaternion rotation, float length, float radius, int segments = Ring.DefaultSegments) - { - return new Cylinder(Proxy, null, position, rotation, length, radius, segments); - } - } -} diff --git a/UnityPackage/Runtime/Shapes/Ellipse/EllipseFactory.cs b/UnityPackage/Runtime/Shapes/Ellipse/EllipseFactory.cs new file mode 100644 index 0000000..378ef8d --- /dev/null +++ b/UnityPackage/Runtime/Shapes/Ellipse/EllipseFactory.cs @@ -0,0 +1,85 @@ +using UnityEngine; + +namespace reromanlee.Wireframes +{ + /// Creates ellipses in a . + public static class EllipseFactory + { + /// + /// Creates a white ellipse around the world origin, lying flat in the XZ plane, 1 unit long along Z and + /// 0.5 wide along X. + /// + public static IEllipse CreateEllipse(this WireframeContainer container) + { + MeshProxy proxy = WireframeContainer.ProxyOf(container); + Vector2 radii = new(ShapeDefaults.Radius * 0.5f, ShapeDefaults.Radius); + return new Ellipse(proxy, null, Vector3.zero, Quaternion.identity, radii, Ring.DefaultSegments); + } + + /// + /// Creates a white ellipse whose long axis runs between two world positions, with as + /// its other semi-axis. It lies as flat as it can: its normal stays as close to world up as the tips allow. + /// + /// Number of straight pieces the ellipse is drawn with, at least 3. + public static IEllipse CreateEllipse( + this WireframeContainer container, + Vector3 tipA, + Vector3 tipB, + float radius, + int segments = Ring.DefaultSegments) + { + return container.CreateEllipse(tipA, tipB, radius, Vector3.up, segments); + } + + /// + /// Creates a white ellipse whose long axis runs between two world positions, with as + /// its other semi-axis, facing as close to as the tips allow. + /// + /// Number of straight pieces the ellipse is drawn with, at least 3. + public static IEllipse CreateEllipse( + this WireframeContainer container, + Vector3 tipA, + Vector3 tipB, + float radius, + Vector3 normal, + int segments = Ring.DefaultSegments) + { + MeshProxy proxy = WireframeContainer.ProxyOf(container); + Quaternion rotation = ShapeRotations.FromAxis(tipB - tipA, normal); + Vector2 radii = new(radius, Vector3.Distance(tipA, tipB) * 0.5f); + return new Ellipse(proxy, null, (tipA + tipB) * 0.5f, rotation, radii, segments); + } + + /// + /// Creates a white ellipse that follows , whose long axis runs between two positions in + /// the bone's local space. It lies as flat in the bone's XZ plane as the tips allow. + /// + /// Number of straight pieces the ellipse is drawn with, at least 3. + public static IEllipse CreateEllipse( + this WireframeContainer container, + Transform bone, + Vector3 localTipA, + Vector3 localTipB, + float radius, + int segments = Ring.DefaultSegments) + { + MeshProxy proxy = WireframeContainer.ProxyOf(container); + Quaternion rotation = ShapeRotations.FromAxis(localTipB - localTipA, Vector3.up); + Vector2 radii = new(radius, Vector3.Distance(localTipA, localTipB) * 0.5f); + return new Ellipse(proxy, bone, (localTipA + localTipB) * 0.5f, rotation, radii, segments); + } + + /// Creates a white ellipse in world space, lying in the XZ plane of . + /// Semi-axes: x along the rotation's X axis and y along its Z axis. + /// Number of straight pieces the ellipse is drawn with, at least 3. + public static IEllipse CreateEllipse( + this WireframeContainer container, + Vector3 center, + Quaternion rotation, + Vector2 radii, + int segments = Ring.DefaultSegments) + { + return new Ellipse(WireframeContainer.ProxyOf(container), null, center, rotation, radii, segments); + } + } +} diff --git a/UnityPackage/Runtime/Shapes/Ellipse/LineContainer.Ellipse.cs.meta b/UnityPackage/Runtime/Shapes/Ellipse/EllipseFactory.cs.meta similarity index 100% rename from UnityPackage/Runtime/Shapes/Ellipse/LineContainer.Ellipse.cs.meta rename to UnityPackage/Runtime/Shapes/Ellipse/EllipseFactory.cs.meta diff --git a/UnityPackage/Runtime/Shapes/Ellipse/ILineContainer.Ellipse.cs b/UnityPackage/Runtime/Shapes/Ellipse/ILineContainer.Ellipse.cs deleted file mode 100644 index 8c2c551..0000000 --- a/UnityPackage/Runtime/Shapes/Ellipse/ILineContainer.Ellipse.cs +++ /dev/null @@ -1,41 +0,0 @@ -using UnityEngine; - -namespace reromanlee.Wireframes -{ - public partial interface ILineContainer - { - /// - /// Creates a white ellipse around the world origin, lying flat in the XZ plane, 1 unit long along Z and - /// 0.5 wide along X. - /// - IEllipse CreateEllipse(); - - /// - /// Creates a white ellipse whose long axis runs between two world positions, with as - /// its other semi-axis. It lies as flat as it can: its normal stays as close to world up as the tips allow. - /// - /// Number of straight pieces the ellipse is drawn with, at least 3. - IEllipse CreateEllipse(Vector3 tipA, Vector3 tipB, float radius, int segments = Ring.DefaultSegments); - - /// - /// Creates a white ellipse whose long axis runs between two world positions, with as - /// its other semi-axis, facing as close to as the tips allow. - /// - /// Number of straight pieces the ellipse is drawn with, at least 3. - IEllipse CreateEllipse( - Vector3 tipA, Vector3 tipB, float radius, Vector3 normal, int segments = Ring.DefaultSegments); - - /// - /// Creates a white ellipse that follows , whose long axis runs between two positions in - /// the bone's local space. It lies as flat in the bone's XZ plane as the tips allow. - /// - /// Number of straight pieces the ellipse is drawn with, at least 3. - IEllipse CreateEllipse( - Transform bone, Vector3 localTipA, Vector3 localTipB, float radius, int segments = Ring.DefaultSegments); - - /// Creates a white ellipse in world space, lying in the XZ plane of . - /// Semi-axes: x along the rotation's X axis and y along its Z axis. - /// Number of straight pieces the ellipse is drawn with, at least 3. - IEllipse CreateEllipse(Vector3 center, Quaternion rotation, Vector2 radii, int segments = Ring.DefaultSegments); - } -} diff --git a/UnityPackage/Runtime/Shapes/Ellipse/ILineContainer.Ellipse.cs.meta b/UnityPackage/Runtime/Shapes/Ellipse/ILineContainer.Ellipse.cs.meta deleted file mode 100644 index bd0808f..0000000 --- a/UnityPackage/Runtime/Shapes/Ellipse/ILineContainer.Ellipse.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 5bc9d54e1da3471123cfc10067fd2021 \ No newline at end of file diff --git a/UnityPackage/Runtime/Shapes/Ellipse/LineContainer.Ellipse.cs b/UnityPackage/Runtime/Shapes/Ellipse/LineContainer.Ellipse.cs deleted file mode 100644 index 0e78362..0000000 --- a/UnityPackage/Runtime/Shapes/Ellipse/LineContainer.Ellipse.cs +++ /dev/null @@ -1,39 +0,0 @@ -using UnityEngine; - -namespace reromanlee.Wireframes -{ - public sealed partial class LineContainer - { - public IEllipse CreateEllipse() - { - Vector2 radii = new(UnitRadius * 0.5f, UnitRadius); - return new Ellipse(Proxy, null, Vector3.zero, Quaternion.identity, radii, Ring.DefaultSegments); - } - - public IEllipse CreateEllipse(Vector3 tipA, Vector3 tipB, float radius, int segments = Ring.DefaultSegments) - { - return CreateEllipse(tipA, tipB, radius, Vector3.up, segments); - } - - public IEllipse CreateEllipse( - Vector3 tipA, Vector3 tipB, float radius, Vector3 normal, int segments = Ring.DefaultSegments) - { - Quaternion rotation = ShapeRotations.FromAxis(tipB - tipA, normal); - Vector2 radii = new(radius, Vector3.Distance(tipA, tipB) * 0.5f); - return new Ellipse(Proxy, null, (tipA + tipB) * 0.5f, rotation, radii, segments); - } - - public IEllipse CreateEllipse( - Transform bone, Vector3 localTipA, Vector3 localTipB, float radius, int segments = Ring.DefaultSegments) - { - Quaternion rotation = ShapeRotations.FromAxis(localTipB - localTipA, Vector3.up); - Vector2 radii = new(radius, Vector3.Distance(localTipA, localTipB) * 0.5f); - return new Ellipse(Proxy, bone, (localTipA + localTipB) * 0.5f, rotation, radii, segments); - } - - public IEllipse CreateEllipse(Vector3 center, Quaternion rotation, Vector2 radii, int segments = Ring.DefaultSegments) - { - return new Ellipse(Proxy, null, center, rotation, radii, segments); - } - } -} diff --git a/UnityPackage/Runtime/Shapes/Ellipsoid/EllipsoidFactory.cs b/UnityPackage/Runtime/Shapes/Ellipsoid/EllipsoidFactory.cs new file mode 100644 index 0000000..3003e4e --- /dev/null +++ b/UnityPackage/Runtime/Shapes/Ellipsoid/EllipsoidFactory.cs @@ -0,0 +1,68 @@ +using UnityEngine; + +namespace reromanlee.Wireframes +{ + /// Creates ellipsoids in a . + public static class EllipsoidFactory + { + /// + /// Creates a white ellipsoid around the world origin, 1 unit long along Z and 0.5 wide along X and Y. + /// + public static IEllipsoid CreateEllipsoid(this WireframeContainer container) + { + MeshProxy proxy = WireframeContainer.ProxyOf(container); + Vector3 radii = new(ShapeDefaults.Radius * 0.5f, ShapeDefaults.Radius * 0.5f, ShapeDefaults.Radius); + return new Ellipsoid(proxy, null, Vector3.zero, Quaternion.identity, radii, Ring.DefaultSegments); + } + + /// + /// Creates a white ellipsoid whose long axis runs between two world positions, with + /// as its other two semi-axes. Its +Y stays as close to world up as the tips allow. + /// + /// Number of straight pieces each ellipse is drawn with, at least 3. + public static IEllipsoid CreateEllipsoid( + this WireframeContainer container, + Vector3 tipA, + Vector3 tipB, + float radius, + int segments = Ring.DefaultSegments) + { + MeshProxy proxy = WireframeContainer.ProxyOf(container); + Quaternion rotation = ShapeRotations.FromAxis(tipB - tipA, Vector3.up); + Vector3 radii = new(radius, radius, Vector3.Distance(tipA, tipB) * 0.5f); + return new Ellipsoid(proxy, null, (tipA + tipB) * 0.5f, rotation, radii, segments); + } + + /// + /// Creates a white ellipsoid that follows , whose long axis runs between two positions in + /// the bone's local space, with as its other two semi-axes. + /// + /// Number of straight pieces each ellipse is drawn with, at least 3. + public static IEllipsoid CreateEllipsoid( + this WireframeContainer container, + Transform bone, + Vector3 localTipA, + Vector3 localTipB, + float radius, + int segments = Ring.DefaultSegments) + { + MeshProxy proxy = WireframeContainer.ProxyOf(container); + Quaternion rotation = ShapeRotations.FromAxis(localTipB - localTipA, Vector3.up); + Vector3 radii = new(radius, radius, Vector3.Distance(localTipA, localTipB) * 0.5f); + return new Ellipsoid(proxy, bone, (localTipA + localTipB) * 0.5f, rotation, radii, segments); + } + + /// Creates a white ellipsoid in world space, turned by . + /// Semi-axes along the rotation's X, Y and Z axes. + /// Number of straight pieces each ellipse is drawn with, at least 3. + public static IEllipsoid CreateEllipsoid( + this WireframeContainer container, + Vector3 center, + Quaternion rotation, + Vector3 radii, + int segments = Ring.DefaultSegments) + { + return new Ellipsoid(WireframeContainer.ProxyOf(container), null, center, rotation, radii, segments); + } + } +} diff --git a/UnityPackage/Runtime/Shapes/Ellipsoid/LineContainer.Ellipsoid.cs.meta b/UnityPackage/Runtime/Shapes/Ellipsoid/EllipsoidFactory.cs.meta similarity index 100% rename from UnityPackage/Runtime/Shapes/Ellipsoid/LineContainer.Ellipsoid.cs.meta rename to UnityPackage/Runtime/Shapes/Ellipsoid/EllipsoidFactory.cs.meta diff --git a/UnityPackage/Runtime/Shapes/Ellipsoid/ILineContainer.Ellipsoid.cs b/UnityPackage/Runtime/Shapes/Ellipsoid/ILineContainer.Ellipsoid.cs deleted file mode 100644 index af637c4..0000000 --- a/UnityPackage/Runtime/Shapes/Ellipsoid/ILineContainer.Ellipsoid.cs +++ /dev/null @@ -1,33 +0,0 @@ -using UnityEngine; - -namespace reromanlee.Wireframes -{ - public partial interface ILineContainer - { - /// - /// Creates a white ellipsoid around the world origin, 1 unit long along Z and 0.5 wide along X and Y. - /// - IEllipsoid CreateEllipsoid(); - - /// - /// Creates a white ellipsoid whose long axis runs between two world positions, with - /// as its other two semi-axes. Its +Y stays as close to world up as the tips allow. - /// - /// Number of straight pieces each ellipse is drawn with, at least 3. - IEllipsoid CreateEllipsoid(Vector3 tipA, Vector3 tipB, float radius, int segments = Ring.DefaultSegments); - - /// - /// Creates a white ellipsoid that follows , whose long axis runs between two positions in - /// the bone's local space, with as its other two semi-axes. - /// - /// Number of straight pieces each ellipse is drawn with, at least 3. - IEllipsoid CreateEllipsoid( - Transform bone, Vector3 localTipA, Vector3 localTipB, float radius, int segments = Ring.DefaultSegments); - - /// Creates a white ellipsoid in world space, turned by . - /// Semi-axes along the rotation's X, Y and Z axes. - /// Number of straight pieces each ellipse is drawn with, at least 3. - IEllipsoid CreateEllipsoid( - Vector3 center, Quaternion rotation, Vector3 radii, int segments = Ring.DefaultSegments); - } -} diff --git a/UnityPackage/Runtime/Shapes/Ellipsoid/ILineContainer.Ellipsoid.cs.meta b/UnityPackage/Runtime/Shapes/Ellipsoid/ILineContainer.Ellipsoid.cs.meta deleted file mode 100644 index e4a82bb..0000000 --- a/UnityPackage/Runtime/Shapes/Ellipsoid/ILineContainer.Ellipsoid.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 8447c528bb4f95bc415e723288e921f2 \ No newline at end of file diff --git a/UnityPackage/Runtime/Shapes/Ellipsoid/LineContainer.Ellipsoid.cs b/UnityPackage/Runtime/Shapes/Ellipsoid/LineContainer.Ellipsoid.cs deleted file mode 100644 index 976b3ad..0000000 --- a/UnityPackage/Runtime/Shapes/Ellipsoid/LineContainer.Ellipsoid.cs +++ /dev/null @@ -1,34 +0,0 @@ -using UnityEngine; - -namespace reromanlee.Wireframes -{ - public sealed partial class LineContainer - { - public IEllipsoid CreateEllipsoid() - { - Vector3 radii = new(UnitRadius * 0.5f, UnitRadius * 0.5f, UnitRadius); - return new Ellipsoid(Proxy, null, Vector3.zero, Quaternion.identity, radii, Ring.DefaultSegments); - } - - public IEllipsoid CreateEllipsoid(Vector3 tipA, Vector3 tipB, float radius, int segments = Ring.DefaultSegments) - { - Quaternion rotation = ShapeRotations.FromAxis(tipB - tipA, Vector3.up); - Vector3 radii = new(radius, radius, Vector3.Distance(tipA, tipB) * 0.5f); - return new Ellipsoid(Proxy, null, (tipA + tipB) * 0.5f, rotation, radii, segments); - } - - public IEllipsoid CreateEllipsoid( - Transform bone, Vector3 localTipA, Vector3 localTipB, float radius, int segments = Ring.DefaultSegments) - { - Quaternion rotation = ShapeRotations.FromAxis(localTipB - localTipA, Vector3.up); - Vector3 radii = new(radius, radius, Vector3.Distance(localTipA, localTipB) * 0.5f); - return new Ellipsoid(Proxy, bone, (localTipA + localTipB) * 0.5f, rotation, radii, segments); - } - - public IEllipsoid CreateEllipsoid( - Vector3 center, Quaternion rotation, Vector3 radii, int segments = Ring.DefaultSegments) - { - return new Ellipsoid(Proxy, null, center, rotation, radii, segments); - } - } -} diff --git a/UnityPackage/Runtime/Shapes/Frustum/FrustumFactory.cs b/UnityPackage/Runtime/Shapes/Frustum/FrustumFactory.cs new file mode 100644 index 0000000..ed7d7ec --- /dev/null +++ b/UnityPackage/Runtime/Shapes/Frustum/FrustumFactory.cs @@ -0,0 +1,79 @@ +using UnityEngine; + +namespace reromanlee.Wireframes +{ + /// Creates frustums, prisms and regular pyramids in a . + public static class FrustumFactory + { + private const int DefaultSides = 4; + + /// + /// Creates a white square frustum from the world origin, 1 unit along +Z, with corners 0.25 out at end A and + /// 0.5 out at end B. + /// + public static IFrustum CreateFrustum(this WireframeContainer container) + { + MeshProxy proxy = WireframeContainer.ProxyOf(container); + return new Frustum( + proxy, + null, + Vector3.zero, + Quaternion.identity, + ShapeDefaults.Size, + ShapeDefaults.Radius * 0.5f, + ShapeDefaults.Radius, + DefaultSides); + } + + /// + /// Creates a white frustum between the centers of its two polygons, both world positions. Its spin around the + /// axis keeps the frustum's +Y as close to world up as it can. + /// + /// Number of sides of each polygon, at least 3. + public static IFrustum CreateFrustum( + this WireframeContainer container, Vector3 endA, Vector3 endB, float radiusA, float radiusB, int sides) + { + MeshProxy proxy = WireframeContainer.ProxyOf(container); + Quaternion rotation = ShapeRotations.FromAxis(endB - endA, Vector3.up); + return new Frustum(proxy, null, endA, rotation, Vector3.Distance(endA, endB), radiusA, radiusB, sides); + } + + /// + /// Creates a white frustum that follows , between the centers of its two polygons in the + /// bone's local space. Its spin around the axis keeps the frustum's +Y as close to the bone's up as it can. + /// + /// Number of sides of each polygon, at least 3. + public static IFrustum CreateFrustum( + this WireframeContainer container, + Transform bone, + Vector3 localEndA, + Vector3 localEndB, + float radiusA, + float radiusB, + int sides) + { + MeshProxy proxy = WireframeContainer.ProxyOf(container); + Quaternion rotation = ShapeRotations.FromAxis(localEndB - localEndA, Vector3.up); + float length = Vector3.Distance(localEndA, localEndB); + return new Frustum(proxy, bone, localEndA, rotation, length, radiusA, radiusB, sides); + } + + /// + /// Creates a white frustum in world space that starts at and runs + /// along the +Z axis of . + /// + /// Number of sides of each polygon, at least 3. + public static IFrustum CreateFrustum( + this WireframeContainer container, + Vector3 position, + Quaternion rotation, + float length, + float radiusA, + float radiusB, + int sides) + { + MeshProxy proxy = WireframeContainer.ProxyOf(container); + return new Frustum(proxy, null, position, rotation, length, radiusA, radiusB, sides); + } + } +} diff --git a/UnityPackage/Runtime/Shapes/Frustum/LineContainer.Frustum.cs.meta b/UnityPackage/Runtime/Shapes/Frustum/FrustumFactory.cs.meta similarity index 100% rename from UnityPackage/Runtime/Shapes/Frustum/LineContainer.Frustum.cs.meta rename to UnityPackage/Runtime/Shapes/Frustum/FrustumFactory.cs.meta diff --git a/UnityPackage/Runtime/Shapes/Frustum/ILineContainer.Frustum.cs b/UnityPackage/Runtime/Shapes/Frustum/ILineContainer.Frustum.cs deleted file mode 100644 index d9dac8d..0000000 --- a/UnityPackage/Runtime/Shapes/Frustum/ILineContainer.Frustum.cs +++ /dev/null @@ -1,36 +0,0 @@ -using UnityEngine; - -namespace reromanlee.Wireframes -{ - public partial interface ILineContainer - { - /// - /// Creates a white square frustum from the world origin, 1 unit along +Z, with corners 0.25 out at end A and - /// 0.5 out at end B. - /// - IFrustum CreateFrustum(); - - /// - /// Creates a white frustum between the centers of its two polygons, both world positions. Its spin around the - /// axis keeps the frustum's +Y as close to world up as it can. - /// - /// Number of sides of each polygon, at least 3. - IFrustum CreateFrustum(Vector3 endA, Vector3 endB, float radiusA, float radiusB, int sides); - - /// - /// Creates a white frustum that follows , between the centers of its two polygons in the - /// bone's local space. Its spin around the axis keeps the frustum's +Y as close to the bone's up as it can. - /// - /// Number of sides of each polygon, at least 3. - IFrustum CreateFrustum( - Transform bone, Vector3 localEndA, Vector3 localEndB, float radiusA, float radiusB, int sides); - - /// - /// Creates a white frustum in world space that starts at and runs - /// along the +Z axis of . - /// - /// Number of sides of each polygon, at least 3. - IFrustum CreateFrustum( - Vector3 position, Quaternion rotation, float length, float radiusA, float radiusB, int sides); - } -} diff --git a/UnityPackage/Runtime/Shapes/Frustum/ILineContainer.Frustum.cs.meta b/UnityPackage/Runtime/Shapes/Frustum/ILineContainer.Frustum.cs.meta deleted file mode 100644 index 402898f..0000000 --- a/UnityPackage/Runtime/Shapes/Frustum/ILineContainer.Frustum.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 35d2821e8858609efb4860ecf6e49966 \ No newline at end of file diff --git a/UnityPackage/Runtime/Shapes/Frustum/LineContainer.Frustum.cs b/UnityPackage/Runtime/Shapes/Frustum/LineContainer.Frustum.cs deleted file mode 100644 index adaee0b..0000000 --- a/UnityPackage/Runtime/Shapes/Frustum/LineContainer.Frustum.cs +++ /dev/null @@ -1,35 +0,0 @@ -using UnityEngine; - -namespace reromanlee.Wireframes -{ - public sealed partial class LineContainer - { - private const int UnitFrustumSides = 4; - - public IFrustum CreateFrustum() - { - return new Frustum( - Proxy, null, Vector3.zero, Quaternion.identity, UnitSize, UnitRadius * 0.5f, UnitRadius, UnitFrustumSides); - } - - public IFrustum CreateFrustum(Vector3 endA, Vector3 endB, float radiusA, float radiusB, int sides) - { - Quaternion rotation = ShapeRotations.FromAxis(endB - endA, Vector3.up); - return new Frustum(Proxy, null, endA, rotation, Vector3.Distance(endA, endB), radiusA, radiusB, sides); - } - - public IFrustum CreateFrustum( - Transform bone, Vector3 localEndA, Vector3 localEndB, float radiusA, float radiusB, int sides) - { - Quaternion rotation = ShapeRotations.FromAxis(localEndB - localEndA, Vector3.up); - float length = Vector3.Distance(localEndA, localEndB); - return new Frustum(Proxy, bone, localEndA, rotation, length, radiusA, radiusB, sides); - } - - public IFrustum CreateFrustum( - Vector3 position, Quaternion rotation, float length, float radiusA, float radiusB, int sides) - { - return new Frustum(Proxy, null, position, rotation, length, radiusA, radiusB, sides); - } - } -} diff --git a/UnityPackage/Runtime/Shapes/IShape.cs b/UnityPackage/Runtime/Shapes/IShape.cs index 2227aae..ccae745 100644 --- a/UnityPackage/Runtime/Shapes/IShape.cs +++ b/UnityPackage/Runtime/Shapes/IShape.cs @@ -4,7 +4,8 @@ namespace reromanlee.Wireframes { /// - /// A shape drawn by an . Disposing it removes it from the container. + /// A shape drawn by a . Disposing it removes it from the container. Shape + /// interfaces are implemented only by the package, so members may be added to them in minor versions. /// Every member except and throws /// once the shape is disposed. /// diff --git a/UnityPackage/Runtime/Shapes/Line/ILineContainer.Line.cs b/UnityPackage/Runtime/Shapes/Line/ILineContainer.Line.cs deleted file mode 100644 index ceba40f..0000000 --- a/UnityPackage/Runtime/Shapes/Line/ILineContainer.Line.cs +++ /dev/null @@ -1,19 +0,0 @@ -using UnityEngine; - -namespace reromanlee.Wireframes -{ - public partial interface ILineContainer - { - /// Creates a white line with both endpoints at the world origin. - ILine CreateLine(); - - /// Creates a white line between two world positions. - ILine CreateLine(Vector3 positionA, Vector3 positionB); - - /// - /// Creates a white line from the origin of to the origin of , - /// with each endpoint following its bone. A null bone puts that endpoint at the world origin. - /// - ILine CreateLine(Transform boneA, Transform boneB); - } -} diff --git a/UnityPackage/Runtime/Shapes/Line/ILineContainer.Line.cs.meta b/UnityPackage/Runtime/Shapes/Line/ILineContainer.Line.cs.meta deleted file mode 100644 index 4f95f10..0000000 --- a/UnityPackage/Runtime/Shapes/Line/ILineContainer.Line.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: f0a53655b78c40188727dc0cca76cf35 diff --git a/UnityPackage/Runtime/Shapes/Line/LineContainer.Line.cs b/UnityPackage/Runtime/Shapes/Line/LineContainer.Line.cs deleted file mode 100644 index 281c5de..0000000 --- a/UnityPackage/Runtime/Shapes/Line/LineContainer.Line.cs +++ /dev/null @@ -1,22 +0,0 @@ -using UnityEngine; - -namespace reromanlee.Wireframes -{ - public sealed partial class LineContainer - { - public ILine CreateLine() - { - return new Line(Proxy, Vector3.zero, Vector3.zero); - } - - public ILine CreateLine(Vector3 positionA, Vector3 positionB) - { - return new Line(Proxy, positionA, positionB); - } - - public ILine CreateLine(Transform boneA, Transform boneB) - { - return new Line(Proxy, boneA, boneB); - } - } -} diff --git a/UnityPackage/Runtime/Shapes/Line/LineFactory.cs b/UnityPackage/Runtime/Shapes/Line/LineFactory.cs new file mode 100644 index 0000000..c5f6ddb --- /dev/null +++ b/UnityPackage/Runtime/Shapes/Line/LineFactory.cs @@ -0,0 +1,29 @@ +using UnityEngine; + +namespace reromanlee.Wireframes +{ + /// Creates lines in a . + public static class LineFactory + { + /// Creates a white line with both endpoints at the world origin. + public static ILine CreateLine(this WireframeContainer container) + { + return new Line(WireframeContainer.ProxyOf(container), Vector3.zero, Vector3.zero); + } + + /// Creates a white line between two world positions. + public static ILine CreateLine(this WireframeContainer container, Vector3 positionA, Vector3 positionB) + { + return new Line(WireframeContainer.ProxyOf(container), positionA, positionB); + } + + /// + /// Creates a white line from the origin of to the origin of , + /// with each endpoint following its bone. A null bone puts that endpoint at the world origin. + /// + public static ILine CreateLine(this WireframeContainer container, Transform boneA, Transform boneB) + { + return new Line(WireframeContainer.ProxyOf(container), boneA, boneB); + } + } +} diff --git a/UnityPackage/Runtime/Shapes/Line/LineContainer.Line.cs.meta b/UnityPackage/Runtime/Shapes/Line/LineFactory.cs.meta similarity index 100% rename from UnityPackage/Runtime/Shapes/Line/LineContainer.Line.cs.meta rename to UnityPackage/Runtime/Shapes/Line/LineFactory.cs.meta diff --git a/UnityPackage/Runtime/Shapes/Polyline/ILineContainer.Polyline.cs b/UnityPackage/Runtime/Shapes/Polyline/ILineContainer.Polyline.cs deleted file mode 100644 index 261fa6c..0000000 --- a/UnityPackage/Runtime/Shapes/Polyline/ILineContainer.Polyline.cs +++ /dev/null @@ -1,41 +0,0 @@ -using System.Collections.Generic; -using UnityEngine; - -namespace reromanlee.Wireframes -{ - public partial interface ILineContainer - { - /// Creates a white, open polyline through at least 2 world positions. - IPolyline CreatePolyline(params Vector3[] points); - - /// Creates a white, open polyline through at least 2 world positions. - IPolyline CreatePolyline(IReadOnlyList points); - - /// - /// Creates a white, open polyline through the origins of at least 2 bones, with each point following its bone. - /// A null bone puts that point at the world origin. - /// - IPolyline CreatePolyline(params Transform[] bones); - - /// Creates a white, closed polyline through at least 3 world positions. - IPolyline CreatePolygon(params Vector3[] points); - - /// Creates a white, closed polyline through at least 3 world positions. - IPolyline CreatePolygon(IReadOnlyList points); - - /// - /// Creates a white, closed polyline through the origins of at least 3 bones, with each point following its - /// bone. A null bone puts that point at the world origin. - /// - IPolyline CreatePolygon(params Transform[] bones); - - /// Creates a white triangle, a closed polyline through 3 world positions. - IPolyline CreateTriangle(Vector3 pointA, Vector3 pointB, Vector3 pointC); - - /// - /// Creates a white triangle through the origins of 3 bones, with each corner following its bone. A null bone - /// puts that corner at the world origin. - /// - IPolyline CreateTriangle(Transform boneA, Transform boneB, Transform boneC); - } -} diff --git a/UnityPackage/Runtime/Shapes/Polyline/ILineContainer.Polyline.cs.meta b/UnityPackage/Runtime/Shapes/Polyline/ILineContainer.Polyline.cs.meta deleted file mode 100644 index 1c500eb..0000000 --- a/UnityPackage/Runtime/Shapes/Polyline/ILineContainer.Polyline.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 657f8efd0d95dd3000118faba964534e \ No newline at end of file diff --git a/UnityPackage/Runtime/Shapes/Polyline/LineContainer.Polyline.cs b/UnityPackage/Runtime/Shapes/Polyline/LineContainer.Polyline.cs deleted file mode 100644 index 3c002b4..0000000 --- a/UnityPackage/Runtime/Shapes/Polyline/LineContainer.Polyline.cs +++ /dev/null @@ -1,48 +0,0 @@ -using System.Collections.Generic; -using UnityEngine; - -namespace reromanlee.Wireframes -{ - public sealed partial class LineContainer - { - public IPolyline CreatePolyline(params Vector3[] points) - { - return new Polyline(Proxy, points, false); - } - - public IPolyline CreatePolyline(IReadOnlyList points) - { - return new Polyline(Proxy, points, false); - } - - public IPolyline CreatePolyline(params Transform[] bones) - { - return new Polyline(Proxy, bones, false); - } - - public IPolyline CreatePolygon(params Vector3[] points) - { - return new Polyline(Proxy, points, true); - } - - public IPolyline CreatePolygon(IReadOnlyList points) - { - return new Polyline(Proxy, points, true); - } - - public IPolyline CreatePolygon(params Transform[] bones) - { - return new Polyline(Proxy, bones, true); - } - - public IPolyline CreateTriangle(Vector3 pointA, Vector3 pointB, Vector3 pointC) - { - return new Polyline(Proxy, new[] { pointA, pointB, pointC }, true); - } - - public IPolyline CreateTriangle(Transform boneA, Transform boneB, Transform boneC) - { - return new Polyline(Proxy, new[] { boneA, boneB, boneC }, true); - } - } -} diff --git a/UnityPackage/Runtime/Shapes/Polyline/PolylineFactory.cs b/UnityPackage/Runtime/Shapes/Polyline/PolylineFactory.cs new file mode 100644 index 0000000..e9e0950 --- /dev/null +++ b/UnityPackage/Runtime/Shapes/Polyline/PolylineFactory.cs @@ -0,0 +1,68 @@ +using System.Collections.Generic; +using UnityEngine; + +namespace reromanlee.Wireframes +{ + /// Creates polylines, polygons and triangles in a . + public static class PolylineFactory + { + /// Creates a white, open polyline through at least 2 world positions. + public static IPolyline CreatePolyline(this WireframeContainer container, params Vector3[] points) + { + return new Polyline(WireframeContainer.ProxyOf(container), points, false); + } + + /// Creates a white, open polyline through at least 2 world positions. + public static IPolyline CreatePolyline(this WireframeContainer container, IReadOnlyList points) + { + return new Polyline(WireframeContainer.ProxyOf(container), points, false); + } + + /// + /// Creates a white, open polyline through the origins of at least 2 bones, with each point following its bone. + /// A null bone puts that point at the world origin. + /// + public static IPolyline CreatePolyline(this WireframeContainer container, params Transform[] bones) + { + return new Polyline(WireframeContainer.ProxyOf(container), bones, false); + } + + /// Creates a white, closed polyline through at least 3 world positions. + public static IPolyline CreatePolygon(this WireframeContainer container, params Vector3[] points) + { + return new Polyline(WireframeContainer.ProxyOf(container), points, true); + } + + /// Creates a white, closed polyline through at least 3 world positions. + public static IPolyline CreatePolygon(this WireframeContainer container, IReadOnlyList points) + { + return new Polyline(WireframeContainer.ProxyOf(container), points, true); + } + + /// + /// Creates a white, closed polyline through the origins of at least 3 bones, with each point following its + /// bone. A null bone puts that point at the world origin. + /// + public static IPolyline CreatePolygon(this WireframeContainer container, params Transform[] bones) + { + return new Polyline(WireframeContainer.ProxyOf(container), bones, true); + } + + /// Creates a white triangle, a closed polyline through 3 world positions. + public static IPolyline CreateTriangle( + this WireframeContainer container, Vector3 pointA, Vector3 pointB, Vector3 pointC) + { + return new Polyline(WireframeContainer.ProxyOf(container), new[] { pointA, pointB, pointC }, true); + } + + /// + /// Creates a white triangle through the origins of 3 bones, with each corner following its bone. A null bone + /// puts that corner at the world origin. + /// + public static IPolyline CreateTriangle( + this WireframeContainer container, Transform boneA, Transform boneB, Transform boneC) + { + return new Polyline(WireframeContainer.ProxyOf(container), new[] { boneA, boneB, boneC }, true); + } + } +} diff --git a/UnityPackage/Runtime/Shapes/Polyline/LineContainer.Polyline.cs.meta b/UnityPackage/Runtime/Shapes/Polyline/PolylineFactory.cs.meta similarity index 100% rename from UnityPackage/Runtime/Shapes/Polyline/LineContainer.Polyline.cs.meta rename to UnityPackage/Runtime/Shapes/Polyline/PolylineFactory.cs.meta diff --git a/UnityPackage/Runtime/Shapes/Pyramid/ILineContainer.Pyramid.cs b/UnityPackage/Runtime/Shapes/Pyramid/ILineContainer.Pyramid.cs deleted file mode 100644 index 6cac5fe..0000000 --- a/UnityPackage/Runtime/Shapes/Pyramid/ILineContainer.Pyramid.cs +++ /dev/null @@ -1,32 +0,0 @@ -using UnityEngine; - -namespace reromanlee.Wireframes -{ - public partial interface ILineContainer - { - /// Creates a white pyramid with its tip at the world origin and a 1 by 1 base, 1 unit along +Z. - IPyramid CreatePyramid(); - - /// - /// Creates a white pyramid from a tip to the center of its base, both world positions. Its spin around the axis - /// keeps the base's Y side as close to world up as it can; pointing straight up or down, the base is aligned - /// with the world's X and Z axes. - /// - /// Width of the base along the pyramid's X axis and height along its Y axis. - IPyramid CreatePyramid(Vector3 tip, Vector3 baseCenter, Vector2 baseSize); - - /// - /// Creates a white pyramid that follows , from a tip to the center of its base, both in - /// the bone's local space. Its spin around the axis keeps the base's Y side as close to the bone's up as it can. - /// - /// Width of the base along the pyramid's X axis and height along its Y axis. - IPyramid CreatePyramid(Transform bone, Vector3 localTip, Vector3 localBaseCenter, Vector2 baseSize); - - /// - /// Creates a white pyramid in world space with its tip at and its base - /// along the +Z axis of . - /// - /// Width of the base along the rotation's X axis and height along its Y axis. - IPyramid CreatePyramid(Vector3 position, Quaternion rotation, float length, Vector2 baseSize); - } -} diff --git a/UnityPackage/Runtime/Shapes/Pyramid/ILineContainer.Pyramid.cs.meta b/UnityPackage/Runtime/Shapes/Pyramid/ILineContainer.Pyramid.cs.meta deleted file mode 100644 index c3d60a0..0000000 --- a/UnityPackage/Runtime/Shapes/Pyramid/ILineContainer.Pyramid.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 08562fc7acae5831b2fec288085eef1e \ No newline at end of file diff --git a/UnityPackage/Runtime/Shapes/Pyramid/LineContainer.Pyramid.cs b/UnityPackage/Runtime/Shapes/Pyramid/LineContainer.Pyramid.cs deleted file mode 100644 index 0b767ec..0000000 --- a/UnityPackage/Runtime/Shapes/Pyramid/LineContainer.Pyramid.cs +++ /dev/null @@ -1,29 +0,0 @@ -using UnityEngine; - -namespace reromanlee.Wireframes -{ - public sealed partial class LineContainer - { - public IPyramid CreatePyramid() - { - return new Pyramid(Proxy, null, Vector3.zero, Quaternion.identity, UnitSize, Vector2.one * UnitSize); - } - - public IPyramid CreatePyramid(Vector3 tip, Vector3 baseCenter, Vector2 baseSize) - { - Quaternion rotation = ShapeRotations.FromAxis(baseCenter - tip, Vector3.up); - return new Pyramid(Proxy, null, tip, rotation, Vector3.Distance(tip, baseCenter), baseSize); - } - - public IPyramid CreatePyramid(Transform bone, Vector3 localTip, Vector3 localBaseCenter, Vector2 baseSize) - { - Quaternion rotation = ShapeRotations.FromAxis(localBaseCenter - localTip, Vector3.up); - return new Pyramid(Proxy, bone, localTip, rotation, Vector3.Distance(localTip, localBaseCenter), baseSize); - } - - public IPyramid CreatePyramid(Vector3 position, Quaternion rotation, float length, Vector2 baseSize) - { - return new Pyramid(Proxy, null, position, rotation, length, baseSize); - } - } -} diff --git a/UnityPackage/Runtime/Shapes/Pyramid/PyramidFactory.cs b/UnityPackage/Runtime/Shapes/Pyramid/PyramidFactory.cs new file mode 100644 index 0000000..f27c0e9 --- /dev/null +++ b/UnityPackage/Runtime/Shapes/Pyramid/PyramidFactory.cs @@ -0,0 +1,63 @@ +using UnityEngine; + +namespace reromanlee.Wireframes +{ + /// Creates pyramids with rectangular bases in a . + public static class PyramidFactory + { + /// Creates a white pyramid with its tip at the world origin and a 1 by 1 base, 1 unit along +Z. + public static IPyramid CreatePyramid(this WireframeContainer container) + { + MeshProxy proxy = WireframeContainer.ProxyOf(container); + Vector2 baseSize = Vector2.one * ShapeDefaults.Size; + return new Pyramid(proxy, null, Vector3.zero, Quaternion.identity, ShapeDefaults.Size, baseSize); + } + + /// + /// Creates a white pyramid from a tip to the center of its base, both world positions. Its spin around the axis + /// keeps the base's Y side as close to world up as it can; pointing straight up or down, the base is aligned + /// with the world's X and Z axes. + /// + /// Width of the base along the pyramid's X axis and height along its Y axis. + public static IPyramid CreatePyramid( + this WireframeContainer container, Vector3 tip, Vector3 baseCenter, Vector2 baseSize) + { + MeshProxy proxy = WireframeContainer.ProxyOf(container); + Quaternion rotation = ShapeRotations.FromAxis(baseCenter - tip, Vector3.up); + return new Pyramid(proxy, null, tip, rotation, Vector3.Distance(tip, baseCenter), baseSize); + } + + /// + /// Creates a white pyramid that follows , from a tip to the center of its base, both in + /// the bone's local space. Its spin around the axis keeps the base's Y side as close to the bone's up as it can. + /// + /// Width of the base along the pyramid's X axis and height along its Y axis. + public static IPyramid CreatePyramid( + this WireframeContainer container, + Transform bone, + Vector3 localTip, + Vector3 localBaseCenter, + Vector2 baseSize) + { + MeshProxy proxy = WireframeContainer.ProxyOf(container); + Quaternion rotation = ShapeRotations.FromAxis(localBaseCenter - localTip, Vector3.up); + float length = Vector3.Distance(localTip, localBaseCenter); + return new Pyramid(proxy, bone, localTip, rotation, length, baseSize); + } + + /// + /// Creates a white pyramid in world space with its tip at and its base + /// along the +Z axis of . + /// + /// Width of the base along the rotation's X axis and height along its Y axis. + public static IPyramid CreatePyramid( + this WireframeContainer container, + Vector3 position, + Quaternion rotation, + float length, + Vector2 baseSize) + { + return new Pyramid(WireframeContainer.ProxyOf(container), null, position, rotation, length, baseSize); + } + } +} diff --git a/UnityPackage/Runtime/Shapes/Pyramid/LineContainer.Pyramid.cs.meta b/UnityPackage/Runtime/Shapes/Pyramid/PyramidFactory.cs.meta similarity index 100% rename from UnityPackage/Runtime/Shapes/Pyramid/LineContainer.Pyramid.cs.meta rename to UnityPackage/Runtime/Shapes/Pyramid/PyramidFactory.cs.meta diff --git a/UnityPackage/Runtime/Shapes/Rectangle/ILineContainer.Rectangle.cs b/UnityPackage/Runtime/Shapes/Rectangle/ILineContainer.Rectangle.cs deleted file mode 100644 index 1d62804..0000000 --- a/UnityPackage/Runtime/Shapes/Rectangle/ILineContainer.Rectangle.cs +++ /dev/null @@ -1,25 +0,0 @@ -using UnityEngine; - -namespace reromanlee.Wireframes -{ - public partial interface ILineContainer - { - /// Creates a white 1 by 1 rectangle around the world origin, lying flat in the XZ plane. - IRectangle CreateRectangle(); - - /// - /// Creates a white, world-aligned rectangle lying flat in the XZ plane, spanned by two opposite world-space - /// corners. If the corners differ in height, the rectangle lies halfway between them. - /// - IRectangle CreateRectangle(Vector3 cornerA, Vector3 cornerB); - - /// - /// Creates a white rectangle that follows , lying flat in the bone's XZ plane and spanned - /// by two opposite corners in the bone's local space. - /// - IRectangle CreateRectangle(Transform bone, Vector3 localCornerA, Vector3 localCornerB); - - /// Creates a white rectangle in world space, lying in the XZ plane of . - IRectangle CreateRectangle(Vector3 center, Quaternion rotation, Vector2 size); - } -} diff --git a/UnityPackage/Runtime/Shapes/Rectangle/ILineContainer.Rectangle.cs.meta b/UnityPackage/Runtime/Shapes/Rectangle/ILineContainer.Rectangle.cs.meta deleted file mode 100644 index 69cd0d2..0000000 --- a/UnityPackage/Runtime/Shapes/Rectangle/ILineContainer.Rectangle.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: eb69f482a3a257187859ebc244068ec3 \ No newline at end of file diff --git a/UnityPackage/Runtime/Shapes/Rectangle/LineContainer.Rectangle.cs b/UnityPackage/Runtime/Shapes/Rectangle/LineContainer.Rectangle.cs deleted file mode 100644 index ff7b6f6..0000000 --- a/UnityPackage/Runtime/Shapes/Rectangle/LineContainer.Rectangle.cs +++ /dev/null @@ -1,35 +0,0 @@ -using UnityEngine; - -namespace reromanlee.Wireframes -{ - public sealed partial class LineContainer - { - public IRectangle CreateRectangle() - { - return new Rectangle(Proxy, null, Vector3.zero, Quaternion.identity, Vector2.one * UnitSize); - } - - public IRectangle CreateRectangle(Vector3 cornerA, Vector3 cornerB) - { - Vector3 center = (cornerA + cornerB) * 0.5f; - return new Rectangle(Proxy, null, center, Quaternion.identity, FlatSpan(cornerA, cornerB)); - } - - public IRectangle CreateRectangle(Transform bone, Vector3 localCornerA, Vector3 localCornerB) - { - Vector3 center = (localCornerA + localCornerB) * 0.5f; - return new Rectangle(Proxy, bone, center, Quaternion.identity, FlatSpan(localCornerA, localCornerB)); - } - - public IRectangle CreateRectangle(Vector3 center, Quaternion rotation, Vector2 size) - { - return new Rectangle(Proxy, null, center, rotation, size); - } - - /// Size of the XZ rectangle spanned by two corners; their difference in height is ignored. - private static Vector2 FlatSpan(Vector3 cornerA, Vector3 cornerB) - { - return new Vector2(cornerB.x - cornerA.x, cornerB.z - cornerA.z); - } - } -} diff --git a/UnityPackage/Runtime/Shapes/Rectangle/RectangleFactory.cs b/UnityPackage/Runtime/Shapes/Rectangle/RectangleFactory.cs new file mode 100644 index 0000000..f59ea13 --- /dev/null +++ b/UnityPackage/Runtime/Shapes/Rectangle/RectangleFactory.cs @@ -0,0 +1,46 @@ +using UnityEngine; + +namespace reromanlee.Wireframes +{ + /// Creates rectangles in a . + public static class RectangleFactory + { + /// Creates a white 1 by 1 rectangle around the world origin, lying flat in the XZ plane. + public static IRectangle CreateRectangle(this WireframeContainer container) + { + MeshProxy proxy = WireframeContainer.ProxyOf(container); + return new Rectangle(proxy, null, Vector3.zero, Quaternion.identity, Vector2.one * ShapeDefaults.Size); + } + + /// + /// Creates a white, world-aligned rectangle lying flat in the XZ plane, spanned by two opposite world-space + /// corners. If the corners differ in height, the rectangle lies halfway between them. + /// + public static IRectangle CreateRectangle(this WireframeContainer container, Vector3 cornerA, Vector3 cornerB) + { + MeshProxy proxy = WireframeContainer.ProxyOf(container); + Vector3 center = (cornerA + cornerB) * 0.5f; + return new Rectangle(proxy, null, center, Quaternion.identity, RectangleShape.FlatSpan(cornerA, cornerB)); + } + + /// + /// Creates a white rectangle that follows , lying flat in the bone's XZ plane and spanned + /// by two opposite corners in the bone's local space. + /// + public static IRectangle CreateRectangle( + this WireframeContainer container, Transform bone, Vector3 localCornerA, Vector3 localCornerB) + { + MeshProxy proxy = WireframeContainer.ProxyOf(container); + Vector3 center = (localCornerA + localCornerB) * 0.5f; + Vector2 size = RectangleShape.FlatSpan(localCornerA, localCornerB); + return new Rectangle(proxy, bone, center, Quaternion.identity, size); + } + + /// Creates a white rectangle in world space, lying in the XZ plane of . + public static IRectangle CreateRectangle( + this WireframeContainer container, Vector3 center, Quaternion rotation, Vector2 size) + { + return new Rectangle(WireframeContainer.ProxyOf(container), null, center, rotation, size); + } + } +} diff --git a/UnityPackage/Runtime/Shapes/Rectangle/LineContainer.Rectangle.cs.meta b/UnityPackage/Runtime/Shapes/Rectangle/RectangleFactory.cs.meta similarity index 100% rename from UnityPackage/Runtime/Shapes/Rectangle/LineContainer.Rectangle.cs.meta rename to UnityPackage/Runtime/Shapes/Rectangle/RectangleFactory.cs.meta diff --git a/UnityPackage/Runtime/Shapes/RoundedRectangle/ILineContainer.RoundedRectangle.cs b/UnityPackage/Runtime/Shapes/RoundedRectangle/ILineContainer.RoundedRectangle.cs deleted file mode 100644 index f20fe3d..0000000 --- a/UnityPackage/Runtime/Shapes/RoundedRectangle/ILineContainer.RoundedRectangle.cs +++ /dev/null @@ -1,46 +0,0 @@ -using UnityEngine; - -namespace reromanlee.Wireframes -{ - public partial interface ILineContainer - { - /// - /// Creates a white 1 by 1 rectangle with corners of radius 0.25 around the world origin, lying flat in the XZ - /// plane. - /// - IRoundedRectangle CreateRoundedRectangle(); - - /// - /// Creates a white, world-aligned rounded rectangle lying flat in the XZ plane, spanned by two opposite - /// world-space corners. If the corners differ in height, the rectangle lies halfway between them. - /// - /// - /// Number of straight pieces a full circle of the corners is drawn with, a positive multiple of 4. - /// - IRoundedRectangle CreateRoundedRectangle( - Vector3 cornerA, Vector3 cornerB, float cornerRadius, int segments = Ring.DefaultSegments); - - /// - /// Creates a white rounded rectangle that follows , lying flat in the bone's XZ plane - /// and spanned by two opposite corners in the bone's local space. - /// - /// - /// Number of straight pieces a full circle of the corners is drawn with, a positive multiple of 4. - /// - IRoundedRectangle CreateRoundedRectangle( - Transform bone, - Vector3 localCornerA, - Vector3 localCornerB, - float cornerRadius, - int segments = Ring.DefaultSegments); - - /// - /// Creates a white rounded rectangle in world space, lying in the XZ plane of . - /// - /// - /// Number of straight pieces a full circle of the corners is drawn with, a positive multiple of 4. - /// - IRoundedRectangle CreateRoundedRectangle( - Vector3 center, Quaternion rotation, Vector2 size, float cornerRadius, int segments = Ring.DefaultSegments); - } -} diff --git a/UnityPackage/Runtime/Shapes/RoundedRectangle/ILineContainer.RoundedRectangle.cs.meta b/UnityPackage/Runtime/Shapes/RoundedRectangle/ILineContainer.RoundedRectangle.cs.meta deleted file mode 100644 index c434d63..0000000 --- a/UnityPackage/Runtime/Shapes/RoundedRectangle/ILineContainer.RoundedRectangle.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 4d116700a802b8c3a0d9ed4202455f8b \ No newline at end of file diff --git a/UnityPackage/Runtime/Shapes/RoundedRectangle/LineContainer.RoundedRectangle.cs b/UnityPackage/Runtime/Shapes/RoundedRectangle/LineContainer.RoundedRectangle.cs deleted file mode 100644 index d5871be..0000000 --- a/UnityPackage/Runtime/Shapes/RoundedRectangle/LineContainer.RoundedRectangle.cs +++ /dev/null @@ -1,41 +0,0 @@ -using UnityEngine; - -namespace reromanlee.Wireframes -{ - public sealed partial class LineContainer - { - private const float UnitCornerRadius = 0.25f; - - public IRoundedRectangle CreateRoundedRectangle() - { - return new RoundedRectangle( - Proxy, null, Vector3.zero, Quaternion.identity, Vector2.one * UnitSize, UnitCornerRadius, Ring.DefaultSegments); - } - - public IRoundedRectangle CreateRoundedRectangle( - Vector3 cornerA, Vector3 cornerB, float cornerRadius, int segments = Ring.DefaultSegments) - { - Vector3 center = (cornerA + cornerB) * 0.5f; - Vector2 size = FlatSpan(cornerA, cornerB); - return new RoundedRectangle(Proxy, null, center, Quaternion.identity, size, cornerRadius, segments); - } - - public IRoundedRectangle CreateRoundedRectangle( - Transform bone, - Vector3 localCornerA, - Vector3 localCornerB, - float cornerRadius, - int segments = Ring.DefaultSegments) - { - Vector3 center = (localCornerA + localCornerB) * 0.5f; - Vector2 size = FlatSpan(localCornerA, localCornerB); - return new RoundedRectangle(Proxy, bone, center, Quaternion.identity, size, cornerRadius, segments); - } - - public IRoundedRectangle CreateRoundedRectangle( - Vector3 center, Quaternion rotation, Vector2 size, float cornerRadius, int segments = Ring.DefaultSegments) - { - return new RoundedRectangle(Proxy, null, center, rotation, size, cornerRadius, segments); - } - } -} diff --git a/UnityPackage/Runtime/Shapes/RoundedRectangle/RoundedRectangleFactory.cs b/UnityPackage/Runtime/Shapes/RoundedRectangle/RoundedRectangleFactory.cs new file mode 100644 index 0000000..dca0944 --- /dev/null +++ b/UnityPackage/Runtime/Shapes/RoundedRectangle/RoundedRectangleFactory.cs @@ -0,0 +1,81 @@ +using UnityEngine; + +namespace reromanlee.Wireframes +{ + /// Creates rounded rectangles in a . + public static class RoundedRectangleFactory + { + private const float DefaultCornerRadius = 0.25f; + + /// + /// Creates a white 1 by 1 rectangle with corners of radius 0.25 around the world origin, lying flat in the XZ + /// plane. + /// + public static IRoundedRectangle CreateRoundedRectangle(this WireframeContainer container) + { + MeshProxy proxy = WireframeContainer.ProxyOf(container); + Vector2 size = Vector2.one * ShapeDefaults.Size; + return new RoundedRectangle( + proxy, null, Vector3.zero, Quaternion.identity, size, DefaultCornerRadius, Ring.DefaultSegments); + } + + /// + /// Creates a white, world-aligned rounded rectangle lying flat in the XZ plane, spanned by two opposite + /// world-space corners. If the corners differ in height, the rectangle lies halfway between them. + /// + /// + /// Number of straight pieces a full circle of the corners is drawn with, a positive multiple of 4. + /// + public static IRoundedRectangle CreateRoundedRectangle( + this WireframeContainer container, + Vector3 cornerA, + Vector3 cornerB, + float cornerRadius, + int segments = Ring.DefaultSegments) + { + MeshProxy proxy = WireframeContainer.ProxyOf(container); + Vector3 center = (cornerA + cornerB) * 0.5f; + Vector2 size = RectangleShape.FlatSpan(cornerA, cornerB); + return new RoundedRectangle(proxy, null, center, Quaternion.identity, size, cornerRadius, segments); + } + + /// + /// Creates a white rounded rectangle that follows , lying flat in the bone's XZ plane + /// and spanned by two opposite corners in the bone's local space. + /// + /// + /// Number of straight pieces a full circle of the corners is drawn with, a positive multiple of 4. + /// + public static IRoundedRectangle CreateRoundedRectangle( + this WireframeContainer container, + Transform bone, + Vector3 localCornerA, + Vector3 localCornerB, + float cornerRadius, + int segments = Ring.DefaultSegments) + { + MeshProxy proxy = WireframeContainer.ProxyOf(container); + Vector3 center = (localCornerA + localCornerB) * 0.5f; + Vector2 size = RectangleShape.FlatSpan(localCornerA, localCornerB); + return new RoundedRectangle(proxy, bone, center, Quaternion.identity, size, cornerRadius, segments); + } + + /// + /// Creates a white rounded rectangle in world space, lying in the XZ plane of . + /// + /// + /// Number of straight pieces a full circle of the corners is drawn with, a positive multiple of 4. + /// + public static IRoundedRectangle CreateRoundedRectangle( + this WireframeContainer container, + Vector3 center, + Quaternion rotation, + Vector2 size, + float cornerRadius, + int segments = Ring.DefaultSegments) + { + MeshProxy proxy = WireframeContainer.ProxyOf(container); + return new RoundedRectangle(proxy, null, center, rotation, size, cornerRadius, segments); + } + } +} diff --git a/UnityPackage/Runtime/Shapes/RoundedRectangle/LineContainer.RoundedRectangle.cs.meta b/UnityPackage/Runtime/Shapes/RoundedRectangle/RoundedRectangleFactory.cs.meta similarity index 100% rename from UnityPackage/Runtime/Shapes/RoundedRectangle/LineContainer.RoundedRectangle.cs.meta rename to UnityPackage/Runtime/Shapes/RoundedRectangle/RoundedRectangleFactory.cs.meta diff --git a/UnityPackage/Runtime/Shapes/Sphere/ILineContainer.Sphere.cs b/UnityPackage/Runtime/Shapes/Sphere/ILineContainer.Sphere.cs deleted file mode 100644 index a9ed8c8..0000000 --- a/UnityPackage/Runtime/Shapes/Sphere/ILineContainer.Sphere.cs +++ /dev/null @@ -1,25 +0,0 @@ -using UnityEngine; - -namespace reromanlee.Wireframes -{ - public partial interface ILineContainer - { - /// Creates a white sphere of radius 0.5 around the world origin. - ISphere CreateSphere(); - - /// Creates a white, world-aligned sphere around a world position. - /// Number of straight pieces each great circle is drawn with, at least 3. - ISphere CreateSphere(Vector3 center, float radius, int segments = Ring.DefaultSegments); - - /// - /// Creates a white sphere that follows , around a position in the bone's local space and - /// aligned to the bone's axes. - /// - /// Number of straight pieces each great circle is drawn with, at least 3. - ISphere CreateSphere(Transform bone, Vector3 localCenter, float radius, int segments = Ring.DefaultSegments); - - /// Creates a white sphere in world space, with its great circles turned by . - /// Number of straight pieces each great circle is drawn with, at least 3. - ISphere CreateSphere(Vector3 center, Quaternion rotation, float radius, int segments = Ring.DefaultSegments); - } -} diff --git a/UnityPackage/Runtime/Shapes/Sphere/ILineContainer.Sphere.cs.meta b/UnityPackage/Runtime/Shapes/Sphere/ILineContainer.Sphere.cs.meta deleted file mode 100644 index 3fd6022..0000000 --- a/UnityPackage/Runtime/Shapes/Sphere/ILineContainer.Sphere.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: 62d3973d3489cc71e36f61ad9af1a889 \ No newline at end of file diff --git a/UnityPackage/Runtime/Shapes/Sphere/LineContainer.Sphere.cs b/UnityPackage/Runtime/Shapes/Sphere/LineContainer.Sphere.cs deleted file mode 100644 index f864ab0..0000000 --- a/UnityPackage/Runtime/Shapes/Sphere/LineContainer.Sphere.cs +++ /dev/null @@ -1,27 +0,0 @@ -using UnityEngine; - -namespace reromanlee.Wireframes -{ - public sealed partial class LineContainer - { - public ISphere CreateSphere() - { - return new Sphere(Proxy, null, Vector3.zero, Quaternion.identity, UnitRadius, Ring.DefaultSegments); - } - - public ISphere CreateSphere(Vector3 center, float radius, int segments = Ring.DefaultSegments) - { - return new Sphere(Proxy, null, center, Quaternion.identity, radius, segments); - } - - public ISphere CreateSphere(Transform bone, Vector3 localCenter, float radius, int segments = Ring.DefaultSegments) - { - return new Sphere(Proxy, bone, localCenter, Quaternion.identity, radius, segments); - } - - public ISphere CreateSphere(Vector3 center, Quaternion rotation, float radius, int segments = Ring.DefaultSegments) - { - return new Sphere(Proxy, null, center, rotation, radius, segments); - } - } -} diff --git a/UnityPackage/Runtime/Shapes/Sphere/SphereFactory.cs b/UnityPackage/Runtime/Shapes/Sphere/SphereFactory.cs new file mode 100644 index 0000000..1b5a124 --- /dev/null +++ b/UnityPackage/Runtime/Shapes/Sphere/SphereFactory.cs @@ -0,0 +1,51 @@ +using UnityEngine; + +namespace reromanlee.Wireframes +{ + /// Creates spheres in a . + public static class SphereFactory + { + /// Creates a white sphere of radius 0.5 around the world origin. + public static ISphere CreateSphere(this WireframeContainer container) + { + MeshProxy proxy = WireframeContainer.ProxyOf(container); + return new Sphere(proxy, null, Vector3.zero, Quaternion.identity, ShapeDefaults.Radius, Ring.DefaultSegments); + } + + /// Creates a white, world-aligned sphere around a world position. + /// Number of straight pieces each great circle is drawn with, at least 3. + public static ISphere CreateSphere( + this WireframeContainer container, Vector3 center, float radius, int segments = Ring.DefaultSegments) + { + return new Sphere(WireframeContainer.ProxyOf(container), null, center, Quaternion.identity, radius, segments); + } + + /// + /// Creates a white sphere that follows , around a position in the bone's local space and + /// aligned to the bone's axes. + /// + /// Number of straight pieces each great circle is drawn with, at least 3. + public static ISphere CreateSphere( + this WireframeContainer container, + Transform bone, + Vector3 localCenter, + float radius, + int segments = Ring.DefaultSegments) + { + MeshProxy proxy = WireframeContainer.ProxyOf(container); + return new Sphere(proxy, bone, localCenter, Quaternion.identity, radius, segments); + } + + /// Creates a white sphere in world space, with its great circles turned by . + /// Number of straight pieces each great circle is drawn with, at least 3. + public static ISphere CreateSphere( + this WireframeContainer container, + Vector3 center, + Quaternion rotation, + float radius, + int segments = Ring.DefaultSegments) + { + return new Sphere(WireframeContainer.ProxyOf(container), null, center, rotation, radius, segments); + } + } +} diff --git a/UnityPackage/Runtime/Shapes/Sphere/LineContainer.Sphere.cs.meta b/UnityPackage/Runtime/Shapes/Sphere/SphereFactory.cs.meta similarity index 100% rename from UnityPackage/Runtime/Shapes/Sphere/LineContainer.Sphere.cs.meta rename to UnityPackage/Runtime/Shapes/Sphere/SphereFactory.cs.meta diff --git a/UnityPackage/Runtime/Shapes/SpikedSphere/ILineContainer.SpikedSphere.cs b/UnityPackage/Runtime/Shapes/SpikedSphere/ILineContainer.SpikedSphere.cs deleted file mode 100644 index 070a7dd..0000000 --- a/UnityPackage/Runtime/Shapes/SpikedSphere/ILineContainer.SpikedSphere.cs +++ /dev/null @@ -1,30 +0,0 @@ -using UnityEngine; - -namespace reromanlee.Wireframes -{ - public partial interface ILineContainer - { - /// - /// Creates a white spiked sphere around the world origin: a dodecahedron with corners 0.25 from the center and - /// 12 spikes reaching 0.5. - /// - ISpikedSphere CreateSpikedSphere(); - - /// Creates a white, world-aligned spiked sphere around a world position. - /// 4, 6, 8, 12 or 20: the number of faces of the Platonic solid it is built on. - ISpikedSphere CreateSpikedSphere(Vector3 center, float baseRadius, float spikeLength, int spikeCount); - - /// - /// Creates a white spiked sphere that follows , around a position in the bone's local - /// space and aligned to the bone's axes. - /// - /// 4, 6, 8, 12 or 20: the number of faces of the Platonic solid it is built on. - ISpikedSphere CreateSpikedSphere( - Transform bone, Vector3 localCenter, float baseRadius, float spikeLength, int spikeCount); - - /// Creates a white spiked sphere in world space, turned by . - /// 4, 6, 8, 12 or 20: the number of faces of the Platonic solid it is built on. - ISpikedSphere CreateSpikedSphere( - Vector3 center, Quaternion rotation, float baseRadius, float spikeLength, int spikeCount); - } -} diff --git a/UnityPackage/Runtime/Shapes/SpikedSphere/ILineContainer.SpikedSphere.cs.meta b/UnityPackage/Runtime/Shapes/SpikedSphere/ILineContainer.SpikedSphere.cs.meta deleted file mode 100644 index ace89b6..0000000 --- a/UnityPackage/Runtime/Shapes/SpikedSphere/ILineContainer.SpikedSphere.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: be8c37819a9dec721110b0d151e81e1e \ No newline at end of file diff --git a/UnityPackage/Runtime/Shapes/SpikedSphere/LineContainer.SpikedSphere.cs b/UnityPackage/Runtime/Shapes/SpikedSphere/LineContainer.SpikedSphere.cs deleted file mode 100644 index c326a96..0000000 --- a/UnityPackage/Runtime/Shapes/SpikedSphere/LineContainer.SpikedSphere.cs +++ /dev/null @@ -1,32 +0,0 @@ -using UnityEngine; - -namespace reromanlee.Wireframes -{ - public sealed partial class LineContainer - { - private const int UnitSpikeCount = 12; - - public ISpikedSphere CreateSpikedSphere() - { - float half = UnitRadius * 0.5f; - return new SpikedSphere(Proxy, null, Vector3.zero, Quaternion.identity, half, half, UnitSpikeCount); - } - - public ISpikedSphere CreateSpikedSphere(Vector3 center, float baseRadius, float spikeLength, int spikeCount) - { - return new SpikedSphere(Proxy, null, center, Quaternion.identity, baseRadius, spikeLength, spikeCount); - } - - public ISpikedSphere CreateSpikedSphere( - Transform bone, Vector3 localCenter, float baseRadius, float spikeLength, int spikeCount) - { - return new SpikedSphere(Proxy, bone, localCenter, Quaternion.identity, baseRadius, spikeLength, spikeCount); - } - - public ISpikedSphere CreateSpikedSphere( - Vector3 center, Quaternion rotation, float baseRadius, float spikeLength, int spikeCount) - { - return new SpikedSphere(Proxy, null, center, rotation, baseRadius, spikeLength, spikeCount); - } - } -} diff --git a/UnityPackage/Runtime/Shapes/SpikedSphere/SpikedSphereFactory.cs b/UnityPackage/Runtime/Shapes/SpikedSphere/SpikedSphereFactory.cs new file mode 100644 index 0000000..39aa629 --- /dev/null +++ b/UnityPackage/Runtime/Shapes/SpikedSphere/SpikedSphereFactory.cs @@ -0,0 +1,61 @@ +using UnityEngine; + +namespace reromanlee.Wireframes +{ + /// Creates spiked spheres in a . + public static class SpikedSphereFactory + { + private const int DefaultSpikeCount = 12; + + /// + /// Creates a white spiked sphere around the world origin: a dodecahedron with corners 0.25 from the center and + /// 12 spikes reaching 0.5. + /// + public static ISpikedSphere CreateSpikedSphere(this WireframeContainer container) + { + MeshProxy proxy = WireframeContainer.ProxyOf(container); + float half = ShapeDefaults.Radius * 0.5f; + return new SpikedSphere(proxy, null, Vector3.zero, Quaternion.identity, half, half, DefaultSpikeCount); + } + + /// Creates a white, world-aligned spiked sphere around a world position. + /// 4, 6, 8, 12 or 20: the number of faces of the Platonic solid it is built on. + public static ISpikedSphere CreateSpikedSphere( + this WireframeContainer container, Vector3 center, float baseRadius, float spikeLength, int spikeCount) + { + MeshProxy proxy = WireframeContainer.ProxyOf(container); + return new SpikedSphere(proxy, null, center, Quaternion.identity, baseRadius, spikeLength, spikeCount); + } + + /// + /// Creates a white spiked sphere that follows , around a position in the bone's local + /// space and aligned to the bone's axes. + /// + /// 4, 6, 8, 12 or 20: the number of faces of the Platonic solid it is built on. + public static ISpikedSphere CreateSpikedSphere( + this WireframeContainer container, + Transform bone, + Vector3 localCenter, + float baseRadius, + float spikeLength, + int spikeCount) + { + MeshProxy proxy = WireframeContainer.ProxyOf(container); + return new SpikedSphere(proxy, bone, localCenter, Quaternion.identity, baseRadius, spikeLength, spikeCount); + } + + /// Creates a white spiked sphere in world space, turned by . + /// 4, 6, 8, 12 or 20: the number of faces of the Platonic solid it is built on. + public static ISpikedSphere CreateSpikedSphere( + this WireframeContainer container, + Vector3 center, + Quaternion rotation, + float baseRadius, + float spikeLength, + int spikeCount) + { + MeshProxy proxy = WireframeContainer.ProxyOf(container); + return new SpikedSphere(proxy, null, center, rotation, baseRadius, spikeLength, spikeCount); + } + } +} diff --git a/UnityPackage/Runtime/Shapes/SpikedSphere/LineContainer.SpikedSphere.cs.meta b/UnityPackage/Runtime/Shapes/SpikedSphere/SpikedSphereFactory.cs.meta similarity index 100% rename from UnityPackage/Runtime/Shapes/SpikedSphere/LineContainer.SpikedSphere.cs.meta rename to UnityPackage/Runtime/Shapes/SpikedSphere/SpikedSphereFactory.cs.meta diff --git a/UnityPackage/Runtime/Shapes/Stadium/ILineContainer.Stadium.cs b/UnityPackage/Runtime/Shapes/Stadium/ILineContainer.Stadium.cs deleted file mode 100644 index 7986250..0000000 --- a/UnityPackage/Runtime/Shapes/Stadium/ILineContainer.Stadium.cs +++ /dev/null @@ -1,68 +0,0 @@ -using UnityEngine; - -namespace reromanlee.Wireframes -{ - public partial interface ILineContainer - { - /// - /// Creates a white stadium lying flat in the XZ plane, around circles of radius 0.5 centered on the world origin - /// and 1 unit along +Z. - /// - IStadium CreateStadium(); - - /// - /// Creates a white stadium around two circles of the same radius, centered on two world positions. It lies as - /// flat as it can: its normal stays as close to world up as the centers allow. - /// - /// Number of straight pieces a full circle would be drawn with, a positive multiple of 4. - IStadium CreateStadium(Vector3 centerA, Vector3 centerB, float radius, int segments = Ring.DefaultSegments); - - /// - /// Creates a white stadium around two circles centered on two world positions, each with its own radius. It lies - /// as flat as it can: its normal stays as close to world up as the centers allow. - /// - /// Number of straight pieces a full circle would be drawn with, a positive multiple of 4. - IStadium CreateStadium( - Vector3 centerA, Vector3 centerB, float radiusA, float radiusB, int segments = Ring.DefaultSegments); - - /// - /// Creates a white stadium around two circles centered on two world positions, facing as close to - /// as the centers allow. - /// - /// Number of straight pieces a full circle would be drawn with, a positive multiple of 4. - IStadium CreateStadium( - Vector3 centerA, - Vector3 centerB, - float radiusA, - float radiusB, - Vector3 normal, - int segments = Ring.DefaultSegments); - - /// - /// Creates a white stadium that follows , around two circles centered on positions in the - /// bone's local space. It lies as flat in the bone's XZ plane as the centers allow. - /// - /// Number of straight pieces a full circle would be drawn with, a positive multiple of 4. - IStadium CreateStadium( - Transform bone, - Vector3 localCenterA, - Vector3 localCenterB, - float radiusA, - float radiusB, - int segments = Ring.DefaultSegments); - - /// - /// Creates a white stadium in world space, lying in the XZ plane of , whose first - /// circle is centered on and whose second is centered - /// along the rotation's +Z axis. - /// - /// Number of straight pieces a full circle would be drawn with, a positive multiple of 4. - IStadium CreateStadium( - Vector3 position, - Quaternion rotation, - float length, - float radiusA, - float radiusB, - int segments = Ring.DefaultSegments); - } -} diff --git a/UnityPackage/Runtime/Shapes/Stadium/ILineContainer.Stadium.cs.meta b/UnityPackage/Runtime/Shapes/Stadium/ILineContainer.Stadium.cs.meta deleted file mode 100644 index 0ad649a..0000000 --- a/UnityPackage/Runtime/Shapes/Stadium/ILineContainer.Stadium.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: b521760b3eb0215a705f55082fa72623 \ No newline at end of file diff --git a/UnityPackage/Runtime/Shapes/Stadium/LineContainer.Stadium.cs b/UnityPackage/Runtime/Shapes/Stadium/LineContainer.Stadium.cs deleted file mode 100644 index 24d27ab..0000000 --- a/UnityPackage/Runtime/Shapes/Stadium/LineContainer.Stadium.cs +++ /dev/null @@ -1,61 +0,0 @@ -using UnityEngine; - -namespace reromanlee.Wireframes -{ - public sealed partial class LineContainer - { - public IStadium CreateStadium() - { - return new Stadium( - Proxy, null, Vector3.zero, Quaternion.identity, UnitSize, UnitRadius, UnitRadius, Ring.DefaultSegments); - } - - public IStadium CreateStadium(Vector3 centerA, Vector3 centerB, float radius, int segments = Ring.DefaultSegments) - { - return CreateStadium(centerA, centerB, radius, radius, Vector3.up, segments); - } - - public IStadium CreateStadium( - Vector3 centerA, Vector3 centerB, float radiusA, float radiusB, int segments = Ring.DefaultSegments) - { - return CreateStadium(centerA, centerB, radiusA, radiusB, Vector3.up, segments); - } - - public IStadium CreateStadium( - Vector3 centerA, - Vector3 centerB, - float radiusA, - float radiusB, - Vector3 normal, - int segments = Ring.DefaultSegments) - { - Quaternion rotation = ShapeRotations.FromAxis(centerB - centerA, normal); - float length = Vector3.Distance(centerA, centerB); - return new Stadium(Proxy, null, centerA, rotation, length, radiusA, radiusB, segments); - } - - public IStadium CreateStadium( - Transform bone, - Vector3 localCenterA, - Vector3 localCenterB, - float radiusA, - float radiusB, - int segments = Ring.DefaultSegments) - { - Quaternion rotation = ShapeRotations.FromAxis(localCenterB - localCenterA, Vector3.up); - float length = Vector3.Distance(localCenterA, localCenterB); - return new Stadium(Proxy, bone, localCenterA, rotation, length, radiusA, radiusB, segments); - } - - public IStadium CreateStadium( - Vector3 position, - Quaternion rotation, - float length, - float radiusA, - float radiusB, - int segments = Ring.DefaultSegments) - { - return new Stadium(Proxy, null, position, rotation, length, radiusA, radiusB, segments); - } - } -} diff --git a/UnityPackage/Runtime/Shapes/Stadium/StadiumFactory.cs b/UnityPackage/Runtime/Shapes/Stadium/StadiumFactory.cs new file mode 100644 index 0000000..f87f34d --- /dev/null +++ b/UnityPackage/Runtime/Shapes/Stadium/StadiumFactory.cs @@ -0,0 +1,116 @@ +using UnityEngine; + +namespace reromanlee.Wireframes +{ + /// Creates stadiums, the flat outlines of capsules, in a . + public static class StadiumFactory + { + /// + /// Creates a white stadium lying flat in the XZ plane, around circles of radius 0.5 centered on the world origin + /// and 1 unit along +Z. + /// + public static IStadium CreateStadium(this WireframeContainer container) + { + MeshProxy proxy = WireframeContainer.ProxyOf(container); + return new Stadium( + proxy, + null, + Vector3.zero, + Quaternion.identity, + ShapeDefaults.Size, + ShapeDefaults.Radius, + ShapeDefaults.Radius, + Ring.DefaultSegments); + } + + /// + /// Creates a white stadium around two circles of the same radius, centered on two world positions. It lies as + /// flat as it can: its normal stays as close to world up as the centers allow. + /// + /// Number of straight pieces a full circle would be drawn with, a positive multiple of 4. + public static IStadium CreateStadium( + this WireframeContainer container, + Vector3 centerA, + Vector3 centerB, + float radius, + int segments = Ring.DefaultSegments) + { + return container.CreateStadium(centerA, centerB, radius, radius, Vector3.up, segments); + } + + /// + /// Creates a white stadium around two circles centered on two world positions, each with its own radius. It lies + /// as flat as it can: its normal stays as close to world up as the centers allow. + /// + /// Number of straight pieces a full circle would be drawn with, a positive multiple of 4. + public static IStadium CreateStadium( + this WireframeContainer container, + Vector3 centerA, + Vector3 centerB, + float radiusA, + float radiusB, + int segments = Ring.DefaultSegments) + { + return container.CreateStadium(centerA, centerB, radiusA, radiusB, Vector3.up, segments); + } + + /// + /// Creates a white stadium around two circles centered on two world positions, facing as close to + /// as the centers allow. + /// + /// Number of straight pieces a full circle would be drawn with, a positive multiple of 4. + public static IStadium CreateStadium( + this WireframeContainer container, + Vector3 centerA, + Vector3 centerB, + float radiusA, + float radiusB, + Vector3 normal, + int segments = Ring.DefaultSegments) + { + MeshProxy proxy = WireframeContainer.ProxyOf(container); + Quaternion rotation = ShapeRotations.FromAxis(centerB - centerA, normal); + float length = Vector3.Distance(centerA, centerB); + return new Stadium(proxy, null, centerA, rotation, length, radiusA, radiusB, segments); + } + + /// + /// Creates a white stadium that follows , around two circles centered on positions in the + /// bone's local space. It lies as flat in the bone's XZ plane as the centers allow. + /// + /// Number of straight pieces a full circle would be drawn with, a positive multiple of 4. + public static IStadium CreateStadium( + this WireframeContainer container, + Transform bone, + Vector3 localCenterA, + Vector3 localCenterB, + float radiusA, + float radiusB, + int segments = Ring.DefaultSegments) + { + MeshProxy proxy = WireframeContainer.ProxyOf(container); + Quaternion rotation = ShapeRotations.FromAxis(localCenterB - localCenterA, Vector3.up); + float length = Vector3.Distance(localCenterA, localCenterB); + return new Stadium(proxy, bone, localCenterA, rotation, length, radiusA, radiusB, segments); + } + + /// + /// Creates a white stadium in world space, lying in the XZ plane of , whose first + /// circle is centered on and whose second is centered + /// along the rotation's +Z axis. + /// + /// Number of straight pieces a full circle would be drawn with, a positive multiple of 4. + public static IStadium CreateStadium( + this WireframeContainer container, + Vector3 position, + Quaternion rotation, + float length, + float radiusA, + float radiusB, + int segments = Ring.DefaultSegments) + { + MeshProxy proxy = WireframeContainer.ProxyOf(container); + return new Stadium(proxy, null, position, rotation, length, radiusA, radiusB, segments); + } + } +} diff --git a/UnityPackage/Runtime/Shapes/Stadium/LineContainer.Stadium.cs.meta b/UnityPackage/Runtime/Shapes/Stadium/StadiumFactory.cs.meta similarity index 100% rename from UnityPackage/Runtime/Shapes/Stadium/LineContainer.Stadium.cs.meta rename to UnityPackage/Runtime/Shapes/Stadium/StadiumFactory.cs.meta diff --git a/UnityPackage/Runtime/Shapes/Star/ILineContainer.Star.cs b/UnityPackage/Runtime/Shapes/Star/ILineContainer.Star.cs deleted file mode 100644 index 1048e33..0000000 --- a/UnityPackage/Runtime/Shapes/Star/ILineContainer.Star.cs +++ /dev/null @@ -1,34 +0,0 @@ -using UnityEngine; - -namespace reromanlee.Wireframes -{ - public partial interface ILineContainer - { - /// - /// Creates a white five-pointed star around the world origin, lying flat in the XZ plane, with an outer radius - /// of 0.5 and an inner radius of 0.2. - /// - IStar CreateStar(); - - /// - /// Creates a white star around a world position, lying flat in the world's XZ plane with its first point along - /// +Z. - /// - /// Number of points, at least 3. - IStar CreateStar(Vector3 center, float innerRadius, float outerRadius, int points); - - /// - /// Creates a white star that follows , around a position in the bone's local space and - /// lying flat in the bone's XZ plane with its first point along the bone's +Z. - /// - /// Number of points, at least 3. - IStar CreateStar(Transform bone, Vector3 localCenter, float innerRadius, float outerRadius, int points); - - /// - /// Creates a white star in world space, lying in the XZ plane of with its first point - /// along the rotation's +Z. - /// - /// Number of points, at least 3. - IStar CreateStar(Vector3 center, Quaternion rotation, float innerRadius, float outerRadius, int points); - } -} diff --git a/UnityPackage/Runtime/Shapes/Star/ILineContainer.Star.cs.meta b/UnityPackage/Runtime/Shapes/Star/ILineContainer.Star.cs.meta deleted file mode 100644 index a598bf1..0000000 --- a/UnityPackage/Runtime/Shapes/Star/ILineContainer.Star.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: d2f4b5290b36cb3a2929121260b9f0dc \ No newline at end of file diff --git a/UnityPackage/Runtime/Shapes/Star/LineContainer.Star.cs b/UnityPackage/Runtime/Shapes/Star/LineContainer.Star.cs deleted file mode 100644 index 915fb4e..0000000 --- a/UnityPackage/Runtime/Shapes/Star/LineContainer.Star.cs +++ /dev/null @@ -1,31 +0,0 @@ -using UnityEngine; - -namespace reromanlee.Wireframes -{ - public sealed partial class LineContainer - { - private const float UnitStarInnerRadius = 0.2f; - private const int UnitStarPoints = 5; - - public IStar CreateStar() - { - return new Star( - Proxy, null, Vector3.zero, Quaternion.identity, UnitStarInnerRadius, UnitRadius, UnitStarPoints); - } - - public IStar CreateStar(Vector3 center, float innerRadius, float outerRadius, int points) - { - return new Star(Proxy, null, center, Quaternion.identity, innerRadius, outerRadius, points); - } - - public IStar CreateStar(Transform bone, Vector3 localCenter, float innerRadius, float outerRadius, int points) - { - return new Star(Proxy, bone, localCenter, Quaternion.identity, innerRadius, outerRadius, points); - } - - public IStar CreateStar(Vector3 center, Quaternion rotation, float innerRadius, float outerRadius, int points) - { - return new Star(Proxy, null, center, rotation, innerRadius, outerRadius, points); - } - } -} diff --git a/UnityPackage/Runtime/Shapes/Star/StarFactory.cs b/UnityPackage/Runtime/Shapes/Star/StarFactory.cs new file mode 100644 index 0000000..fe7aec2 --- /dev/null +++ b/UnityPackage/Runtime/Shapes/Star/StarFactory.cs @@ -0,0 +1,68 @@ +using UnityEngine; + +namespace reromanlee.Wireframes +{ + /// Creates stars in a . + public static class StarFactory + { + private const float DefaultInnerRadius = 0.2f; + private const int DefaultPoints = 5; + + /// + /// Creates a white five-pointed star around the world origin, lying flat in the XZ plane, with an outer radius + /// of 0.5 and an inner radius of 0.2. + /// + public static IStar CreateStar(this WireframeContainer container) + { + MeshProxy proxy = WireframeContainer.ProxyOf(container); + return new Star( + proxy, null, Vector3.zero, Quaternion.identity, DefaultInnerRadius, ShapeDefaults.Radius, DefaultPoints); + } + + /// + /// Creates a white star around a world position, lying flat in the world's XZ plane with its first point along + /// +Z. + /// + /// Number of points, at least 3. + public static IStar CreateStar( + this WireframeContainer container, Vector3 center, float innerRadius, float outerRadius, int points) + { + MeshProxy proxy = WireframeContainer.ProxyOf(container); + return new Star(proxy, null, center, Quaternion.identity, innerRadius, outerRadius, points); + } + + /// + /// Creates a white star that follows , around a position in the bone's local space and + /// lying flat in the bone's XZ plane with its first point along the bone's +Z. + /// + /// Number of points, at least 3. + public static IStar CreateStar( + this WireframeContainer container, + Transform bone, + Vector3 localCenter, + float innerRadius, + float outerRadius, + int points) + { + MeshProxy proxy = WireframeContainer.ProxyOf(container); + return new Star(proxy, bone, localCenter, Quaternion.identity, innerRadius, outerRadius, points); + } + + /// + /// Creates a white star in world space, lying in the XZ plane of with its first point + /// along the rotation's +Z. + /// + /// Number of points, at least 3. + public static IStar CreateStar( + this WireframeContainer container, + Vector3 center, + Quaternion rotation, + float innerRadius, + float outerRadius, + int points) + { + MeshProxy proxy = WireframeContainer.ProxyOf(container); + return new Star(proxy, null, center, rotation, innerRadius, outerRadius, points); + } + } +} diff --git a/UnityPackage/Runtime/Shapes/Star/LineContainer.Star.cs.meta b/UnityPackage/Runtime/Shapes/Star/StarFactory.cs.meta similarity index 100% rename from UnityPackage/Runtime/Shapes/Star/LineContainer.Star.cs.meta rename to UnityPackage/Runtime/Shapes/Star/StarFactory.cs.meta diff --git a/UnityPackage/Samples~/ShapeGallery/ShapeGallery.cs b/UnityPackage/Samples~/ShapeGallery/ShapeGallery.cs index 1057374..b2ef7d0 100644 --- a/UnityPackage/Samples~/ShapeGallery/ShapeGallery.cs +++ b/UnityPackage/Samples~/ShapeGallery/ShapeGallery.cs @@ -18,12 +18,12 @@ public sealed class ShapeGallery : MonoBehaviour private readonly List _bones = new(); private readonly List _names = new(); - private LineContainer _container; + private WireframeContainer _container; private GUIStyle _nameStyle; private void Start() { - _container = new LineContainer(); + _container = new WireframeContainer(); // Flat shapes lie in their bone's XZ plane, so their bones are tipped over to face the camera. Quaternion facing = Quaternion.Euler(-90f, 0f, 0f); Quaternion tilted = Quaternion.Euler(-20f, 0f, 0f); diff --git a/UnityPackage/Samples~/StressTest/StressTest.cs b/UnityPackage/Samples~/StressTest/StressTest.cs index 0743b9d..598a14a 100644 --- a/UnityPackage/Samples~/StressTest/StressTest.cs +++ b/UnityPackage/Samples~/StressTest/StressTest.cs @@ -24,7 +24,7 @@ public sealed class StressTest : MonoBehaviour [Tooltip("Lines recolored every frame, to measure the cost of edits.")] [SerializeField, Min(0)] private int _recolorPerFrame; - private LineContainer _container; + private WireframeContainer _container; private Transform[] _bones; private Vector3[] _orbitAxes; private ILine[] _lines; @@ -33,7 +33,7 @@ public sealed class StressTest : MonoBehaviour private void Start() { - _container = new LineContainer(); + _container = new WireframeContainer(); _bones = new Transform[_boneCount]; _orbitAxes = new Vector3[_boneCount]; diff --git a/UnityPackage/Tests/Runtime/BoxTests.cs b/UnityPackage/Tests/Runtime/BoxTests.cs index 5ef9dbc..c9b6a33 100644 --- a/UnityPackage/Tests/Runtime/BoxTests.cs +++ b/UnityPackage/Tests/Runtime/BoxTests.cs @@ -11,7 +11,7 @@ public class BoxTests : WireframesTestBase [Test] public void Box_IsEightCornersJoinedByTwelveEdges() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); IBox box = container.CreateBox(new Vector3(-1f, -2f, -3f), new Vector3(1f, 2f, 3f)); int start = ((Box)box).VertexStart; @@ -72,7 +72,7 @@ public void Corners_ComeBackAsTheyWereSet() [Test] public void PoseForm_TurnsTheBox() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); Quaternion rotation = Quaternion.Euler(0f, 45f, 0f); IBox box = container.CreateBox(new Vector3(0f, 1f, 0f), rotation, new Vector3(2f, 4f, 6f)); @@ -87,7 +87,7 @@ public void PoseForm_TurnsTheBox() [Test] public void SettingCorner_MovesOnlyThatCorner() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); Quaternion rotation = Quaternion.Euler(20f, 30f, 40f); IBox box = container.CreateBox(Vector3.zero, rotation, Vector3.one); Vector3 cornerB = box.WorldCornerB; @@ -121,7 +121,7 @@ public void ChangingBone_KeepsWorldCornersAndRotation() [Test] public void CreateBoxOnBone_IsAlignedToTheBone() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); Transform bone = CreateBone(new Vector3(0f, 2f, 0f), Quaternion.Euler(10f, 20f, 30f), 1.5f); Vector3 extents = new(0.5f, 1f, 1.5f); @@ -137,7 +137,7 @@ public void CreateBoxOnBone_IsAlignedToTheBone() [Test] public void Box_FollowsItsBone() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); Transform bone = CreateBone(Vector3.zero, Quaternion.identity); IBox box = container.CreateBox(); box.Bone = bone; @@ -155,7 +155,7 @@ public void Box_FollowsItsBone() [Test] public void SetColor_ColorsEveryCorner() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); IBox box = container.CreateBox(); int start = ((Box)box).VertexStart; diff --git a/UnityPackage/Tests/Runtime/CapsuleTests.cs b/UnityPackage/Tests/Runtime/CapsuleTests.cs index 2801c22..db6fea2 100644 --- a/UnityPackage/Tests/Runtime/CapsuleTests.cs +++ b/UnityPackage/Tests/Runtime/CapsuleTests.cs @@ -9,7 +9,7 @@ public class CapsuleTests : WireframesTestBase [Test] public void Capsule_IsTwoRingsFourSideLinesAndTwoArcsOverEachCap() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); Vector3 centerA = Vector3.zero; Vector3 centerB = new(0f, 0f, 4f); @@ -40,7 +40,7 @@ public void Capsule_IsTwoRingsFourSideLinesAndTwoArcsOverEachCap() [Test] public void TaperedCapsule_SideLinesTouchBothSpheres() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); Vector3 centerB = new(0f, 0f, 4f); ICapsule capsule = container.CreateCapsule(Vector3.zero, centerB, 1f, 0.5f, 8); @@ -60,7 +60,7 @@ public void TaperedCapsule_SideLinesTouchBothSpheres() [Test] public void SphereInsideTheOther_DrawsOnlyTheBiggerSphere() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); ICapsule capsule = container.CreateCapsule(Vector3.zero, new Vector3(0f, 0f, 1f), 3f, 0.5f, 8); @@ -73,7 +73,7 @@ public void SphereInsideTheOther_DrawsOnlyTheBiggerSphere() [Test] public void NegativeLength_MirrorsTheCapsule() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); ICapsule capsule = container.CreateCapsule(Vector3.zero, Quaternion.identity, -4f, 1f, 0.5f, 8); @@ -97,7 +97,7 @@ public void CreateCapsule_WithoutArguments_HasHalfUnitSpheresOneUnitApart() [Test] public void CreateCapsuleOnBone_FollowsTheBone() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); Transform upperArm = CreateBone(new Vector3(0f, 1.5f, 0f), Quaternion.identity); ICapsule arm = container.CreateCapsule(upperArm, Vector3.zero, new Vector3(0f, -0.3f, 0f), 0.1f, 0.08f, 4); @@ -116,7 +116,7 @@ public void CreateCapsuleOnBone_FollowsTheBone() [Test] public void SegmentsThatAreNotAMultipleOfFour_Throw() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); Assert.Throws(() => container.CreateCapsule(Vector3.zero, Vector3.up, 1f, 6)); Assert.That(ChunkOf(container).ShapeCount, Is.Zero); diff --git a/UnityPackage/Tests/Runtime/CircleTests.cs b/UnityPackage/Tests/Runtime/CircleTests.cs index 63df037..e9d59f2 100644 --- a/UnityPackage/Tests/Runtime/CircleTests.cs +++ b/UnityPackage/Tests/Runtime/CircleTests.cs @@ -9,7 +9,7 @@ public class CircleTests : WireframesTestBase [Test] public void Circle_IsARingOfSegmentsFlatInXZ() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); Vector3 center = new(1f, 2f, 3f); ICircle circle = container.CreateCircle(center, 2f, 16); @@ -49,7 +49,7 @@ public void CreateCircle_WithoutArguments_IsHalfUnitRadiusAtOrigin() [Test] public void CreateCircleWithNormal_FacesTheNormal() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); Vector3 normal = new Vector3(1f, 1f, 0f).normalized; ICircle circle = container.CreateCircle(Vector3.zero, normal * 3f, 1f, 12); @@ -64,7 +64,7 @@ public void CreateCircleWithNormal_FacesTheNormal() [Test] public void CreateCircleOnBone_LiesFlatInTheBonesXZ() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); Transform bone = CreateBone(new Vector3(0f, 1f, 0f), Quaternion.Euler(0f, 0f, 90f)); ICircle circle = container.CreateCircle(bone, new Vector3(0f, 0.5f, 0f), 2f, 8); @@ -82,7 +82,7 @@ public void CreateCircleOnBone_LiesFlatInTheBonesXZ() [Test] public void Radius_RewritesTheRing() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); ICircle circle = container.CreateCircle(Vector3.zero, 1f, 4); circle.Radius = 3f; @@ -93,7 +93,7 @@ public void Radius_RewritesTheRing() [Test] public void FewerThanThreeSegments_Throw() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); Assert.Throws(() => container.CreateCircle(Vector3.zero, 1f, 2)); Assert.Throws(() => container.CreateCircle(Vector3.zero, 1f, -1)); diff --git a/UnityPackage/Tests/Runtime/ConeTests.cs b/UnityPackage/Tests/Runtime/ConeTests.cs index 696821b..b110be8 100644 --- a/UnityPackage/Tests/Runtime/ConeTests.cs +++ b/UnityPackage/Tests/Runtime/ConeTests.cs @@ -9,7 +9,7 @@ public class ConeTests : WireframesTestBase [Test] public void Cone_IsABaseRingJoinedToTheTipByFourLines() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); Vector3 tip = new(0f, 3f, 0f); Vector3 baseCenter = new(0f, 1f, 0f); @@ -48,7 +48,7 @@ public void CreateCone_WithoutArguments_PointsItsBaseAlongZ() [Test] public void CreateConeOnBone_PutsTheTipOnTheBone() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); Transform head = CreateBone(new Vector3(0f, 1.7f, 0f), Quaternion.Euler(0f, 90f, 0f)); ICone sight = container.CreateCone(head, Vector3.zero, new Vector3(0f, 0f, 10f), 3f, 4); @@ -71,7 +71,7 @@ public void PoseForm_RunsAlongTheRotationsZ() [Test] public void SegmentsThatAreNotAMultipleOfFour_Throw() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); Assert.Throws(() => container.CreateCone(Vector3.zero, Vector3.forward, 1f, 10)); Assert.That(ChunkOf(container).ShapeCount, Is.Zero); diff --git a/UnityPackage/Tests/Runtime/CylinderTests.cs b/UnityPackage/Tests/Runtime/CylinderTests.cs index c45ad31..94b4c9d 100644 --- a/UnityPackage/Tests/Runtime/CylinderTests.cs +++ b/UnityPackage/Tests/Runtime/CylinderTests.cs @@ -9,7 +9,7 @@ public class CylinderTests : WireframesTestBase [Test] public void Cylinder_IsTwoRingsJoinedByFourSideLines() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); Vector3 endA = new(1f, 0f, 0f); Vector3 endB = new(1f, 0f, 6f); ICylinder cylinder = container.CreateCylinder(endA, endB, 2f, 8); @@ -99,7 +99,7 @@ public void SettingEndOnTopOfEndA_KeepsRotation() [Test] public void CreateCylinderOnBone_FollowsTheBone() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); Transform bone = CreateBone(new Vector3(0f, 2f, 0f), Quaternion.Euler(0f, 90f, 0f), 2f); Vector3 localEndA = new(0f, 0f, 0.5f); Vector3 localEndB = new(0f, 1f, 0.5f); @@ -140,7 +140,7 @@ public void ChangingBone_ConvertsLengthAndRadius() [Test] public void SegmentsThatAreNotAMultipleOfFour_Throw() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); Assert.Throws(() => container.CreateCylinder(Vector3.zero, Vector3.up, 1f, 30)); Assert.Throws(() => container.CreateCylinder(Vector3.zero, Vector3.up, 1f, 0)); diff --git a/UnityPackage/Tests/Runtime/DegenerateShapeTests.cs b/UnityPackage/Tests/Runtime/DegenerateShapeTests.cs index 2b0d660..c082522 100644 --- a/UnityPackage/Tests/Runtime/DegenerateShapeTests.cs +++ b/UnityPackage/Tests/Runtime/DegenerateShapeTests.cs @@ -9,7 +9,7 @@ public class DegenerateShapeTests : WireframesTestBase [Test] public void ZeroAndNegativeSizes_NeverProduceInvalidVertices() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); List shapes = new() { container.CreateBox(Vector3.zero, Vector3.zero), @@ -49,7 +49,7 @@ public void ZeroAndNegativeSizes_NeverProduceInvalidVertices() [Test] public void SettingEndOnTopOfEndA_KeepsEveryLongShapeValid() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); IAxialShape[] shapes = { container.CreateCylinder(), container.CreateCone(), container.CreateCapsule(), diff --git a/UnityPackage/Tests/Runtime/EllipseTests.cs b/UnityPackage/Tests/Runtime/EllipseTests.cs index 626b123..1c8e4fb 100644 --- a/UnityPackage/Tests/Runtime/EllipseTests.cs +++ b/UnityPackage/Tests/Runtime/EllipseTests.cs @@ -9,7 +9,7 @@ public class EllipseTests : WireframesTestBase [Test] public void Ellipse_HasItsRadiiAlongXAndZ() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); Vector3 center = new(1f, 2f, 3f); IEllipse ellipse = container.CreateEllipse(center, Quaternion.identity, new Vector2(3f, 1f), 8); @@ -30,7 +30,7 @@ public void Ellipse_HasItsRadiiAlongXAndZ() [Test] public void CreateEllipseFromTips_RunsBetweenThemLyingFlat() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); IEllipse ellipse = container.CreateEllipse(new Vector3(-2f, 1f, 0f), new Vector3(2f, 1f, 0f), 0.5f, 8); @@ -48,7 +48,7 @@ public void CreateEllipseFromTips_RunsBetweenThemLyingFlat() [Test] public void CreateEllipseFromTipsWithNormal_FacesTheNormal() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); IEllipse ellipse = container.CreateEllipse(Vector3.zero, new Vector3(0f, 0f, 4f), 1f, Vector3.right, 8); @@ -83,7 +83,7 @@ public void CreateEllipse_WithoutArguments_IsOneUnitLongAlongZ() [Test] public void FewerThanThreeSegments_Throw() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); Assert.Throws( () => container.CreateEllipse(Vector3.zero, Quaternion.identity, Vector2.one, 2)); diff --git a/UnityPackage/Tests/Runtime/EllipsoidTests.cs b/UnityPackage/Tests/Runtime/EllipsoidTests.cs index a8b02f8..570e29c 100644 --- a/UnityPackage/Tests/Runtime/EllipsoidTests.cs +++ b/UnityPackage/Tests/Runtime/EllipsoidTests.cs @@ -9,7 +9,7 @@ public class EllipsoidTests : WireframesTestBase [Test] public void Ellipsoid_IsThreeEllipsesOnItsSurface() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); IEllipsoid ellipsoid = container.CreateEllipsoid(Vector3.zero, Quaternion.identity, new Vector3(1f, 2f, 3f), 8); @@ -42,7 +42,7 @@ public void CreateEllipsoidFromTips_IsARoundSpheroidBetweenThem() [Test] public void CreateEllipsoidOnBone_FollowsTheBone() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); Transform bone = CreateBone(Vector3.zero, Quaternion.identity); IEllipsoid ellipsoid = container.CreateEllipsoid(bone, new Vector3(0f, 0f, -1f), new Vector3(0f, 0f, 1f), 0.5f, 4); @@ -64,7 +64,7 @@ public void CreateEllipsoid_WithoutArguments_IsOneUnitLongAlongZ() [Test] public void FewerThanThreeSegments_Throw() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); Assert.Throws( () => container.CreateEllipsoid(Vector3.zero, Vector3.up, 1f, 1)); diff --git a/UnityPackage/Tests/Runtime/FrustumTests.cs b/UnityPackage/Tests/Runtime/FrustumTests.cs index 8538191..ea2b839 100644 --- a/UnityPackage/Tests/Runtime/FrustumTests.cs +++ b/UnityPackage/Tests/Runtime/FrustumTests.cs @@ -9,7 +9,7 @@ public class FrustumTests : WireframesTestBase [Test] public void Frustum_IsTwoPolygonsJoinedAtEveryCorner() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); IFrustum frustum = container.CreateFrustum(Vector3.zero, new Vector3(0f, 0f, 2f), 1f, 2f, 4); @@ -35,7 +35,7 @@ public void Frustum_IsTwoPolygonsJoinedAtEveryCorner() [Test] public void ThreeSides_HaveALevelBottomSide() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); IFrustum frustum = container.CreateFrustum(Vector3.zero, Quaternion.identity, 1f, 1f, 1f, 3); @@ -47,7 +47,7 @@ public void ThreeSides_HaveALevelBottomSide() [Test] public void RadiusAOfZero_MakesAPyramidWithARegularBase() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); IFrustum pyramid = container.CreateFrustum(Vector3.zero, new Vector3(0f, 0f, 1f), 0f, 1f, 5); @@ -84,7 +84,7 @@ public void CreateFrustumOnBone_FollowsTheBone() [Test] public void FewerThanThreeSides_Throw() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); Assert.Throws(() => container.CreateFrustum(Vector3.zero, Vector3.up, 1f, 1f, 2)); Assert.That(ChunkOf(container).ShapeCount, Is.Zero); diff --git a/UnityPackage/Tests/Runtime/LineContainerTests.cs b/UnityPackage/Tests/Runtime/LineContainerTests.cs deleted file mode 100644 index 4939e42..0000000 --- a/UnityPackage/Tests/Runtime/LineContainerTests.cs +++ /dev/null @@ -1,118 +0,0 @@ -using System; -using System.Collections; -using NUnit.Framework; -using UnityEngine; -using UnityEngine.SceneManagement; -using UnityEngine.TestTools; -using Object = UnityEngine.Object; -using Random = UnityEngine.Random; - -namespace reromanlee.Wireframes.Tests -{ - public class LineContainerTests : WireframesTestBase - { - private const string ShaderName = "reromanlee/Wireframes/VertexColors"; - - [Test] - public void Constructor_CreatesProxyInActiveSceneWithPackageShader() - { - LineContainer container = CreateContainer(); - - Assert.That(container.IsDisposed, Is.False); - Assert.That(container.Proxy.gameObject.scene, Is.EqualTo(SceneManager.GetActiveScene())); - Assert.That(ChunkOf(container).Renderer.sharedMaterial.shader.name, Is.EqualTo(ShaderName)); - } - - [Test] - public void Dispose_DisposesEveryShape() - { - LineContainer container = CreateContainer(); - ILine line = container.CreateLine(); - IBox box = container.CreateBox(); - - container.Dispose(); - - Assert.That(container.IsDisposed, Is.True); - Assert.That(line.IsDisposed, Is.True); - Assert.That(box.IsDisposed, Is.True); - Assert.Throws(() => line.ColorA = Color.red); - Assert.Throws(() => box.SetColor(Color.red)); - Assert.Throws(() => container.CreateLine()); - Assert.DoesNotThrow(() => line.Dispose()); - Assert.DoesNotThrow(() => container.Dispose()); - } - - [UnityTest] - public IEnumerator DestroyingProxyObject_DisposesContainer() - { - LineContainer container = CreateContainer(); - ILine line = container.CreateLine(); - - Object.Destroy(container.Proxy.gameObject); - yield return null; - - Assert.That(container.IsDisposed, Is.True); - Assert.That(line.IsDisposed, Is.True); - } - - [UnityTest] - public IEnumerator UnloadingScene_DisposesContainer() - { - Scene previous = SceneManager.GetActiveScene(); - Scene scene = SceneManager.CreateScene("WireframesUnloadTest"); - SceneManager.SetActiveScene(scene); - LineContainer container = CreateContainer(); - ILine line = container.CreateLine(); - SceneManager.SetActiveScene(previous); - - yield return SceneManager.UnloadSceneAsync(scene); - - Assert.That(container.IsDisposed, Is.True); - Assert.That(line.IsDisposed, Is.True); - } - - [UnityTest] - public IEnumerator Dispose_LeavesCallerMaterialAlive() - { - Material material = Track(new Material(Shader.Find(ShaderName))); - LineContainer container = CreateContainer(material); - - container.Dispose(); - yield return null; - - Assert.That(material != null, Is.True); - } - - [UnityTest] - public IEnumerator Rendering_LogsNoErrorsWhileBuffersGrow() - { - // The test framework fails a test on any logged error, e.g. a bone/bind pose mismatch while rendering. - // The shapes added here outgrow 16-bit indices, so the index format switches while a camera renders. - Camera camera = Track(new GameObject("Camera")).AddComponent(); - camera.transform.position = new Vector3(0f, 0f, -20f); - LineContainer container = CreateContainer(); - Transform bone = CreateBone(Vector3.zero, Quaternion.identity); - - for (int frame = 0; frame < 6; frame++) - { - for (int i = 0; i < 1000 * (frame + 1); i++) - { - ILine line = container.CreateLine(Random.insideUnitSphere * 5f, Random.insideUnitSphere * 5f); - if (i % 3 == 0) - { - line.BoneA = bone; - } - if (i % 7 == 0) - { - container.CreateBox(Random.insideUnitSphere, Random.insideUnitSphere).Bone = bone; - } - } - bone.position += Vector3.right; - yield return null; - } - - Assert.That(ChunkOf(container).ShapeCount, Is.GreaterThan(21000)); - Assert.That(ChunkOf(container).Mesh.indexFormat, Is.EqualTo(UnityEngine.Rendering.IndexFormat.UInt32)); - } - } -} diff --git a/UnityPackage/Tests/Runtime/LineTests.cs b/UnityPackage/Tests/Runtime/LineTests.cs index 379664b..2cc2105 100644 --- a/UnityPackage/Tests/Runtime/LineTests.cs +++ b/UnityPackage/Tests/Runtime/LineTests.cs @@ -24,7 +24,7 @@ public void CreateLine_StartsWhiteInWorldSpace() [Test] public void CreateLineFromBones_PutsEndpointsOnTheBones() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); Transform shoulder = CreateBone(new Vector3(0f, 1.5f, 0f), Quaternion.Euler(0f, 0f, 45f)); Transform elbow = CreateBone(new Vector3(0.5f, 1.2f, 0f), Quaternion.identity); @@ -72,7 +72,7 @@ public void ChangingBone_KeepsWorldPosition() [Test] public void Skinning_MovesEndpointsWithTheirBones() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); Transform boneA = CreateBone(new Vector3(-3f, 0f, 0f), Quaternion.identity); Transform boneB = CreateBone(new Vector3(3f, 0f, 0f), Quaternion.identity); ILine line = container.CreateLine(); @@ -97,7 +97,7 @@ public void Skinning_MovesEndpointsWithTheirBones() [Test] public void Colors_AreUploadedPerEndpoint() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); ILine line = container.CreateLine(); line.ColorA = Color.red; line.ColorB = new Color(0f, 0.5f, 1f, 1f); @@ -119,7 +119,7 @@ public void Colors_AreUploadedPerEndpoint() [Test] public void SharedBone_IsRegisteredOnceAndReleasedWithLastUse() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); BoneRegistry bones = ChunkOf(container).Bones; Transform bone = CreateBone(Vector3.zero, Quaternion.identity); ILine first = container.CreateLine(); @@ -157,7 +157,7 @@ public IEnumerator ReleasingLastUse_RemovesHiddenWatcher() [UnityTest] public IEnumerator DestroyedBone_LeavesEndpointInPlace() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); Transform bone = CreateBone(new Vector3(2f, 0f, 0f), Quaternion.Euler(0f, 90f, 0f)); ILine line = container.CreateLine(); line.BoneA = bone; @@ -213,7 +213,7 @@ public IEnumerator ReattachingInTheSameFrame_KeepsWatchingTheBone() [UnityTest] public IEnumerator BoneDestroyedBeforeItWasActive_FallsBackToLocalOffset() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); Transform bone = CreateBone(new Vector3(9f, 9f, 9f), Quaternion.identity); bone.gameObject.SetActive(false); ILine line = container.CreateLine(); diff --git a/UnityPackage/Tests/Runtime/MeshBufferTests.cs b/UnityPackage/Tests/Runtime/MeshBufferTests.cs index bf5cf8b..3e46edf 100644 --- a/UnityPackage/Tests/Runtime/MeshBufferTests.cs +++ b/UnityPackage/Tests/Runtime/MeshBufferTests.cs @@ -11,7 +11,7 @@ public class MeshBufferTests : WireframesTestBase public void DisposingShapes_KeepsRemainingEdgesOnTheirOwnVertices() { // Mixing sizes makes removals move edges between different owners. - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); List shapes = new(); for (int i = 0; i < 30; i++) { @@ -53,7 +53,7 @@ public void DisposingShapes_KeepsRemainingEdgesOnTheirOwnVertices() [Test] public void FreedBlock_IsReusedByShapeOfSameSize() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); ILine first = container.CreateLine(); container.CreateLine(); int start = ((Line)first).VertexStart; @@ -69,7 +69,7 @@ public void FreedBlock_IsReusedByShapeOfSameSize() [Test] public void FreedBlocks_AreCompactedOnceTheyFillHalfTheBuffer() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); List lines = new(); for (int i = 0; i < 2000; i++) { @@ -99,7 +99,7 @@ public void FreedBlocks_AreCompactedOnceTheyFillHalfTheBuffer() [Test] public void ManyVertices_SwitchTheIndexBufferTo32Bit() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); container.Proxy.Flush(); Assert.That(ChunkOf(container).Mesh.indexFormat, Is.EqualTo(IndexFormat.UInt16)); @@ -121,7 +121,7 @@ public void ManyVertices_SwitchTheIndexBufferTo32Bit() [Test] public void ManyBones_GrowTheBoneArrayWithMatchingBindposes() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); List lines = new(); for (int i = 0; i < 100; i++) { diff --git a/UnityPackage/Tests/Runtime/PerformanceTests.cs b/UnityPackage/Tests/Runtime/PerformanceTests.cs index fb8679b..4bc5349 100644 --- a/UnityPackage/Tests/Runtime/PerformanceTests.cs +++ b/UnityPackage/Tests/Runtime/PerformanceTests.cs @@ -21,7 +21,7 @@ public void LogLineTimings() Transform[] bones = CreateBones(); // Warm-up so JIT compilation isn't counted. - LineContainer warmUp = CreateContainer(); + WireframeContainer warmUp = CreateContainer(); for (int i = 0; i < 100; i++) { warmUp.CreateLine().BoneA = bones[i % BoneCount]; @@ -29,7 +29,7 @@ public void LogLineTimings() warmUp.Proxy.Flush(); warmUp.Dispose(); - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); ILine[] lines = new ILine[LineCount]; Stopwatch stopwatch = Stopwatch.StartNew(); for (int i = 0; i < LineCount; i++) @@ -74,7 +74,7 @@ public void LogSphereTimings() Transform[] bones = CreateBones(); // Warm-up so JIT compilation isn't counted. - LineContainer warmUp = CreateContainer(); + WireframeContainer warmUp = CreateContainer(); for (int i = 0; i < 100; i++) { warmUp.CreateSphere(bones[i % BoneCount], Vector3.zero, 0.5f).Radius = 1f; @@ -82,7 +82,7 @@ public void LogSphereTimings() warmUp.Proxy.Flush(); warmUp.Dispose(); - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); ISphere[] spheres = new ISphere[SphereCount]; Stopwatch stopwatch = Stopwatch.StartNew(); for (int i = 0; i < SphereCount; i++) @@ -134,7 +134,7 @@ private Transform[] CreateBones() } /// Milliseconds per flush when nothing changed. - private static double TimeIdleFlushes(LineContainer container, Stopwatch stopwatch) + private static double TimeIdleFlushes(WireframeContainer container, Stopwatch stopwatch) { stopwatch.Restart(); for (int i = 0; i < IdleFlushes; i++) @@ -145,7 +145,7 @@ private static double TimeIdleFlushes(LineContainer container, Stopwatch stopwat } /// Milliseconds for the flush after every bone moved. - private static double TimeFlushAfterMoving(Transform[] bones, LineContainer container, Stopwatch stopwatch) + private static double TimeFlushAfterMoving(Transform[] bones, WireframeContainer container, Stopwatch stopwatch) { foreach (Transform bone in bones) { diff --git a/UnityPackage/Tests/Runtime/PolylineTests.cs b/UnityPackage/Tests/Runtime/PolylineTests.cs index daf4078..22a9494 100644 --- a/UnityPackage/Tests/Runtime/PolylineTests.cs +++ b/UnityPackage/Tests/Runtime/PolylineTests.cs @@ -18,7 +18,7 @@ public class PolylineTests : WireframesTestBase [Test] public void OpenPolyline_JoinsPointsInOrder() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); IPolyline polyline = container.CreatePolyline(Square); (Vector3 A, Vector3 B)[] edges = BakeEdges(container, polyline); @@ -36,7 +36,7 @@ public void OpenPolyline_JoinsPointsInOrder() [Test] public void Polygon_ClosesBackToTheFirstPoint() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); IPolyline polygon = container.CreatePolygon(Square); (Vector3 A, Vector3 B)[] edges = BakeEdges(container, polygon); @@ -50,7 +50,7 @@ public void Polygon_ClosesBackToTheFirstPoint() [Test] public void Triangle_IsAClosedPolylineOfThreePoints() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); IPolyline triangle = container.CreateTriangle(Vector3.zero, Vector3.right, Vector3.up); Assert.That(triangle.PointCount, Is.EqualTo(3)); @@ -74,7 +74,7 @@ public void CreatePolyline_CopiesTheList() [Test] public void TooFewPoints_Throw() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); Assert.Throws(() => container.CreatePolyline(Vector3.zero)); Assert.Throws(() => container.CreatePolygon(Vector3.zero, Vector3.one)); @@ -95,7 +95,7 @@ public void PointIndexOutOfRange_Throws() [Test] public void CreatePolylineFromBones_PutsPointsOnTheBones() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); Transform hips = CreateBone(new Vector3(0f, 1f, 0f), Quaternion.identity); Transform chest = CreateBone(new Vector3(0f, 1.5f, 0f), Quaternion.identity); Transform head = CreateBone(new Vector3(0f, 2f, 0f), Quaternion.identity); @@ -128,7 +128,7 @@ public void SetBone_KeepsTheWorldPosition() [Test] public void Colors_AreUploadedPerPoint() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); IPolyline polyline = container.CreatePolyline(Square); int start = ((Polyline)polyline).VertexStart; @@ -148,7 +148,7 @@ public void Colors_AreUploadedPerPoint() [Test] public void Dispose_ReleasesEveryBone() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); Transform bone = CreateBone(Vector3.zero, Quaternion.identity); IPolyline polyline = container.CreatePolygon(bone, bone, CreateBone(Vector3.one, Quaternion.identity)); Assert.That(ChunkOf(container).Bones.Count, Is.EqualTo(2)); diff --git a/UnityPackage/Tests/Runtime/PyramidTests.cs b/UnityPackage/Tests/Runtime/PyramidTests.cs index f4fbbf0..36d5b38 100644 --- a/UnityPackage/Tests/Runtime/PyramidTests.cs +++ b/UnityPackage/Tests/Runtime/PyramidTests.cs @@ -8,7 +8,7 @@ public class PyramidTests : WireframesTestBase [Test] public void Pyramid_IsARectangularBaseJoinedToTheTip() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); IPyramid pyramid = container.CreatePyramid(Vector3.zero, new Vector3(0f, 0f, 2f), new Vector2(2f, 1f)); @@ -28,7 +28,7 @@ public void Pyramid_IsARectangularBaseJoinedToTheTip() [Test] public void PointingDown_KeepsTheBaseAlignedWithTheWorldAxes() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); IPyramid pyramid = container.CreatePyramid(new Vector3(0f, 3f, 0f), Vector3.zero, new Vector2(2f, 2f)); @@ -53,7 +53,7 @@ public void CreatePyramid_WithoutArguments_HasAUnitBaseOneUnitAlongZ() [Test] public void CreatePyramidOnBone_PutsTheTipOnTheBone() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); Transform eye = CreateBone(new Vector3(0f, 2f, 0f), Quaternion.Euler(0f, -90f, 0f)); IPyramid view = container.CreatePyramid(eye, Vector3.zero, new Vector3(0f, 0f, 5f), new Vector2(4f, 3f)); diff --git a/UnityPackage/Tests/Runtime/RectangleTests.cs b/UnityPackage/Tests/Runtime/RectangleTests.cs index 105481c..c62fbcf 100644 --- a/UnityPackage/Tests/Runtime/RectangleTests.cs +++ b/UnityPackage/Tests/Runtime/RectangleTests.cs @@ -8,7 +8,7 @@ public class RectangleTests : WireframesTestBase [Test] public void Rectangle_IsFourCornersFlatInXZ() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); IRectangle rectangle = container.CreateRectangle(new Vector3(0f, 1f, 0f), new Vector3(2f, 1f, 3f)); Vector3[] corners = BakeShape(container, rectangle); @@ -57,7 +57,7 @@ public void SettingCorner_MovesItOntoThePlaneAndKeepsTheOtherCorner() [Test] public void CreateRectangleOnBone_LiesFlatInTheBonesXZ() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); Transform bone = CreateBone(new Vector3(0f, 2f, 0f), Quaternion.Euler(90f, 0f, 0f)); IRectangle rectangle = container.CreateRectangle(bone, new Vector3(-1f, 0f, -1f), new Vector3(1f, 0f, 1f)); diff --git a/UnityPackage/Tests/Runtime/RigidShapeTests.cs b/UnityPackage/Tests/Runtime/RigidShapeTests.cs index f3437ea..947a93a 100644 --- a/UnityPackage/Tests/Runtime/RigidShapeTests.cs +++ b/UnityPackage/Tests/Runtime/RigidShapeTests.cs @@ -43,7 +43,7 @@ public void WorldPose_IsConvertedThroughBone() [Test] public void ChangingBone_KeepsWorldPositionRotationAndSize() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); Quaternion rotation = Quaternion.Euler(10f, 20f, 30f); ICircle circle = container.CreateCircle(new Vector3(1f, 2f, 3f), rotation, 5f); Vector3[] before = BakeShape(container, circle); @@ -78,7 +78,7 @@ public void ChangingBone_ConvertsSizeByTheBonesAverageScale() [Test] public void Shape_MovesTurnsAndScalesWithItsBone() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); Transform bone = CreateBone(Vector3.zero, Quaternion.identity); ICircle circle = container.CreateCircle(bone, new Vector3(0f, 1f, 0f), 2f, 8); @@ -114,7 +114,7 @@ public IEnumerator DestroyedBone_LeavesShapeInPlaceAtItsSize() [Test] public void Color_IsUploadedToEveryVertex() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); ICircle circle = container.CreateCircle(Vector3.zero, 1f, 6); int start = ((Circle)circle).VertexStart; diff --git a/UnityPackage/Tests/Runtime/RoundedRectangleTests.cs b/UnityPackage/Tests/Runtime/RoundedRectangleTests.cs index a94bcb6..1736465 100644 --- a/UnityPackage/Tests/Runtime/RoundedRectangleTests.cs +++ b/UnityPackage/Tests/Runtime/RoundedRectangleTests.cs @@ -9,7 +9,7 @@ public class RoundedRectangleTests : WireframesTestBase [Test] public void RoundedRectangle_IsFourQuarterCirclesJoinedBySides() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); IRoundedRectangle rectangle = container.CreateRoundedRectangle(new Vector3(-2f, 0f, -1f), new Vector3(2f, 0f, 1f), 0.5f, 8); @@ -37,7 +37,7 @@ public void RoundedRectangle_IsFourQuarterCirclesJoinedBySides() [Test] public void CornerRadius_IsDrawnClampedToHalfTheShorterSide() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); IRoundedRectangle rectangle = container.CreateRoundedRectangle(Vector3.zero, Quaternion.identity, new Vector2(4f, 2f), 5f, 8); @@ -54,7 +54,7 @@ public void CornerRadius_IsDrawnClampedToHalfTheShorterSide() [Test] public void NegativeCornerRadius_DrawsSharpCorners() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); IRoundedRectangle rectangle = container.CreateRoundedRectangle(Vector3.zero, Quaternion.identity, new Vector2(2f, 2f), -1f, 4); @@ -100,7 +100,7 @@ public void ChangingBone_ConvertsSizeAndCornerRadius() [Test] public void SegmentsThatAreNotAMultipleOfFour_Throw() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); Assert.Throws( () => container.CreateRoundedRectangle(Vector3.zero, Vector3.one, 0.1f, 6)); diff --git a/UnityPackage/Tests/Runtime/SphereTests.cs b/UnityPackage/Tests/Runtime/SphereTests.cs index d28bae1..a898ae5 100644 --- a/UnityPackage/Tests/Runtime/SphereTests.cs +++ b/UnityPackage/Tests/Runtime/SphereTests.cs @@ -9,7 +9,7 @@ public class SphereTests : WireframesTestBase [Test] public void Sphere_IsThreeGreatCirclesTurnedWithTheShape() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); Vector3 center = new(0f, 5f, 0f); Quaternion rotation = Quaternion.Euler(0f, 0f, 30f); ISphere sphere = container.CreateSphere(center, rotation, 2f, 8); @@ -46,7 +46,7 @@ public void CreateSphere_WithoutArguments_IsHalfUnitRadiusAtOrigin() [Test] public void CreateSphereOnBone_FollowsTheBone() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); Transform bone = CreateBone(new Vector3(-2f, 0f, 0f), Quaternion.Euler(45f, 0f, 0f)); ISphere sphere = container.CreateSphere(bone, new Vector3(0f, 0f, 1f), 0.5f, 4); @@ -60,7 +60,7 @@ public void CreateSphereOnBone_FollowsTheBone() [Test] public void FewerThanThreeSegments_Throw() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); Assert.Throws(() => container.CreateSphere(Vector3.zero, 1f, 0)); Assert.That(ChunkOf(container).ShapeCount, Is.Zero); diff --git a/UnityPackage/Tests/Runtime/SpikedSphereTests.cs b/UnityPackage/Tests/Runtime/SpikedSphereTests.cs index 6782bf2..e12c985 100644 --- a/UnityPackage/Tests/Runtime/SpikedSphereTests.cs +++ b/UnityPackage/Tests/Runtime/SpikedSphereTests.cs @@ -14,7 +14,7 @@ public class SpikedSphereTests : WireframesTestBase [TestCase(20, 12, 30, 3, TestName = "Icosahedron")] public void SpikedSphere_PutsASpikeOnEveryFaceOfItsSolid(int spikes, int corners, int solidEdges, int faceSize) { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); ISpikedSphere sphere = container.CreateSpikedSphere(Vector3.zero, 1f, 0.5f, spikes); @@ -62,7 +62,7 @@ public void SpikedSphere_PutsASpikeOnEveryFaceOfItsSolid(int spikes, int corners [Test] public void CubeSpikes_PointAlongTheAxes() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); ISpikedSphere sphere = container.CreateSpikedSphere(Vector3.zero, 1f, 1f, 6); @@ -103,7 +103,7 @@ public void ChangingBone_ConvertsBaseRadiusAndSpikeLength() [TestCase(24)] public void SpikeCountThatIsNotAPlatonicSolid_Throws(int spikes) { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); Assert.Throws(() => container.CreateSpikedSphere(Vector3.zero, 1f, 1f, spikes)); Assert.That(ChunkOf(container).ShapeCount, Is.Zero); diff --git a/UnityPackage/Tests/Runtime/StadiumTests.cs b/UnityPackage/Tests/Runtime/StadiumTests.cs index db7d784..584ca22 100644 --- a/UnityPackage/Tests/Runtime/StadiumTests.cs +++ b/UnityPackage/Tests/Runtime/StadiumTests.cs @@ -9,7 +9,7 @@ public class StadiumTests : WireframesTestBase [Test] public void Stadium_IsTwoArcsJoinedByStraightSides() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); IStadium stadium = container.CreateStadium(Vector3.zero, new Vector3(0f, 0f, 4f), 1f, 8); @@ -35,7 +35,7 @@ public void Stadium_IsTwoArcsJoinedByStraightSides() [Test] public void TaperedStadium_SidesTouchBothCircles() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); Vector3 centerB = new(0f, 0f, 4f); IStadium stadium = container.CreateStadium(Vector3.zero, centerB, 1f, 0.5f, 8); @@ -58,7 +58,7 @@ public void TaperedStadium_SidesTouchBothCircles() [Test] public void CreateStadiumWithNormal_FacesTheNormal() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); IStadium stadium = container.CreateStadium(Vector3.zero, new Vector3(4f, 0f, 0f), 1f, 1f, Vector3.forward, 8); @@ -72,7 +72,7 @@ public void CreateStadiumWithNormal_FacesTheNormal() [Test] public void CircleInsideTheOther_DrawsOnlyTheBiggerCircle() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); IStadium stadium = container.CreateStadium(Vector3.zero, new Vector3(0f, 0f, 1f), 0.5f, 3f, 8); @@ -85,7 +85,7 @@ public void CircleInsideTheOther_DrawsOnlyTheBiggerCircle() [Test] public void CreateStadiumOnBone_LiesFlatInTheBonesXZ() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); Transform bone = CreateBone(new Vector3(0f, 1f, 0f), Quaternion.Euler(0f, 0f, 30f)); IStadium stadium = container.CreateStadium(bone, Vector3.zero, new Vector3(2f, 0f, 0f), 0.5f, 0.5f, 8); @@ -111,7 +111,7 @@ public void CreateStadium_WithoutArguments_HasHalfUnitCirclesOneUnitApart() [Test] public void SegmentsThatAreNotAMultipleOfFour_Throw() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); Assert.Throws(() => container.CreateStadium(Vector3.zero, Vector3.right, 1f, 2)); Assert.That(ChunkOf(container).ShapeCount, Is.Zero); diff --git a/UnityPackage/Tests/Runtime/StarTests.cs b/UnityPackage/Tests/Runtime/StarTests.cs index 209d1bf..ea2f736 100644 --- a/UnityPackage/Tests/Runtime/StarTests.cs +++ b/UnityPackage/Tests/Runtime/StarTests.cs @@ -9,7 +9,7 @@ public class StarTests : WireframesTestBase [Test] public void Star_AlternatesTipsAndInnerCorners() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); Vector3 center = new(0f, 1f, 0f); IStar star = container.CreateStar(center, 0.4f, 1f, 5); @@ -32,7 +32,7 @@ public void Star_AlternatesTipsAndInnerCorners() [Test] public void PoseForm_PointsTheFirstTipAlongTheRotationsZ() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); Quaternion rotation = Quaternion.Euler(-90f, 0f, 0f); IStar star = container.CreateStar(Vector3.zero, rotation, 0.5f, 2f, 3); @@ -64,7 +64,7 @@ public void ChangingBone_ConvertsBothRadii() [Test] public void FewerThanThreePoints_Throw() { - LineContainer container = CreateContainer(); + WireframeContainer container = CreateContainer(); Assert.Throws(() => container.CreateStar(Vector3.zero, 0.5f, 1f, 2)); Assert.Throws(() => container.CreateStar(Vector3.zero, 0.5f, 1f, -1)); diff --git a/UnityPackage/Tests/Runtime/WireframeContainerTests.cs b/UnityPackage/Tests/Runtime/WireframeContainerTests.cs new file mode 100644 index 0000000..445eda7 --- /dev/null +++ b/UnityPackage/Tests/Runtime/WireframeContainerTests.cs @@ -0,0 +1,261 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Reflection; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.SceneManagement; +using UnityEngine.TestTools; +using Object = UnityEngine.Object; +using Random = UnityEngine.Random; + +namespace reromanlee.Wireframes.Tests +{ + public class WireframeContainerTests : WireframesTestBase + { + private const string ShaderName = "reromanlee/Wireframes/VertexColors"; + + [Test] + public void Constructor_CreatesProxyInActiveSceneWithPackageShader() + { + WireframeContainer container = CreateContainer(); + + Assert.That(container.IsDisposed, Is.False); + Assert.That(container.Proxy.gameObject.scene, Is.EqualTo(SceneManager.GetActiveScene())); + Assert.That(ChunkOf(container).Renderer.sharedMaterial.shader.name, Is.EqualTo(ShaderName)); + } + + [Test] + public void Dispose_DisposesEveryShape() + { + WireframeContainer container = CreateContainer(); + ILine line = container.CreateLine(); + IBox box = container.CreateBox(); + + container.Dispose(); + + Assert.That(container.IsDisposed, Is.True); + Assert.That(line.IsDisposed, Is.True); + Assert.That(box.IsDisposed, Is.True); + Assert.Throws(() => line.ColorA = Color.red); + Assert.Throws(() => box.SetColor(Color.red)); + Assert.Throws(() => container.CreateLine()); + Assert.DoesNotThrow(() => line.Dispose()); + Assert.DoesNotThrow(() => container.Dispose()); + } + + [UnityTest] + public IEnumerator DestroyingProxyObject_DisposesContainer() + { + WireframeContainer container = CreateContainer(); + ILine line = container.CreateLine(); + + Object.Destroy(container.Proxy.gameObject); + yield return null; + + Assert.That(container.IsDisposed, Is.True); + Assert.That(line.IsDisposed, Is.True); + } + + [UnityTest] + public IEnumerator UnloadingScene_DisposesContainer() + { + Scene previous = SceneManager.GetActiveScene(); + Scene scene = SceneManager.CreateScene("WireframesUnloadTest"); + SceneManager.SetActiveScene(scene); + WireframeContainer container = CreateContainer(); + ILine line = container.CreateLine(); + SceneManager.SetActiveScene(previous); + + yield return SceneManager.UnloadSceneAsync(scene); + + Assert.That(container.IsDisposed, Is.True); + Assert.That(line.IsDisposed, Is.True); + } + + [UnityTest] + public IEnumerator PersistAcrossScenes_KeepsContainerWhenItsSceneUnloads() + { + Scene previous = SceneManager.GetActiveScene(); + Scene scene = SceneManager.CreateScene("WireframesPersistTest"); + SceneManager.SetActiveScene(scene); + WireframeContainer container = CreateContainer(new WireframeContainerSettings { PersistAcrossScenes = true }); + ILine line = container.CreateLine(); + SceneManager.SetActiveScene(previous); + + yield return SceneManager.UnloadSceneAsync(scene); + + Assert.That(container.IsDisposed, Is.False); + Assert.That(line.IsDisposed, Is.False); + Assert.That(container.Proxy.gameObject.scene.name, Is.EqualTo("DontDestroyOnLoad")); + } + + [UnityTest] + public IEnumerator Dispose_LeavesCallerMaterialAlive() + { + Material material = Track(new Material(Shader.Find(ShaderName))); + WireframeContainer container = CreateContainer(new WireframeContainerSettings { Material = material }); + + Assert.That(ChunkOf(container).Renderer.sharedMaterial, Is.SameAs(material)); + container.Dispose(); + yield return null; + + Assert.That(material != null, Is.True); + } + + [Test] + public void Settings_NameAndLayer_ApplyToTheContainersGameObjects() + { + WireframeContainer container = CreateContainer(new WireframeContainerSettings { Name = "Debug Lines", Layer = 5 }); + + Assert.That(container.Proxy.gameObject.name, Is.EqualTo("Debug Lines")); + Assert.That(container.Proxy.gameObject.layer, Is.EqualTo(5)); + Assert.That(ChunkOf(container).Renderer.gameObject.layer, Is.EqualTo(5)); + } + + [Test] + public void Settings_EmptyName_UsesTheDefaultName() + { + WireframeContainer container = CreateContainer(new WireframeContainerSettings { Name = "" }); + + Assert.That(container.Proxy.gameObject.name, Is.EqualTo("Wireframes")); + } + + [Test] + public void Settings_OutOfRange_ThrowBeforeAnythingIsCreated() + { + int objectsBefore = Object.FindObjectsByType(FindObjectsSortMode.None).Length; + + Assert.Throws(() => CreateContainer(new WireframeContainerSettings { Layer = 32 })); + Assert.Throws(() => CreateContainer(new WireframeContainerSettings { Layer = -1 })); + Assert.Throws( + () => CreateContainer(new WireframeContainerSettings { VertexCapacity = -1 })); + Assert.Throws( + () => CreateContainer(new WireframeContainerSettings { EdgeCapacity = -1 })); + Assert.That(Object.FindObjectsByType(FindObjectsSortMode.None), Has.Length.EqualTo(objectsBefore)); + } + + [Test] + public void Settings_Capacity_IsReservedSoAKnownLoadNeverGrowsTheBuffers() + { + WireframeContainer container = CreateContainer( + new WireframeContainerSettings { VertexCapacity = 10000, EdgeCapacity = 5000 }); + MeshChunk chunk = ChunkOf(container); + int vertexCapacity = chunk.VertexCapacity; + int edgeCapacity = chunk.EdgeCapacity; + int meshVertexCount = chunk.Mesh.vertexCount; + + for (int i = 0; i < 5000; i++) + { + container.CreateLine(new Vector3(i, 0f, 0f), new Vector3(i, 1f, 0f)); + } + container.Proxy.Flush(); + + Assert.That(vertexCapacity, Is.GreaterThanOrEqualTo(10000)); + Assert.That(edgeCapacity, Is.GreaterThanOrEqualTo(5000)); + Assert.That(chunk.VertexCapacity, Is.EqualTo(vertexCapacity)); + Assert.That(chunk.EdgeCapacity, Is.EqualTo(edgeCapacity)); + Assert.That(chunk.Mesh.vertexCount, Is.EqualTo(meshVertexCount)); + } + + [Test] + public void EveryFactoryMethod_RejectsANullContainer() + { + foreach (MethodInfo method in FactoryMethods()) + { + TargetInvocationException exception = + Assert.Throws(() => method.Invoke(null, Arguments(method, null))); + Assert.That(exception.InnerException, Is.TypeOf(), Describe(method)); + Assert.That(((ArgumentNullException)exception.InnerException).ParamName, Is.EqualTo("container")); + } + } + + [Test] + public void EveryFactoryMethod_RejectsADisposedContainer() + { + WireframeContainer container = CreateContainer(); + container.Dispose(); + + foreach (MethodInfo method in FactoryMethods()) + { + TargetInvocationException exception = + Assert.Throws(() => method.Invoke(null, Arguments(method, container))); + Assert.That(exception.InnerException, Is.TypeOf(), Describe(method)); + } + } + + /// Every public extension method on in the package. + private static List FactoryMethods() + { + List methods = new(); + foreach (Type type in typeof(WireframeContainer).Assembly.GetExportedTypes()) + { + if (!type.IsAbstract || !type.IsSealed || !type.Name.EndsWith("Factory", StringComparison.Ordinal)) + { + continue; + } + foreach (MethodInfo method in type.GetMethods(BindingFlags.Public | BindingFlags.Static)) + { + ParameterInfo[] parameters = method.GetParameters(); + if (parameters.Length > 0 && parameters[0].ParameterType == typeof(WireframeContainer)) + { + methods.Add(method); + } + } + } + Assert.That(methods, Has.Count.GreaterThan(70), "The factory classes weren't found."); + return methods; + } + + /// The container, then a default value for every other parameter; the container is checked first. + private static object[] Arguments(MethodInfo method, WireframeContainer container) + { + ParameterInfo[] parameters = method.GetParameters(); + object[] arguments = new object[parameters.Length]; + arguments[0] = container; + for (int i = 1; i < parameters.Length; i++) + { + Type type = parameters[i].ParameterType; + arguments[i] = type.IsValueType ? Activator.CreateInstance(type) : null; + } + return arguments; + } + + private static string Describe(MethodInfo method) + { + return $"{method.DeclaringType?.Name}.{method.Name}({method.GetParameters().Length} parameters)"; + } + + [UnityTest] + public IEnumerator Rendering_LogsNoErrorsWhileBuffersGrow() + { + // The test framework fails a test on any logged error, e.g. a bone/bind pose mismatch while rendering. + // The shapes added here outgrow 16-bit indices, so the index format switches while a camera renders. + Camera camera = Track(new GameObject("Camera")).AddComponent(); + camera.transform.position = new Vector3(0f, 0f, -20f); + WireframeContainer container = CreateContainer(); + Transform bone = CreateBone(Vector3.zero, Quaternion.identity); + + for (int frame = 0; frame < 6; frame++) + { + for (int i = 0; i < 1000 * (frame + 1); i++) + { + ILine line = container.CreateLine(Random.insideUnitSphere * 5f, Random.insideUnitSphere * 5f); + if (i % 3 == 0) + { + line.BoneA = bone; + } + if (i % 7 == 0) + { + container.CreateBox(Random.insideUnitSphere, Random.insideUnitSphere).Bone = bone; + } + } + bone.position += Vector3.right; + yield return null; + } + + Assert.That(ChunkOf(container).ShapeCount, Is.GreaterThan(21000)); + Assert.That(ChunkOf(container).Mesh.indexFormat, Is.EqualTo(UnityEngine.Rendering.IndexFormat.UInt32)); + } + } +} diff --git a/UnityPackage/Tests/Runtime/LineContainerTests.cs.meta b/UnityPackage/Tests/Runtime/WireframeContainerTests.cs.meta similarity index 100% rename from UnityPackage/Tests/Runtime/LineContainerTests.cs.meta rename to UnityPackage/Tests/Runtime/WireframeContainerTests.cs.meta diff --git a/UnityPackage/Tests/Runtime/WireframesTestBase.cs b/UnityPackage/Tests/Runtime/WireframesTestBase.cs index 4e90206..504adfd 100644 --- a/UnityPackage/Tests/Runtime/WireframesTestBase.cs +++ b/UnityPackage/Tests/Runtime/WireframesTestBase.cs @@ -10,13 +10,13 @@ public abstract class WireframesTestBase { private const float Tolerance = 1e-4f; - private readonly List _containers = new(); + private readonly List _containers = new(); private readonly List _objects = new(); [TearDown] public void DestroyTestObjects() { - foreach (LineContainer container in _containers) + foreach (WireframeContainer container in _containers) { container.Dispose(); } @@ -31,9 +31,9 @@ public void DestroyTestObjects() _objects.Clear(); } - protected LineContainer CreateContainer(Material material = null) + protected WireframeContainer CreateContainer(WireframeContainerSettings settings = null) { - LineContainer container = material == null ? new LineContainer() : new LineContainer(material); + WireframeContainer container = new(settings); _containers.Add(container); return container; } @@ -54,13 +54,13 @@ protected T Track(T target) where T : Object return target; } - private protected static MeshChunk ChunkOf(LineContainer container) + private protected static MeshChunk ChunkOf(WireframeContainer container) { return container.Proxy.Chunks[0]; } /// Applies pending edits, then skins the mesh on the CPU and returns world-space vertices. - protected static Vector3[] FlushAndBake(LineContainer container) + protected static Vector3[] FlushAndBake(WireframeContainer container) { container.Proxy.Flush(); Mesh baked = new(); @@ -71,7 +71,7 @@ protected static Vector3[] FlushAndBake(LineContainer container) } /// Applies pending edits, then skins the mesh on the CPU and returns the shape's world-space vertices. - protected static Vector3[] BakeShape(LineContainer container, IShape shape) + protected static Vector3[] BakeShape(WireframeContainer container, IShape shape) { Vector3[] baked = FlushAndBake(container); Shape target = (Shape)shape; @@ -81,7 +81,7 @@ protected static Vector3[] BakeShape(LineContainer container, IShape shape) } /// Applies pending edits, then skins the mesh on the CPU and returns the world-space ends of the shape's edges. - protected static (Vector3 A, Vector3 B)[] BakeEdges(LineContainer container, IShape shape) + protected static (Vector3 A, Vector3 B)[] BakeEdges(WireframeContainer container, IShape shape) { Vector3[] baked = FlushAndBake(container); int[] indices = ChunkOf(container).Mesh.GetIndices(0); From 45294ad57b2952504ae15de98258616db4f6be77 Mon Sep 17 00:00:00 2001 From: Roman Likhadievski Date: Tue, 29 Sep 2026 09:52:11 +0300 Subject: [PATCH 02/13] refactor: shape naming --- UnityPackage/Runtime/Core/AxialShape.cs | 38 +++++++++-- UnityPackage/Runtime/Core/AxisRings.cs | 14 ++--- UnityPackage/Runtime/Core/Ring.cs | 22 +++---- .../Runtime/Shapes/Capsule/Capsule.cs | 50 +++++++-------- .../Runtime/Shapes/Capsule/CapsuleFactory.cs | 26 ++++---- .../Runtime/Shapes/Capsule/ICapsule.cs | 2 +- UnityPackage/Runtime/Shapes/Circle/Circle.cs | 6 +- .../Runtime/Shapes/Circle/CircleFactory.cs | 26 ++++---- UnityPackage/Runtime/Shapes/Circle/ICircle.cs | 2 +- UnityPackage/Runtime/Shapes/Cone/Cone.cs | 26 ++++---- .../Runtime/Shapes/Cone/ConeFactory.cs | 20 +++--- UnityPackage/Runtime/Shapes/Cone/ICone.cs | 2 +- .../Runtime/Shapes/Cylinder/Cylinder.cs | 28 ++++----- .../Shapes/Cylinder/CylinderFactory.cs | 20 +++--- .../Runtime/Shapes/Cylinder/ICylinder.cs | 2 +- .../Runtime/Shapes/Ellipse/Ellipse.cs | 6 +- .../Runtime/Shapes/Ellipse/EllipseFactory.cs | 26 ++++---- .../Runtime/Shapes/Ellipse/IEllipse.cs | 2 +- .../Runtime/Shapes/Ellipsoid/Ellipsoid.cs | 8 +-- .../Shapes/Ellipsoid/EllipsoidFactory.cs | 20 +++--- .../Runtime/Shapes/Ellipsoid/IEllipsoid.cs | 2 +- .../Runtime/Shapes/Frustum/Frustum.cs | 34 +++++----- .../Runtime/Shapes/Frustum/FrustumFactory.cs | 22 +++---- .../Runtime/Shapes/Frustum/IFrustum.cs | 2 +- UnityPackage/Runtime/Shapes/IAxialShape.cs | 18 ++++-- .../RoundedRectangle/IRoundedRectangle.cs | 2 +- .../RoundedRectangle/RoundedRectangle.cs | 22 +++---- .../RoundedRectangleFactory.cs | 20 +++--- UnityPackage/Runtime/Shapes/Sphere/ISphere.cs | 2 +- UnityPackage/Runtime/Shapes/Sphere/Sphere.cs | 8 +-- .../Runtime/Shapes/Sphere/SphereFactory.cs | 20 +++--- .../Runtime/Shapes/Stadium/IStadium.cs | 2 +- .../Runtime/Shapes/Stadium/Stadium.cs | 8 +-- .../Runtime/Shapes/Stadium/StadiumFactory.cs | 32 +++++----- UnityPackage/Runtime/Shapes/Star/Star.cs | 12 ++-- .../Runtime/Shapes/Star/StarFactory.cs | 22 +++---- UnityPackage/Tests/Runtime/CapsuleTests.cs | 8 +-- UnityPackage/Tests/Runtime/CircleTests.cs | 4 +- UnityPackage/Tests/Runtime/ConeTests.cs | 10 +-- UnityPackage/Tests/Runtime/CylinderTests.cs | 63 +++++++++++++++---- .../Tests/Runtime/DegenerateShapeTests.cs | 2 +- UnityPackage/Tests/Runtime/EllipseTests.cs | 2 +- UnityPackage/Tests/Runtime/EllipsoidTests.cs | 2 +- UnityPackage/Tests/Runtime/FrustumTests.cs | 6 +- UnityPackage/Tests/Runtime/PyramidTests.cs | 4 +- UnityPackage/Tests/Runtime/RigidShapeTests.cs | 2 +- .../Tests/Runtime/RoundedRectangleTests.cs | 2 +- UnityPackage/Tests/Runtime/SphereTests.cs | 2 +- UnityPackage/Tests/Runtime/StadiumTests.cs | 2 +- 49 files changed, 382 insertions(+), 301 deletions(-) diff --git a/UnityPackage/Runtime/Core/AxialShape.cs b/UnityPackage/Runtime/Core/AxialShape.cs index 804c279..ef0e82d 100644 --- a/UnityPackage/Runtime/Core/AxialShape.cs +++ b/UnityPackage/Runtime/Core/AxialShape.cs @@ -38,7 +38,23 @@ public float Length } } - public Vector3 LocalEnd + public Vector3 LocalEndA + { + get + { + ThrowIfDisposed(); + return LocalPosition; + } + set + { + ThrowIfDisposed(); + Vector3 endB = LocalEndB; + LocalPosition = value; + LocalEndB = endB; + } + } + + public Vector3 LocalEndB { get { @@ -62,17 +78,31 @@ public Vector3 LocalEnd } } - public Vector3 WorldEnd + public Vector3 WorldEndA + { + get + { + ThrowIfDisposed(); + return ToWorld(Bone, LocalEndA); + } + set + { + ThrowIfDisposed(); + LocalEndA = ToLocal(Bone, value); + } + } + + public Vector3 WorldEndB { get { ThrowIfDisposed(); - return ToWorld(Bone, LocalEnd); + return ToWorld(Bone, LocalEndB); } set { ThrowIfDisposed(); - LocalEnd = ToLocal(Bone, value); + LocalEndB = ToLocal(Bone, value); } } diff --git a/UnityPackage/Runtime/Core/AxisRings.cs b/UnityPackage/Runtime/Core/AxisRings.cs index 4ace073..d924c1e 100644 --- a/UnityPackage/Runtime/Core/AxisRings.cs +++ b/UnityPackage/Runtime/Core/AxisRings.cs @@ -15,22 +15,22 @@ internal static class AxisRings /// Writes the three rings, each with a third of the positions, with the given radius along each axis. internal static void Write(Span positions, Vector3 radii) { - int segments = positions.Length / RingCount; + int segmentCount = positions.Length / RingCount; Vector3 x = new(radii.x, 0f, 0f); Vector3 y = new(0f, radii.y, 0f); Vector3 z = new(0f, 0f, radii.z); - Ring.Write(positions.Slice(0, segments), Vector3.zero, z, x); - Ring.Write(positions.Slice(segments, segments), Vector3.zero, y, x); - Ring.Write(positions.Slice(segments * 2, segments), Vector3.zero, y, z); + Ring.Write(positions.Slice(0, segmentCount), Vector3.zero, z, x); + Ring.Write(positions.Slice(segmentCount, segmentCount), Vector3.zero, y, x); + Ring.Write(positions.Slice(segmentCount * 2, segmentCount), Vector3.zero, y, z); } - private static int[] BuildEdges(int segments) + private static int[] BuildEdges(int segmentCount) { - int[] pattern = new int[segments * RingCount * 2]; + int[] pattern = new int[segmentCount * RingCount * 2]; int cursor = 0; for (int ring = 0; ring < RingCount; ring++) { - Ring.AddEdges(pattern, ref cursor, ring * segments, segments); + Ring.AddEdges(pattern, ref cursor, ring * segmentCount, segmentCount); } return pattern; } diff --git a/UnityPackage/Runtime/Core/Ring.cs b/UnityPackage/Runtime/Core/Ring.cs index 0983f3c..94bea21 100644 --- a/UnityPackage/Runtime/Core/Ring.cs +++ b/UnityPackage/Runtime/Core/Ring.cs @@ -7,7 +7,7 @@ namespace reromanlee.Wireframes /// Points and edges of the closed rings that round shapes are made of. internal static class Ring { - internal const int DefaultSegments = 32; + internal const int DefaultSegmentCount = 32; /// Edges of shapes that are one closed ring, by vertex count. internal static readonly PatternCache Patterns = new(BuildEdges); @@ -15,28 +15,28 @@ internal static class Ring // Cosine and sine around a unit circle for each segment count in use, shared by every ring with that count. private static readonly Dictionary UnitCircles = new(); - /// Returns once it is checked, so it can be used in a base constructor call. - internal static int CheckSegments(int segments) + /// Returns once it is checked, so it can be used in a base constructor call. + internal static int CheckSegmentCount(int segmentCount) { - if (segments < 3) + if (segmentCount < 3) { - throw new ArgumentOutOfRangeException(nameof(segments), segments, "A ring needs at least 3 segments."); + throw new ArgumentOutOfRangeException(nameof(segmentCount), segmentCount, "A ring needs at least 3 segments."); } - return segments; + return segmentCount; } /// - /// Returns once it is checked to be a multiple of 4, which shapes need when their + /// Returns once it is checked to be a multiple of 4, which shapes need when their /// lines meet a ring at its quarter points. /// - internal static int CheckQuarterSegments(int segments) + internal static int CheckQuarterSegmentCount(int segmentCount) { - if (segments < 4 || segments % 4 != 0) + if (segmentCount < 4 || segmentCount % 4 != 0) { throw new ArgumentOutOfRangeException( - nameof(segments), segments, "This shape needs a positive multiple of 4 segments."); + nameof(segmentCount), segmentCount, "This shape needs a positive multiple of 4 segments."); } - return segments; + return segmentCount; } /// diff --git a/UnityPackage/Runtime/Shapes/Capsule/Capsule.cs b/UnityPackage/Runtime/Shapes/Capsule/Capsule.cs index 36a9ca3..dace375 100644 --- a/UnityPackage/Runtime/Shapes/Capsule/Capsule.cs +++ b/UnityPackage/Runtime/Shapes/Capsule/Capsule.cs @@ -19,11 +19,11 @@ internal Capsule( float length, float radiusA, float radiusB, - int segments) + int segmentCount) : base( proxy, - CountVertices(Ring.CheckQuarterSegments(segments)), - Patterns.Get(segments), + CountVertices(Ring.CheckQuarterSegmentCount(segmentCount)), + Patterns.Get(segmentCount), bone, localPosition, localRotation, @@ -63,7 +63,7 @@ public float RadiusB } } - public int Segments + public int SegmentCount { get { @@ -75,8 +75,8 @@ public int Segments // Vertices: the ring on sphere A, the ring on sphere B, then the inside points of cap A's arcs and cap B's arcs. protected override void WriteShape(Span positions, float length) { - int segments = (positions.Length + 6) / 4; - int capVertices = segments - 3; + int segmentCount = (positions.Length + 6) / 4; + int capVertices = segmentCount - 3; // Built along +Z for the sizes without their signs, then mirrored when the length is negative. float span = Mathf.Abs(length); float radiusA = Mathf.Abs(_radiusA); @@ -84,10 +84,10 @@ protected override void WriteShape(Span positions, float length) Vector3 centerB = new(0f, 0f, span); Hull.Tangent(radiusA, radiusB, span, out float sine, out float cosine); - Span ringA = positions.Slice(0, segments); - Span ringB = positions.Slice(segments, segments); - Span capA = positions.Slice(segments * 2, capVertices); - Span capB = positions.Slice(segments * 2 + capVertices, capVertices); + Span ringA = positions.Slice(0, segmentCount); + Span ringB = positions.Slice(segmentCount, segmentCount); + Span capA = positions.Slice(segmentCount * 2, capVertices); + Span capB = positions.Slice(segmentCount * 2 + capVertices, capVertices); WriteRing(ringA, Vector3.zero, radiusA, sine, cosine); WriteRing(ringB, centerB, radiusB, sine, cosine); WriteCap(capA, Vector3.zero, radiusA, Vector3.back, Mathf.Atan2(cosine, sine)); @@ -123,10 +123,10 @@ protected override void ScaleCrossSection(float factor) _radiusB *= factor; } - private static int CountVertices(int segments) + private static int CountVertices(int segmentCount) { // Two rings, and two caps whose arcs share the ring points and each cap's pole. - return segments * 4 - 6; + return segmentCount * 4 - 6; } /// @@ -165,30 +165,30 @@ private static void WriteCap(Span cap, Vector3 center, float radius, Ve } } - private static int[] BuildEdges(int segments) + private static int[] BuildEdges(int segmentCount) { - int[] pattern = new int[(segments * 4 + SideLineCount) * 2]; + int[] pattern = new int[(segmentCount * 4 + SideLineCount) * 2]; int cursor = 0; - Ring.AddEdges(pattern, ref cursor, 0, segments); - Ring.AddEdges(pattern, ref cursor, segments, segments); - // The side lines join the rings at their quarter points, which is why segments must be a multiple of 4. + Ring.AddEdges(pattern, ref cursor, 0, segmentCount); + Ring.AddEdges(pattern, ref cursor, segmentCount, segmentCount); + // The side lines join the rings at their quarter points, which is why the segment count must be a multiple of 4. for (int line = 0; line < SideLineCount; line++) { - int vertex = line * segments / SideLineCount; + int vertex = line * segmentCount / SideLineCount; pattern[cursor++] = vertex; - pattern[cursor++] = segments + vertex; + pattern[cursor++] = segmentCount + vertex; } - int capVertices = segments - 3; - AddCapEdges(pattern, ref cursor, 0, segments * 2, segments); - AddCapEdges(pattern, ref cursor, segments, segments * 2 + capVertices, segments); + int capVertices = segmentCount - 3; + AddCapEdges(pattern, ref cursor, 0, segmentCount * 2, segmentCount); + AddCapEdges(pattern, ref cursor, segmentCount, segmentCount * 2 + capVertices, segmentCount); return pattern; } /// Adds the edges of a cap's two arcs, in the order writes their points. - private static void AddCapEdges(int[] pattern, ref int cursor, int ring, int inside, int segments) + private static void AddCapEdges(int[] pattern, ref int cursor, int ring, int inside, int segmentCount) { - int half = segments / 2; - int quarter = segments / 4; + int half = segmentCount / 2; + int quarter = segmentCount / 4; int pole = inside + quarter - 1; // From the ring's +Y point over the pole to its -Y point. diff --git a/UnityPackage/Runtime/Shapes/Capsule/CapsuleFactory.cs b/UnityPackage/Runtime/Shapes/Capsule/CapsuleFactory.cs index c205369..3c50592 100644 --- a/UnityPackage/Runtime/Shapes/Capsule/CapsuleFactory.cs +++ b/UnityPackage/Runtime/Shapes/Capsule/CapsuleFactory.cs @@ -19,7 +19,7 @@ public static ICapsule CreateCapsule(this WireframeContainer container) ShapeDefaults.Size, ShapeDefaults.Radius, ShapeDefaults.Radius, - Ring.DefaultSegments); + Ring.DefaultSegmentCount); } /// @@ -27,41 +27,41 @@ public static ICapsule CreateCapsule(this WireframeContainer container) /// capsule of Physics.CapsuleCast. Its spin around the axis keeps the capsule's +Y as close to world up /// as it can. /// - /// Number of straight pieces each ring is drawn with, a positive multiple of 4. + /// Number of straight pieces each ring is drawn with, a positive multiple of 4. public static ICapsule CreateCapsule( this WireframeContainer container, Vector3 centerA, Vector3 centerB, float radius, - int segments = Ring.DefaultSegments) + int segmentCount = Ring.DefaultSegmentCount) { - return container.CreateCapsule(centerA, centerB, radius, radius, segments); + return container.CreateCapsule(centerA, centerB, radius, radius, segmentCount); } /// /// Creates a white capsule around two spheres centered on two world positions, each with its own radius. Its /// spin around the axis keeps the capsule's +Y as close to world up as it can. /// - /// Number of straight pieces each ring is drawn with, a positive multiple of 4. + /// Number of straight pieces each ring is drawn with, a positive multiple of 4. public static ICapsule CreateCapsule( this WireframeContainer container, Vector3 centerA, Vector3 centerB, float radiusA, float radiusB, - int segments = Ring.DefaultSegments) + int segmentCount = Ring.DefaultSegmentCount) { MeshProxy proxy = WireframeContainer.ProxyOf(container); Quaternion rotation = ShapeRotations.FromAxis(centerB - centerA, Vector3.up); float length = Vector3.Distance(centerA, centerB); - return new Capsule(proxy, null, centerA, rotation, length, radiusA, radiusB, segments); + return new Capsule(proxy, null, centerA, rotation, length, radiusA, radiusB, segmentCount); } /// /// Creates a white capsule that follows , around two spheres centered on positions in the /// bone's local space. Its spin around the axis keeps the capsule's +Y as close to the bone's up as it can. /// - /// Number of straight pieces each ring is drawn with, a positive multiple of 4. + /// Number of straight pieces each ring is drawn with, a positive multiple of 4. public static ICapsule CreateCapsule( this WireframeContainer container, Transform bone, @@ -69,19 +69,19 @@ public static ICapsule CreateCapsule( Vector3 localCenterB, float radiusA, float radiusB, - int segments = Ring.DefaultSegments) + int segmentCount = Ring.DefaultSegmentCount) { MeshProxy proxy = WireframeContainer.ProxyOf(container); Quaternion rotation = ShapeRotations.FromAxis(localCenterB - localCenterA, Vector3.up); float length = Vector3.Distance(localCenterA, localCenterB); - return new Capsule(proxy, bone, localCenterA, rotation, length, radiusA, radiusB, segments); + return new Capsule(proxy, bone, localCenterA, rotation, length, radiusA, radiusB, segmentCount); } /// /// Creates a white capsule in world space whose first sphere is centered on and /// whose second sphere is centered along the +Z axis of . /// - /// Number of straight pieces each ring is drawn with, a positive multiple of 4. + /// Number of straight pieces each ring is drawn with, a positive multiple of 4. public static ICapsule CreateCapsule( this WireframeContainer container, Vector3 position, @@ -89,10 +89,10 @@ public static ICapsule CreateCapsule( float length, float radiusA, float radiusB, - int segments = Ring.DefaultSegments) + int segmentCount = Ring.DefaultSegmentCount) { MeshProxy proxy = WireframeContainer.ProxyOf(container); - return new Capsule(proxy, null, position, rotation, length, radiusA, radiusB, segments); + return new Capsule(proxy, null, position, rotation, length, radiusA, radiusB, segmentCount); } } } diff --git a/UnityPackage/Runtime/Shapes/Capsule/ICapsule.cs b/UnityPackage/Runtime/Shapes/Capsule/ICapsule.cs index dcfe9e6..53fa91f 100644 --- a/UnityPackage/Runtime/Shapes/Capsule/ICapsule.cs +++ b/UnityPackage/Runtime/Shapes/Capsule/ICapsule.cs @@ -16,6 +16,6 @@ public interface ICapsule : IAxialShape /// /// Number of straight pieces each ring is drawn with, fixed at creation. Each arc over a cap is half of them. /// - int Segments { get; } + int SegmentCount { get; } } } diff --git a/UnityPackage/Runtime/Shapes/Circle/Circle.cs b/UnityPackage/Runtime/Shapes/Circle/Circle.cs index 71dd604..e29afda 100644 --- a/UnityPackage/Runtime/Shapes/Circle/Circle.cs +++ b/UnityPackage/Runtime/Shapes/Circle/Circle.cs @@ -13,8 +13,8 @@ internal Circle( Vector3 localCenter, Quaternion localRotation, float radius, - int segments) - : base(proxy, Ring.CheckSegments(segments), Ring.Patterns.Get(segments), bone, localCenter, localRotation) + int segmentCount) + : base(proxy, Ring.CheckSegmentCount(segmentCount), Ring.Patterns.Get(segmentCount), bone, localCenter, localRotation) { _radius = radius; } @@ -34,7 +34,7 @@ public float Radius } } - public int Segments + public int SegmentCount { get { diff --git a/UnityPackage/Runtime/Shapes/Circle/CircleFactory.cs b/UnityPackage/Runtime/Shapes/Circle/CircleFactory.cs index dda8889..5ac2a57 100644 --- a/UnityPackage/Runtime/Shapes/Circle/CircleFactory.cs +++ b/UnityPackage/Runtime/Shapes/Circle/CircleFactory.cs @@ -9,56 +9,56 @@ public static class CircleFactory public static ICircle CreateCircle(this WireframeContainer container) { MeshProxy proxy = WireframeContainer.ProxyOf(container); - return new Circle(proxy, null, Vector3.zero, Quaternion.identity, ShapeDefaults.Radius, Ring.DefaultSegments); + return new Circle(proxy, null, Vector3.zero, Quaternion.identity, ShapeDefaults.Radius, Ring.DefaultSegmentCount); } /// Creates a white circle around a world position, lying flat in the world's XZ plane. - /// Number of straight pieces the circle is drawn with, at least 3. + /// Number of straight pieces the circle is drawn with, at least 3. public static ICircle CreateCircle( - this WireframeContainer container, Vector3 center, float radius, int segments = Ring.DefaultSegments) + this WireframeContainer container, Vector3 center, float radius, int segmentCount = Ring.DefaultSegmentCount) { - return new Circle(WireframeContainer.ProxyOf(container), null, center, Quaternion.identity, radius, segments); + return new Circle(WireframeContainer.ProxyOf(container), null, center, Quaternion.identity, radius, segmentCount); } /// /// Creates a white circle that follows , around a position in the bone's local space /// and lying flat in the bone's XZ plane. /// - /// Number of straight pieces the circle is drawn with, at least 3. + /// Number of straight pieces the circle is drawn with, at least 3. public static ICircle CreateCircle( this WireframeContainer container, Transform bone, Vector3 localCenter, float radius, - int segments = Ring.DefaultSegments) + int segmentCount = Ring.DefaultSegmentCount) { MeshProxy proxy = WireframeContainer.ProxyOf(container); - return new Circle(proxy, bone, localCenter, Quaternion.identity, radius, segments); + return new Circle(proxy, bone, localCenter, Quaternion.identity, radius, segmentCount); } /// Creates a white circle in world space, lying in the XZ plane of . - /// Number of straight pieces the circle is drawn with, at least 3. + /// Number of straight pieces the circle is drawn with, at least 3. public static ICircle CreateCircle( this WireframeContainer container, Vector3 center, Quaternion rotation, float radius, - int segments = Ring.DefaultSegments) + int segmentCount = Ring.DefaultSegmentCount) { - return new Circle(WireframeContainer.ProxyOf(container), null, center, rotation, radius, segments); + return new Circle(WireframeContainer.ProxyOf(container), null, center, rotation, radius, segmentCount); } /// Creates a white circle around a world position, facing . - /// Number of straight pieces the circle is drawn with, at least 3. + /// Number of straight pieces the circle is drawn with, at least 3. public static ICircle CreateCircle( this WireframeContainer container, Vector3 center, Vector3 normal, float radius, - int segments = Ring.DefaultSegments) + int segmentCount = Ring.DefaultSegmentCount) { MeshProxy proxy = WireframeContainer.ProxyOf(container); - return new Circle(proxy, null, center, ShapeRotations.FromNormal(normal), radius, segments); + return new Circle(proxy, null, center, ShapeRotations.FromNormal(normal), radius, segmentCount); } } } diff --git a/UnityPackage/Runtime/Shapes/Circle/ICircle.cs b/UnityPackage/Runtime/Shapes/Circle/ICircle.cs index 280cf52..b32c096 100644 --- a/UnityPackage/Runtime/Shapes/Circle/ICircle.cs +++ b/UnityPackage/Runtime/Shapes/Circle/ICircle.cs @@ -10,6 +10,6 @@ public interface ICircle : IRigidShape float Radius { get; set; } /// Number of straight pieces the circle is drawn with, fixed at creation. - int Segments { get; } + int SegmentCount { get; } } } diff --git a/UnityPackage/Runtime/Shapes/Cone/Cone.cs b/UnityPackage/Runtime/Shapes/Cone/Cone.cs index fa67a68..843ed14 100644 --- a/UnityPackage/Runtime/Shapes/Cone/Cone.cs +++ b/UnityPackage/Runtime/Shapes/Cone/Cone.cs @@ -18,11 +18,11 @@ internal Cone( Quaternion localRotation, float length, float radius, - int segments) + int segmentCount) : base( proxy, - Ring.CheckQuarterSegments(segments) + TipCount, - Patterns.Get(segments), + Ring.CheckQuarterSegmentCount(segmentCount) + TipCount, + Patterns.Get(segmentCount), bone, localPosition, localRotation, @@ -46,7 +46,7 @@ public float Radius } } - public int Segments + public int SegmentCount { get { @@ -57,14 +57,14 @@ public int Segments protected override void WriteShape(Span positions, float length) { - int segments = positions.Length - TipCount; + int segmentCount = positions.Length - TipCount; // The base ring starts on +Y and passes +X a quarter turn later; the tip comes last. Ring.Write( - positions.Slice(0, segments), + positions.Slice(0, segmentCount), new Vector3(0f, 0f, length), new Vector3(0f, _radius, 0f), new Vector3(_radius, 0f, 0f)); - positions[segments] = Vector3.zero; + positions[segmentCount] = Vector3.zero; } protected override void ScaleCrossSection(float factor) @@ -72,16 +72,16 @@ protected override void ScaleCrossSection(float factor) _radius *= factor; } - private static int[] BuildEdges(int segments) + private static int[] BuildEdges(int segmentCount) { - int[] pattern = new int[(segments + SideLineCount) * 2]; + int[] pattern = new int[(segmentCount + SideLineCount) * 2]; int cursor = 0; - Ring.AddEdges(pattern, ref cursor, 0, segments); - // The side lines meet the ring at its quarter points, which is why segments must be a multiple of 4. + Ring.AddEdges(pattern, ref cursor, 0, segmentCount); + // The side lines meet the ring at its quarter points, which is why the segment count must be a multiple of 4. for (int line = 0; line < SideLineCount; line++) { - pattern[cursor++] = segments; - pattern[cursor++] = line * segments / SideLineCount; + pattern[cursor++] = segmentCount; + pattern[cursor++] = line * segmentCount / SideLineCount; } return pattern; } diff --git a/UnityPackage/Runtime/Shapes/Cone/ConeFactory.cs b/UnityPackage/Runtime/Shapes/Cone/ConeFactory.cs index a84eb02..30fc756 100644 --- a/UnityPackage/Runtime/Shapes/Cone/ConeFactory.cs +++ b/UnityPackage/Runtime/Shapes/Cone/ConeFactory.cs @@ -16,60 +16,60 @@ public static ICone CreateCone(this WireframeContainer container) Quaternion.identity, ShapeDefaults.Size, ShapeDefaults.Radius, - Ring.DefaultSegments); + Ring.DefaultSegmentCount); } /// /// Creates a white cone from a tip to the center of its base, both world positions. Its spin around the axis /// keeps the cone's +Y as close to world up as it can. /// - /// Number of straight pieces the base ring is drawn with, a positive multiple of 4. + /// Number of straight pieces the base ring is drawn with, a positive multiple of 4. public static ICone CreateCone( this WireframeContainer container, Vector3 tip, Vector3 baseCenter, float radius, - int segments = Ring.DefaultSegments) + int segmentCount = Ring.DefaultSegmentCount) { MeshProxy proxy = WireframeContainer.ProxyOf(container); Quaternion rotation = ShapeRotations.FromAxis(baseCenter - tip, Vector3.up); - return new Cone(proxy, null, tip, rotation, Vector3.Distance(tip, baseCenter), radius, segments); + return new Cone(proxy, null, tip, rotation, Vector3.Distance(tip, baseCenter), radius, segmentCount); } /// /// Creates a white cone that follows , from a tip to the center of its base, both in the /// bone's local space. Its spin around the axis keeps the cone's +Y as close to the bone's up as it can. /// - /// Number of straight pieces the base ring is drawn with, a positive multiple of 4. + /// Number of straight pieces the base ring is drawn with, a positive multiple of 4. public static ICone CreateCone( this WireframeContainer container, Transform bone, Vector3 localTip, Vector3 localBaseCenter, float radius, - int segments = Ring.DefaultSegments) + int segmentCount = Ring.DefaultSegmentCount) { MeshProxy proxy = WireframeContainer.ProxyOf(container); Quaternion rotation = ShapeRotations.FromAxis(localBaseCenter - localTip, Vector3.up); float length = Vector3.Distance(localTip, localBaseCenter); - return new Cone(proxy, bone, localTip, rotation, length, radius, segments); + return new Cone(proxy, bone, localTip, rotation, length, radius, segmentCount); } /// /// Creates a white cone in world space with its tip at and its base /// along the +Z axis of . /// - /// Number of straight pieces the base ring is drawn with, a positive multiple of 4. + /// Number of straight pieces the base ring is drawn with, a positive multiple of 4. public static ICone CreateCone( this WireframeContainer container, Vector3 position, Quaternion rotation, float length, float radius, - int segments = Ring.DefaultSegments) + int segmentCount = Ring.DefaultSegmentCount) { MeshProxy proxy = WireframeContainer.ProxyOf(container); - return new Cone(proxy, null, position, rotation, length, radius, segments); + return new Cone(proxy, null, position, rotation, length, radius, segmentCount); } } } diff --git a/UnityPackage/Runtime/Shapes/Cone/ICone.cs b/UnityPackage/Runtime/Shapes/Cone/ICone.cs index 53da520..0230ff7 100644 --- a/UnityPackage/Runtime/Shapes/Cone/ICone.cs +++ b/UnityPackage/Runtime/Shapes/Cone/ICone.cs @@ -10,6 +10,6 @@ public interface ICone : IAxialShape float Radius { get; set; } /// Number of straight pieces the base ring is drawn with, fixed at creation. - int Segments { get; } + int SegmentCount { get; } } } diff --git a/UnityPackage/Runtime/Shapes/Cylinder/Cylinder.cs b/UnityPackage/Runtime/Shapes/Cylinder/Cylinder.cs index 01f190d..2702529 100644 --- a/UnityPackage/Runtime/Shapes/Cylinder/Cylinder.cs +++ b/UnityPackage/Runtime/Shapes/Cylinder/Cylinder.cs @@ -18,11 +18,11 @@ internal Cylinder( Quaternion localRotation, float length, float radius, - int segments) + int segmentCount) : base( proxy, - Ring.CheckQuarterSegments(segments) * RingCount, - Patterns.Get(segments), + Ring.CheckQuarterSegmentCount(segmentCount) * RingCount, + Patterns.Get(segmentCount), bone, localPosition, localRotation, @@ -46,7 +46,7 @@ public float Radius } } - public int Segments + public int SegmentCount { get { @@ -57,12 +57,12 @@ public int Segments protected override void WriteShape(Span positions, float length) { - int segments = positions.Length / RingCount; + int segmentCount = positions.Length / RingCount; // Each ring starts on +Y and passes +X a quarter turn later. Vector3 up = new(0f, _radius, 0f); Vector3 right = new(_radius, 0f, 0f); - Ring.Write(positions.Slice(0, segments), Vector3.zero, up, right); - Ring.Write(positions.Slice(segments, segments), new Vector3(0f, 0f, length), up, right); + Ring.Write(positions.Slice(0, segmentCount), Vector3.zero, up, right); + Ring.Write(positions.Slice(segmentCount, segmentCount), new Vector3(0f, 0f, length), up, right); } protected override void ScaleCrossSection(float factor) @@ -70,18 +70,18 @@ protected override void ScaleCrossSection(float factor) _radius *= factor; } - private static int[] BuildEdges(int segments) + private static int[] BuildEdges(int segmentCount) { - int[] pattern = new int[(segments * RingCount + SideLineCount) * 2]; + int[] pattern = new int[(segmentCount * RingCount + SideLineCount) * 2]; int cursor = 0; - Ring.AddEdges(pattern, ref cursor, 0, segments); - Ring.AddEdges(pattern, ref cursor, segments, segments); - // The side lines join the rings at their quarter points, which is why segments must be a multiple of 4. + Ring.AddEdges(pattern, ref cursor, 0, segmentCount); + Ring.AddEdges(pattern, ref cursor, segmentCount, segmentCount); + // The side lines join the rings at their quarter points, which is why the segment count must be a multiple of 4. for (int line = 0; line < SideLineCount; line++) { - int vertex = line * segments / SideLineCount; + int vertex = line * segmentCount / SideLineCount; pattern[cursor++] = vertex; - pattern[cursor++] = segments + vertex; + pattern[cursor++] = segmentCount + vertex; } return pattern; } diff --git a/UnityPackage/Runtime/Shapes/Cylinder/CylinderFactory.cs b/UnityPackage/Runtime/Shapes/Cylinder/CylinderFactory.cs index db65858..f7bdb00 100644 --- a/UnityPackage/Runtime/Shapes/Cylinder/CylinderFactory.cs +++ b/UnityPackage/Runtime/Shapes/Cylinder/CylinderFactory.cs @@ -16,60 +16,60 @@ public static ICylinder CreateCylinder(this WireframeContainer container) Quaternion.identity, ShapeDefaults.Size, ShapeDefaults.Radius, - Ring.DefaultSegments); + Ring.DefaultSegmentCount); } /// /// Creates a white cylinder between two world positions. Its spin around the axis keeps the cylinder's +Y as /// close to world up as it can. /// - /// Number of straight pieces each ring is drawn with, a positive multiple of 4. + /// Number of straight pieces each ring is drawn with, a positive multiple of 4. public static ICylinder CreateCylinder( this WireframeContainer container, Vector3 endA, Vector3 endB, float radius, - int segments = Ring.DefaultSegments) + int segmentCount = Ring.DefaultSegmentCount) { MeshProxy proxy = WireframeContainer.ProxyOf(container); Quaternion rotation = ShapeRotations.FromAxis(endB - endA, Vector3.up); - return new Cylinder(proxy, null, endA, rotation, Vector3.Distance(endA, endB), radius, segments); + return new Cylinder(proxy, null, endA, rotation, Vector3.Distance(endA, endB), radius, segmentCount); } /// /// Creates a white cylinder that follows , between two positions in the bone's local /// space. Its spin around the axis keeps the cylinder's +Y as close to the bone's up as it can. /// - /// Number of straight pieces each ring is drawn with, a positive multiple of 4. + /// Number of straight pieces each ring is drawn with, a positive multiple of 4. public static ICylinder CreateCylinder( this WireframeContainer container, Transform bone, Vector3 localEndA, Vector3 localEndB, float radius, - int segments = Ring.DefaultSegments) + int segmentCount = Ring.DefaultSegmentCount) { MeshProxy proxy = WireframeContainer.ProxyOf(container); Quaternion rotation = ShapeRotations.FromAxis(localEndB - localEndA, Vector3.up); float length = Vector3.Distance(localEndA, localEndB); - return new Cylinder(proxy, bone, localEndA, rotation, length, radius, segments); + return new Cylinder(proxy, bone, localEndA, rotation, length, radius, segmentCount); } /// /// Creates a white cylinder in world space that starts at and runs /// along the +Z axis of . /// - /// Number of straight pieces each ring is drawn with, a positive multiple of 4. + /// Number of straight pieces each ring is drawn with, a positive multiple of 4. public static ICylinder CreateCylinder( this WireframeContainer container, Vector3 position, Quaternion rotation, float length, float radius, - int segments = Ring.DefaultSegments) + int segmentCount = Ring.DefaultSegmentCount) { MeshProxy proxy = WireframeContainer.ProxyOf(container); - return new Cylinder(proxy, null, position, rotation, length, radius, segments); + return new Cylinder(proxy, null, position, rotation, length, radius, segmentCount); } } } diff --git a/UnityPackage/Runtime/Shapes/Cylinder/ICylinder.cs b/UnityPackage/Runtime/Shapes/Cylinder/ICylinder.cs index 9d62ef6..389b8af 100644 --- a/UnityPackage/Runtime/Shapes/Cylinder/ICylinder.cs +++ b/UnityPackage/Runtime/Shapes/Cylinder/ICylinder.cs @@ -9,6 +9,6 @@ public interface ICylinder : IAxialShape float Radius { get; set; } /// Number of straight pieces each ring is drawn with, fixed at creation. - int Segments { get; } + int SegmentCount { get; } } } diff --git a/UnityPackage/Runtime/Shapes/Ellipse/Ellipse.cs b/UnityPackage/Runtime/Shapes/Ellipse/Ellipse.cs index d5613b2..55d3b69 100644 --- a/UnityPackage/Runtime/Shapes/Ellipse/Ellipse.cs +++ b/UnityPackage/Runtime/Shapes/Ellipse/Ellipse.cs @@ -13,8 +13,8 @@ internal Ellipse( Vector3 localCenter, Quaternion localRotation, Vector2 radii, - int segments) - : base(proxy, Ring.CheckSegments(segments), Ring.Patterns.Get(segments), bone, localCenter, localRotation) + int segmentCount) + : base(proxy, Ring.CheckSegmentCount(segmentCount), Ring.Patterns.Get(segmentCount), bone, localCenter, localRotation) { _radii = radii; } @@ -34,7 +34,7 @@ public Vector2 Radii } } - public int Segments + public int SegmentCount { get { diff --git a/UnityPackage/Runtime/Shapes/Ellipse/EllipseFactory.cs b/UnityPackage/Runtime/Shapes/Ellipse/EllipseFactory.cs index 378ef8d..c6653b2 100644 --- a/UnityPackage/Runtime/Shapes/Ellipse/EllipseFactory.cs +++ b/UnityPackage/Runtime/Shapes/Ellipse/EllipseFactory.cs @@ -13,73 +13,73 @@ public static IEllipse CreateEllipse(this WireframeContainer container) { MeshProxy proxy = WireframeContainer.ProxyOf(container); Vector2 radii = new(ShapeDefaults.Radius * 0.5f, ShapeDefaults.Radius); - return new Ellipse(proxy, null, Vector3.zero, Quaternion.identity, radii, Ring.DefaultSegments); + return new Ellipse(proxy, null, Vector3.zero, Quaternion.identity, radii, Ring.DefaultSegmentCount); } /// /// Creates a white ellipse whose long axis runs between two world positions, with as /// its other semi-axis. It lies as flat as it can: its normal stays as close to world up as the tips allow. /// - /// Number of straight pieces the ellipse is drawn with, at least 3. + /// Number of straight pieces the ellipse is drawn with, at least 3. public static IEllipse CreateEllipse( this WireframeContainer container, Vector3 tipA, Vector3 tipB, float radius, - int segments = Ring.DefaultSegments) + int segmentCount = Ring.DefaultSegmentCount) { - return container.CreateEllipse(tipA, tipB, radius, Vector3.up, segments); + return container.CreateEllipse(tipA, tipB, radius, Vector3.up, segmentCount); } /// /// Creates a white ellipse whose long axis runs between two world positions, with as /// its other semi-axis, facing as close to as the tips allow. /// - /// Number of straight pieces the ellipse is drawn with, at least 3. + /// Number of straight pieces the ellipse is drawn with, at least 3. public static IEllipse CreateEllipse( this WireframeContainer container, Vector3 tipA, Vector3 tipB, float radius, Vector3 normal, - int segments = Ring.DefaultSegments) + int segmentCount = Ring.DefaultSegmentCount) { MeshProxy proxy = WireframeContainer.ProxyOf(container); Quaternion rotation = ShapeRotations.FromAxis(tipB - tipA, normal); Vector2 radii = new(radius, Vector3.Distance(tipA, tipB) * 0.5f); - return new Ellipse(proxy, null, (tipA + tipB) * 0.5f, rotation, radii, segments); + return new Ellipse(proxy, null, (tipA + tipB) * 0.5f, rotation, radii, segmentCount); } /// /// Creates a white ellipse that follows , whose long axis runs between two positions in /// the bone's local space. It lies as flat in the bone's XZ plane as the tips allow. /// - /// Number of straight pieces the ellipse is drawn with, at least 3. + /// Number of straight pieces the ellipse is drawn with, at least 3. public static IEllipse CreateEllipse( this WireframeContainer container, Transform bone, Vector3 localTipA, Vector3 localTipB, float radius, - int segments = Ring.DefaultSegments) + int segmentCount = Ring.DefaultSegmentCount) { MeshProxy proxy = WireframeContainer.ProxyOf(container); Quaternion rotation = ShapeRotations.FromAxis(localTipB - localTipA, Vector3.up); Vector2 radii = new(radius, Vector3.Distance(localTipA, localTipB) * 0.5f); - return new Ellipse(proxy, bone, (localTipA + localTipB) * 0.5f, rotation, radii, segments); + return new Ellipse(proxy, bone, (localTipA + localTipB) * 0.5f, rotation, radii, segmentCount); } /// Creates a white ellipse in world space, lying in the XZ plane of . /// Semi-axes: x along the rotation's X axis and y along its Z axis. - /// Number of straight pieces the ellipse is drawn with, at least 3. + /// Number of straight pieces the ellipse is drawn with, at least 3. public static IEllipse CreateEllipse( this WireframeContainer container, Vector3 center, Quaternion rotation, Vector2 radii, - int segments = Ring.DefaultSegments) + int segmentCount = Ring.DefaultSegmentCount) { - return new Ellipse(WireframeContainer.ProxyOf(container), null, center, rotation, radii, segments); + return new Ellipse(WireframeContainer.ProxyOf(container), null, center, rotation, radii, segmentCount); } } } diff --git a/UnityPackage/Runtime/Shapes/Ellipse/IEllipse.cs b/UnityPackage/Runtime/Shapes/Ellipse/IEllipse.cs index ee81b26..8c336f7 100644 --- a/UnityPackage/Runtime/Shapes/Ellipse/IEllipse.cs +++ b/UnityPackage/Runtime/Shapes/Ellipse/IEllipse.cs @@ -9,6 +9,6 @@ public interface IEllipse : IRigidShape Vector2 Radii { get; set; } /// Number of straight pieces the ellipse is drawn with, fixed at creation. - int Segments { get; } + int SegmentCount { get; } } } diff --git a/UnityPackage/Runtime/Shapes/Ellipsoid/Ellipsoid.cs b/UnityPackage/Runtime/Shapes/Ellipsoid/Ellipsoid.cs index d83fe28..3a2274a 100644 --- a/UnityPackage/Runtime/Shapes/Ellipsoid/Ellipsoid.cs +++ b/UnityPackage/Runtime/Shapes/Ellipsoid/Ellipsoid.cs @@ -13,11 +13,11 @@ internal Ellipsoid( Vector3 localCenter, Quaternion localRotation, Vector3 radii, - int segments) + int segmentCount) : base( proxy, - Ring.CheckSegments(segments) * AxisRings.RingCount, - AxisRings.Patterns.Get(segments), + Ring.CheckSegmentCount(segmentCount) * AxisRings.RingCount, + AxisRings.Patterns.Get(segmentCount), bone, localCenter, localRotation) @@ -40,7 +40,7 @@ public Vector3 Radii } } - public int Segments + public int SegmentCount { get { diff --git a/UnityPackage/Runtime/Shapes/Ellipsoid/EllipsoidFactory.cs b/UnityPackage/Runtime/Shapes/Ellipsoid/EllipsoidFactory.cs index 3003e4e..4e59634 100644 --- a/UnityPackage/Runtime/Shapes/Ellipsoid/EllipsoidFactory.cs +++ b/UnityPackage/Runtime/Shapes/Ellipsoid/EllipsoidFactory.cs @@ -12,57 +12,57 @@ public static IEllipsoid CreateEllipsoid(this WireframeContainer container) { MeshProxy proxy = WireframeContainer.ProxyOf(container); Vector3 radii = new(ShapeDefaults.Radius * 0.5f, ShapeDefaults.Radius * 0.5f, ShapeDefaults.Radius); - return new Ellipsoid(proxy, null, Vector3.zero, Quaternion.identity, radii, Ring.DefaultSegments); + return new Ellipsoid(proxy, null, Vector3.zero, Quaternion.identity, radii, Ring.DefaultSegmentCount); } /// /// Creates a white ellipsoid whose long axis runs between two world positions, with /// as its other two semi-axes. Its +Y stays as close to world up as the tips allow. /// - /// Number of straight pieces each ellipse is drawn with, at least 3. + /// Number of straight pieces each ellipse is drawn with, at least 3. public static IEllipsoid CreateEllipsoid( this WireframeContainer container, Vector3 tipA, Vector3 tipB, float radius, - int segments = Ring.DefaultSegments) + int segmentCount = Ring.DefaultSegmentCount) { MeshProxy proxy = WireframeContainer.ProxyOf(container); Quaternion rotation = ShapeRotations.FromAxis(tipB - tipA, Vector3.up); Vector3 radii = new(radius, radius, Vector3.Distance(tipA, tipB) * 0.5f); - return new Ellipsoid(proxy, null, (tipA + tipB) * 0.5f, rotation, radii, segments); + return new Ellipsoid(proxy, null, (tipA + tipB) * 0.5f, rotation, radii, segmentCount); } /// /// Creates a white ellipsoid that follows , whose long axis runs between two positions in /// the bone's local space, with as its other two semi-axes. /// - /// Number of straight pieces each ellipse is drawn with, at least 3. + /// Number of straight pieces each ellipse is drawn with, at least 3. public static IEllipsoid CreateEllipsoid( this WireframeContainer container, Transform bone, Vector3 localTipA, Vector3 localTipB, float radius, - int segments = Ring.DefaultSegments) + int segmentCount = Ring.DefaultSegmentCount) { MeshProxy proxy = WireframeContainer.ProxyOf(container); Quaternion rotation = ShapeRotations.FromAxis(localTipB - localTipA, Vector3.up); Vector3 radii = new(radius, radius, Vector3.Distance(localTipA, localTipB) * 0.5f); - return new Ellipsoid(proxy, bone, (localTipA + localTipB) * 0.5f, rotation, radii, segments); + return new Ellipsoid(proxy, bone, (localTipA + localTipB) * 0.5f, rotation, radii, segmentCount); } /// Creates a white ellipsoid in world space, turned by . /// Semi-axes along the rotation's X, Y and Z axes. - /// Number of straight pieces each ellipse is drawn with, at least 3. + /// Number of straight pieces each ellipse is drawn with, at least 3. public static IEllipsoid CreateEllipsoid( this WireframeContainer container, Vector3 center, Quaternion rotation, Vector3 radii, - int segments = Ring.DefaultSegments) + int segmentCount = Ring.DefaultSegmentCount) { - return new Ellipsoid(WireframeContainer.ProxyOf(container), null, center, rotation, radii, segments); + return new Ellipsoid(WireframeContainer.ProxyOf(container), null, center, rotation, radii, segmentCount); } } } diff --git a/UnityPackage/Runtime/Shapes/Ellipsoid/IEllipsoid.cs b/UnityPackage/Runtime/Shapes/Ellipsoid/IEllipsoid.cs index d359785..469e84b 100644 --- a/UnityPackage/Runtime/Shapes/Ellipsoid/IEllipsoid.cs +++ b/UnityPackage/Runtime/Shapes/Ellipsoid/IEllipsoid.cs @@ -12,6 +12,6 @@ public interface IEllipsoid : IRigidShape Vector3 Radii { get; set; } /// Number of straight pieces each ellipse is drawn with, fixed at creation. - int Segments { get; } + int SegmentCount { get; } } } diff --git a/UnityPackage/Runtime/Shapes/Frustum/Frustum.cs b/UnityPackage/Runtime/Shapes/Frustum/Frustum.cs index 7283968..d4d3432 100644 --- a/UnityPackage/Runtime/Shapes/Frustum/Frustum.cs +++ b/UnityPackage/Runtime/Shapes/Frustum/Frustum.cs @@ -19,8 +19,8 @@ internal Frustum( float length, float radiusA, float radiusB, - int sides) - : base(proxy, CountVertices(sides), Patterns.Get(sides), bone, localPosition, localRotation, length) + int sideCount) + : base(proxy, CountVertices(sideCount), Patterns.Get(sideCount), bone, localPosition, localRotation, length) { _radiusA = radiusA; _radiusB = radiusB; @@ -56,7 +56,7 @@ public float RadiusB } } - public int Sides + public int SideCount { get { @@ -67,15 +67,15 @@ public int Sides protected override void WriteShape(Span positions, float length) { - int sides = positions.Length / PolygonCount; - for (int corner = 0; corner < sides; corner++) + int sideCount = positions.Length / PolygonCount; + for (int corner = 0; corner < sideCount; corner++) { // Half a side past -Y, so the bottom side is level. - float angle = Mathf.PI * (2f * corner + 1f) / sides - Mathf.PI * 0.5f; + float angle = Mathf.PI * (2f * corner + 1f) / sideCount - Mathf.PI * 0.5f; float cos = Mathf.Cos(angle); float sin = Mathf.Sin(angle); positions[corner] = new Vector3(cos * _radiusA, sin * _radiusA, 0f); - positions[sides + corner] = new Vector3(cos * _radiusB, sin * _radiusB, length); + positions[sideCount + corner] = new Vector3(cos * _radiusB, sin * _radiusB, length); } } @@ -85,25 +85,25 @@ protected override void ScaleCrossSection(float factor) _radiusB *= factor; } - private static int CountVertices(int sides) + private static int CountVertices(int sideCount) { - if (sides < 3) + if (sideCount < 3) { - throw new ArgumentOutOfRangeException(nameof(sides), sides, "A frustum needs at least 3 sides."); + throw new ArgumentOutOfRangeException(nameof(sideCount), sideCount, "A frustum needs at least 3 sides."); } - return sides * PolygonCount; + return sideCount * PolygonCount; } - private static int[] BuildEdges(int sides) + private static int[] BuildEdges(int sideCount) { - int[] pattern = new int[sides * 3 * 2]; + int[] pattern = new int[sideCount * 3 * 2]; int cursor = 0; - Ring.AddEdges(pattern, ref cursor, 0, sides); - Ring.AddEdges(pattern, ref cursor, sides, sides); - for (int corner = 0; corner < sides; corner++) + Ring.AddEdges(pattern, ref cursor, 0, sideCount); + Ring.AddEdges(pattern, ref cursor, sideCount, sideCount); + for (int corner = 0; corner < sideCount; corner++) { pattern[cursor++] = corner; - pattern[cursor++] = sides + corner; + pattern[cursor++] = sideCount + corner; } return pattern; } diff --git a/UnityPackage/Runtime/Shapes/Frustum/FrustumFactory.cs b/UnityPackage/Runtime/Shapes/Frustum/FrustumFactory.cs index ed7d7ec..7b48827 100644 --- a/UnityPackage/Runtime/Shapes/Frustum/FrustumFactory.cs +++ b/UnityPackage/Runtime/Shapes/Frustum/FrustumFactory.cs @@ -5,7 +5,7 @@ namespace reromanlee.Wireframes /// Creates frustums, prisms and regular pyramids in a . public static class FrustumFactory { - private const int DefaultSides = 4; + private const int DefaultSideCount = 4; /// /// Creates a white square frustum from the world origin, 1 unit along +Z, with corners 0.25 out at end A and @@ -22,27 +22,27 @@ public static IFrustum CreateFrustum(this WireframeContainer container) ShapeDefaults.Size, ShapeDefaults.Radius * 0.5f, ShapeDefaults.Radius, - DefaultSides); + DefaultSideCount); } /// /// Creates a white frustum between the centers of its two polygons, both world positions. Its spin around the /// axis keeps the frustum's +Y as close to world up as it can. /// - /// Number of sides of each polygon, at least 3. + /// Number of sides of each polygon, at least 3. public static IFrustum CreateFrustum( - this WireframeContainer container, Vector3 endA, Vector3 endB, float radiusA, float radiusB, int sides) + this WireframeContainer container, Vector3 endA, Vector3 endB, float radiusA, float radiusB, int sideCount) { MeshProxy proxy = WireframeContainer.ProxyOf(container); Quaternion rotation = ShapeRotations.FromAxis(endB - endA, Vector3.up); - return new Frustum(proxy, null, endA, rotation, Vector3.Distance(endA, endB), radiusA, radiusB, sides); + return new Frustum(proxy, null, endA, rotation, Vector3.Distance(endA, endB), radiusA, radiusB, sideCount); } /// /// Creates a white frustum that follows , between the centers of its two polygons in the /// bone's local space. Its spin around the axis keeps the frustum's +Y as close to the bone's up as it can. /// - /// Number of sides of each polygon, at least 3. + /// Number of sides of each polygon, at least 3. public static IFrustum CreateFrustum( this WireframeContainer container, Transform bone, @@ -50,19 +50,19 @@ public static IFrustum CreateFrustum( Vector3 localEndB, float radiusA, float radiusB, - int sides) + int sideCount) { MeshProxy proxy = WireframeContainer.ProxyOf(container); Quaternion rotation = ShapeRotations.FromAxis(localEndB - localEndA, Vector3.up); float length = Vector3.Distance(localEndA, localEndB); - return new Frustum(proxy, bone, localEndA, rotation, length, radiusA, radiusB, sides); + return new Frustum(proxy, bone, localEndA, rotation, length, radiusA, radiusB, sideCount); } /// /// Creates a white frustum in world space that starts at and runs /// along the +Z axis of . /// - /// Number of sides of each polygon, at least 3. + /// Number of sides of each polygon, at least 3. public static IFrustum CreateFrustum( this WireframeContainer container, Vector3 position, @@ -70,10 +70,10 @@ public static IFrustum CreateFrustum( float length, float radiusA, float radiusB, - int sides) + int sideCount) { MeshProxy proxy = WireframeContainer.ProxyOf(container); - return new Frustum(proxy, null, position, rotation, length, radiusA, radiusB, sides); + return new Frustum(proxy, null, position, rotation, length, radiusA, radiusB, sideCount); } } } diff --git a/UnityPackage/Runtime/Shapes/Frustum/IFrustum.cs b/UnityPackage/Runtime/Shapes/Frustum/IFrustum.cs index 849def7..2e2d21f 100644 --- a/UnityPackage/Runtime/Shapes/Frustum/IFrustum.cs +++ b/UnityPackage/Runtime/Shapes/Frustum/IFrustum.cs @@ -14,6 +14,6 @@ public interface IFrustum : IAxialShape float RadiusB { get; set; } /// Number of sides of each polygon, fixed at creation. - int Sides { get; } + int SideCount { get; } } } diff --git a/UnityPackage/Runtime/Shapes/IAxialShape.cs b/UnityPackage/Runtime/Shapes/IAxialShape.cs index 26d1678..1e07a1f 100644 --- a/UnityPackage/Runtime/Shapes/IAxialShape.cs +++ b/UnityPackage/Runtime/Shapes/IAxialShape.cs @@ -4,21 +4,31 @@ namespace reromanlee.Wireframes { /// /// A long shape that runs from end A, at , along the +Z axis of its rotation - /// to end B. + /// to end B. Setting one end keeps the other where it is, while setting the position moves the whole shape. /// public interface IAxialShape : IRigidShape { /// Distance from end A to end B, in the bone's units. float Length { get; set; } + /// + /// End A relative to , or in world space without a bone; the same point as + /// . Setting it keeps end B where it is: the axis turns by the smallest + /// rotation and follows. + /// + Vector3 LocalEndA { get; set; } + /// /// End B relative to , or in world space without a bone. Setting it keeps end A, /// turns the axis toward the new point by the smallest rotation, which keeps the spin around the axis as far /// as it can, and sets . /// - Vector3 LocalEnd { get; set; } + Vector3 LocalEndB { get; set; } + + /// End A in world space, converted through the bone's current pose. Setting it works like . + Vector3 WorldEndA { get; set; } - /// End B in world space, converted through the bone's current pose. Setting it works like . - Vector3 WorldEnd { get; set; } + /// End B in world space, converted through the bone's current pose. Setting it works like . + Vector3 WorldEndB { get; set; } } } diff --git a/UnityPackage/Runtime/Shapes/RoundedRectangle/IRoundedRectangle.cs b/UnityPackage/Runtime/Shapes/RoundedRectangle/IRoundedRectangle.cs index 9ce294e..d8cab2f 100644 --- a/UnityPackage/Runtime/Shapes/RoundedRectangle/IRoundedRectangle.cs +++ b/UnityPackage/Runtime/Shapes/RoundedRectangle/IRoundedRectangle.cs @@ -13,6 +13,6 @@ public interface IRoundedRectangle : IRectangle /// Number of straight pieces a full circle of the corners is drawn with, fixed at creation. Each corner is a /// quarter of them. /// - int Segments { get; } + int SegmentCount { get; } } } diff --git a/UnityPackage/Runtime/Shapes/RoundedRectangle/RoundedRectangle.cs b/UnityPackage/Runtime/Shapes/RoundedRectangle/RoundedRectangle.cs index 5152fcb..8c58694 100644 --- a/UnityPackage/Runtime/Shapes/RoundedRectangle/RoundedRectangle.cs +++ b/UnityPackage/Runtime/Shapes/RoundedRectangle/RoundedRectangle.cs @@ -17,11 +17,11 @@ internal RoundedRectangle( Quaternion localRotation, Vector2 size, float cornerRadius, - int segments) + int segmentCount) : base( proxy, - Ring.CheckQuarterSegments(segments) + CornerCount, - Patterns.Get(segments), + Ring.CheckQuarterSegmentCount(segmentCount) + CornerCount, + Patterns.Get(segmentCount), bone, localCenter, localRotation, @@ -45,7 +45,7 @@ public float CornerRadius } } - public int Segments + public int SegmentCount { get { @@ -56,8 +56,8 @@ public int Segments protected override void WriteShape(Span positions) { - int segments = positions.Length - CornerCount; - int quarter = segments / 4; + int segmentCount = positions.Length - CornerCount; + int quarter = segmentCount / 4; Vector2 size = Size; // Built for the absolute size, then mirrored by its signs. float halfX = Mathf.Abs(size.x) * 0.5f; @@ -65,7 +65,7 @@ protected override void WriteShape(Span positions) float mirrorX = size.x < 0f ? -1f : 1f; float mirrorZ = size.y < 0f ? -1f : 1f; float radius = Mathf.Clamp(_cornerRadius, 0f, Mathf.Min(halfX, halfZ)); - Vector2[] circle = Ring.UnitCircle(segments); + Vector2[] circle = Ring.UnitCircle(segmentCount); // Going around from +Z toward +X, the corners are (+X, +Z), (+X, -Z), (-X, -Z) and (-X, +Z); each is a // quarter of a circle around a point inset by the radius. @@ -76,7 +76,7 @@ protected override void WriteShape(Span positions) float centerZ = corner == 0 || corner == 3 ? halfZ - radius : radius - halfZ; for (int step = 0; step <= quarter; step++) { - Vector2 direction = circle[(corner * quarter + step) % segments]; + Vector2 direction = circle[(corner * quarter + step) % segmentCount]; positions[vertex++] = new Vector3( (centerX + direction.y * radius) * mirrorX, 0f, @@ -91,11 +91,11 @@ protected override void ScaleSizes(float factor) _cornerRadius *= factor; } - private static int[] BuildEdges(int segments) + private static int[] BuildEdges(int segmentCount) { - int quarter = segments / 4; + int quarter = segmentCount / 4; int cornerVertices = quarter + 1; - int[] pattern = new int[(segments + CornerCount) * 2]; + int[] pattern = new int[(segmentCount + CornerCount) * 2]; int cursor = 0; for (int corner = 0; corner < CornerCount; corner++) { diff --git a/UnityPackage/Runtime/Shapes/RoundedRectangle/RoundedRectangleFactory.cs b/UnityPackage/Runtime/Shapes/RoundedRectangle/RoundedRectangleFactory.cs index dca0944..e0ed1dc 100644 --- a/UnityPackage/Runtime/Shapes/RoundedRectangle/RoundedRectangleFactory.cs +++ b/UnityPackage/Runtime/Shapes/RoundedRectangle/RoundedRectangleFactory.cs @@ -16,14 +16,14 @@ public static IRoundedRectangle CreateRoundedRectangle(this WireframeContainer c MeshProxy proxy = WireframeContainer.ProxyOf(container); Vector2 size = Vector2.one * ShapeDefaults.Size; return new RoundedRectangle( - proxy, null, Vector3.zero, Quaternion.identity, size, DefaultCornerRadius, Ring.DefaultSegments); + proxy, null, Vector3.zero, Quaternion.identity, size, DefaultCornerRadius, Ring.DefaultSegmentCount); } /// /// Creates a white, world-aligned rounded rectangle lying flat in the XZ plane, spanned by two opposite /// world-space corners. If the corners differ in height, the rectangle lies halfway between them. /// - /// + /// /// Number of straight pieces a full circle of the corners is drawn with, a positive multiple of 4. /// public static IRoundedRectangle CreateRoundedRectangle( @@ -31,19 +31,19 @@ public static IRoundedRectangle CreateRoundedRectangle( Vector3 cornerA, Vector3 cornerB, float cornerRadius, - int segments = Ring.DefaultSegments) + int segmentCount = Ring.DefaultSegmentCount) { MeshProxy proxy = WireframeContainer.ProxyOf(container); Vector3 center = (cornerA + cornerB) * 0.5f; Vector2 size = RectangleShape.FlatSpan(cornerA, cornerB); - return new RoundedRectangle(proxy, null, center, Quaternion.identity, size, cornerRadius, segments); + return new RoundedRectangle(proxy, null, center, Quaternion.identity, size, cornerRadius, segmentCount); } /// /// Creates a white rounded rectangle that follows , lying flat in the bone's XZ plane /// and spanned by two opposite corners in the bone's local space. /// - /// + /// /// Number of straight pieces a full circle of the corners is drawn with, a positive multiple of 4. /// public static IRoundedRectangle CreateRoundedRectangle( @@ -52,18 +52,18 @@ public static IRoundedRectangle CreateRoundedRectangle( Vector3 localCornerA, Vector3 localCornerB, float cornerRadius, - int segments = Ring.DefaultSegments) + int segmentCount = Ring.DefaultSegmentCount) { MeshProxy proxy = WireframeContainer.ProxyOf(container); Vector3 center = (localCornerA + localCornerB) * 0.5f; Vector2 size = RectangleShape.FlatSpan(localCornerA, localCornerB); - return new RoundedRectangle(proxy, bone, center, Quaternion.identity, size, cornerRadius, segments); + return new RoundedRectangle(proxy, bone, center, Quaternion.identity, size, cornerRadius, segmentCount); } /// /// Creates a white rounded rectangle in world space, lying in the XZ plane of . /// - /// + /// /// Number of straight pieces a full circle of the corners is drawn with, a positive multiple of 4. /// public static IRoundedRectangle CreateRoundedRectangle( @@ -72,10 +72,10 @@ public static IRoundedRectangle CreateRoundedRectangle( Quaternion rotation, Vector2 size, float cornerRadius, - int segments = Ring.DefaultSegments) + int segmentCount = Ring.DefaultSegmentCount) { MeshProxy proxy = WireframeContainer.ProxyOf(container); - return new RoundedRectangle(proxy, null, center, rotation, size, cornerRadius, segments); + return new RoundedRectangle(proxy, null, center, rotation, size, cornerRadius, segmentCount); } } } diff --git a/UnityPackage/Runtime/Shapes/Sphere/ISphere.cs b/UnityPackage/Runtime/Shapes/Sphere/ISphere.cs index c37413b..78713b0 100644 --- a/UnityPackage/Runtime/Shapes/Sphere/ISphere.cs +++ b/UnityPackage/Runtime/Shapes/Sphere/ISphere.cs @@ -10,6 +10,6 @@ public interface ISphere : IRigidShape float Radius { get; set; } /// Number of straight pieces each great circle is drawn with, fixed at creation. - int Segments { get; } + int SegmentCount { get; } } } diff --git a/UnityPackage/Runtime/Shapes/Sphere/Sphere.cs b/UnityPackage/Runtime/Shapes/Sphere/Sphere.cs index 59f6f12..e53fade 100644 --- a/UnityPackage/Runtime/Shapes/Sphere/Sphere.cs +++ b/UnityPackage/Runtime/Shapes/Sphere/Sphere.cs @@ -13,11 +13,11 @@ internal Sphere( Vector3 localCenter, Quaternion localRotation, float radius, - int segments) + int segmentCount) : base( proxy, - Ring.CheckSegments(segments) * AxisRings.RingCount, - AxisRings.Patterns.Get(segments), + Ring.CheckSegmentCount(segmentCount) * AxisRings.RingCount, + AxisRings.Patterns.Get(segmentCount), bone, localCenter, localRotation) @@ -40,7 +40,7 @@ public float Radius } } - public int Segments + public int SegmentCount { get { diff --git a/UnityPackage/Runtime/Shapes/Sphere/SphereFactory.cs b/UnityPackage/Runtime/Shapes/Sphere/SphereFactory.cs index 1b5a124..c924b61 100644 --- a/UnityPackage/Runtime/Shapes/Sphere/SphereFactory.cs +++ b/UnityPackage/Runtime/Shapes/Sphere/SphereFactory.cs @@ -9,43 +9,43 @@ public static class SphereFactory public static ISphere CreateSphere(this WireframeContainer container) { MeshProxy proxy = WireframeContainer.ProxyOf(container); - return new Sphere(proxy, null, Vector3.zero, Quaternion.identity, ShapeDefaults.Radius, Ring.DefaultSegments); + return new Sphere(proxy, null, Vector3.zero, Quaternion.identity, ShapeDefaults.Radius, Ring.DefaultSegmentCount); } /// Creates a white, world-aligned sphere around a world position. - /// Number of straight pieces each great circle is drawn with, at least 3. + /// Number of straight pieces each great circle is drawn with, at least 3. public static ISphere CreateSphere( - this WireframeContainer container, Vector3 center, float radius, int segments = Ring.DefaultSegments) + this WireframeContainer container, Vector3 center, float radius, int segmentCount = Ring.DefaultSegmentCount) { - return new Sphere(WireframeContainer.ProxyOf(container), null, center, Quaternion.identity, radius, segments); + return new Sphere(WireframeContainer.ProxyOf(container), null, center, Quaternion.identity, radius, segmentCount); } /// /// Creates a white sphere that follows , around a position in the bone's local space and /// aligned to the bone's axes. /// - /// Number of straight pieces each great circle is drawn with, at least 3. + /// Number of straight pieces each great circle is drawn with, at least 3. public static ISphere CreateSphere( this WireframeContainer container, Transform bone, Vector3 localCenter, float radius, - int segments = Ring.DefaultSegments) + int segmentCount = Ring.DefaultSegmentCount) { MeshProxy proxy = WireframeContainer.ProxyOf(container); - return new Sphere(proxy, bone, localCenter, Quaternion.identity, radius, segments); + return new Sphere(proxy, bone, localCenter, Quaternion.identity, radius, segmentCount); } /// Creates a white sphere in world space, with its great circles turned by . - /// Number of straight pieces each great circle is drawn with, at least 3. + /// Number of straight pieces each great circle is drawn with, at least 3. public static ISphere CreateSphere( this WireframeContainer container, Vector3 center, Quaternion rotation, float radius, - int segments = Ring.DefaultSegments) + int segmentCount = Ring.DefaultSegmentCount) { - return new Sphere(WireframeContainer.ProxyOf(container), null, center, rotation, radius, segments); + return new Sphere(WireframeContainer.ProxyOf(container), null, center, rotation, radius, segmentCount); } } } diff --git a/UnityPackage/Runtime/Shapes/Stadium/IStadium.cs b/UnityPackage/Runtime/Shapes/Stadium/IStadium.cs index 241cb6b..318a710 100644 --- a/UnityPackage/Runtime/Shapes/Stadium/IStadium.cs +++ b/UnityPackage/Runtime/Shapes/Stadium/IStadium.cs @@ -15,6 +15,6 @@ public interface IStadium : IAxialShape /// /// Number of straight pieces a full circle would be drawn with, fixed at creation. Each end is half of them. /// - int Segments { get; } + int SegmentCount { get; } } } diff --git a/UnityPackage/Runtime/Shapes/Stadium/Stadium.cs b/UnityPackage/Runtime/Shapes/Stadium/Stadium.cs index 2be4bbe..3b154d3 100644 --- a/UnityPackage/Runtime/Shapes/Stadium/Stadium.cs +++ b/UnityPackage/Runtime/Shapes/Stadium/Stadium.cs @@ -18,12 +18,12 @@ internal Stadium( float length, float radiusA, float radiusB, - int segments) + int segmentCount) : base( proxy, - Ring.CheckQuarterSegments(segments) + ArcCount, + Ring.CheckQuarterSegmentCount(segmentCount) + ArcCount, // One closed loop: arc A, the -X side, arc B and the +X side back to the start. - Ring.Patterns.Get(segments + ArcCount), + Ring.Patterns.Get(segmentCount + ArcCount), bone, localPosition, localRotation, @@ -63,7 +63,7 @@ public float RadiusB } } - public int Segments + public int SegmentCount { get { diff --git a/UnityPackage/Runtime/Shapes/Stadium/StadiumFactory.cs b/UnityPackage/Runtime/Shapes/Stadium/StadiumFactory.cs index f87f34d..fc2fdbd 100644 --- a/UnityPackage/Runtime/Shapes/Stadium/StadiumFactory.cs +++ b/UnityPackage/Runtime/Shapes/Stadium/StadiumFactory.cs @@ -20,45 +20,45 @@ public static IStadium CreateStadium(this WireframeContainer container) ShapeDefaults.Size, ShapeDefaults.Radius, ShapeDefaults.Radius, - Ring.DefaultSegments); + Ring.DefaultSegmentCount); } /// /// Creates a white stadium around two circles of the same radius, centered on two world positions. It lies as /// flat as it can: its normal stays as close to world up as the centers allow. /// - /// Number of straight pieces a full circle would be drawn with, a positive multiple of 4. + /// Number of straight pieces a full circle would be drawn with, a positive multiple of 4. public static IStadium CreateStadium( this WireframeContainer container, Vector3 centerA, Vector3 centerB, float radius, - int segments = Ring.DefaultSegments) + int segmentCount = Ring.DefaultSegmentCount) { - return container.CreateStadium(centerA, centerB, radius, radius, Vector3.up, segments); + return container.CreateStadium(centerA, centerB, radius, radius, Vector3.up, segmentCount); } /// /// Creates a white stadium around two circles centered on two world positions, each with its own radius. It lies /// as flat as it can: its normal stays as close to world up as the centers allow. /// - /// Number of straight pieces a full circle would be drawn with, a positive multiple of 4. + /// Number of straight pieces a full circle would be drawn with, a positive multiple of 4. public static IStadium CreateStadium( this WireframeContainer container, Vector3 centerA, Vector3 centerB, float radiusA, float radiusB, - int segments = Ring.DefaultSegments) + int segmentCount = Ring.DefaultSegmentCount) { - return container.CreateStadium(centerA, centerB, radiusA, radiusB, Vector3.up, segments); + return container.CreateStadium(centerA, centerB, radiusA, radiusB, Vector3.up, segmentCount); } /// /// Creates a white stadium around two circles centered on two world positions, facing as close to /// as the centers allow. /// - /// Number of straight pieces a full circle would be drawn with, a positive multiple of 4. + /// Number of straight pieces a full circle would be drawn with, a positive multiple of 4. public static IStadium CreateStadium( this WireframeContainer container, Vector3 centerA, @@ -66,19 +66,19 @@ public static IStadium CreateStadium( float radiusA, float radiusB, Vector3 normal, - int segments = Ring.DefaultSegments) + int segmentCount = Ring.DefaultSegmentCount) { MeshProxy proxy = WireframeContainer.ProxyOf(container); Quaternion rotation = ShapeRotations.FromAxis(centerB - centerA, normal); float length = Vector3.Distance(centerA, centerB); - return new Stadium(proxy, null, centerA, rotation, length, radiusA, radiusB, segments); + return new Stadium(proxy, null, centerA, rotation, length, radiusA, radiusB, segmentCount); } /// /// Creates a white stadium that follows , around two circles centered on positions in the /// bone's local space. It lies as flat in the bone's XZ plane as the centers allow. /// - /// Number of straight pieces a full circle would be drawn with, a positive multiple of 4. + /// Number of straight pieces a full circle would be drawn with, a positive multiple of 4. public static IStadium CreateStadium( this WireframeContainer container, Transform bone, @@ -86,12 +86,12 @@ public static IStadium CreateStadium( Vector3 localCenterB, float radiusA, float radiusB, - int segments = Ring.DefaultSegments) + int segmentCount = Ring.DefaultSegmentCount) { MeshProxy proxy = WireframeContainer.ProxyOf(container); Quaternion rotation = ShapeRotations.FromAxis(localCenterB - localCenterA, Vector3.up); float length = Vector3.Distance(localCenterA, localCenterB); - return new Stadium(proxy, bone, localCenterA, rotation, length, radiusA, radiusB, segments); + return new Stadium(proxy, bone, localCenterA, rotation, length, radiusA, radiusB, segmentCount); } /// @@ -99,7 +99,7 @@ public static IStadium CreateStadium( /// circle is centered on and whose second is centered /// along the rotation's +Z axis. /// - /// Number of straight pieces a full circle would be drawn with, a positive multiple of 4. + /// Number of straight pieces a full circle would be drawn with, a positive multiple of 4. public static IStadium CreateStadium( this WireframeContainer container, Vector3 position, @@ -107,10 +107,10 @@ public static IStadium CreateStadium( float length, float radiusA, float radiusB, - int segments = Ring.DefaultSegments) + int segmentCount = Ring.DefaultSegmentCount) { MeshProxy proxy = WireframeContainer.ProxyOf(container); - return new Stadium(proxy, null, position, rotation, length, radiusA, radiusB, segments); + return new Stadium(proxy, null, position, rotation, length, radiusA, radiusB, segmentCount); } } } diff --git a/UnityPackage/Runtime/Shapes/Star/Star.cs b/UnityPackage/Runtime/Shapes/Star/Star.cs index cfb88d5..fdaa567 100644 --- a/UnityPackage/Runtime/Shapes/Star/Star.cs +++ b/UnityPackage/Runtime/Shapes/Star/Star.cs @@ -15,8 +15,8 @@ internal Star( Quaternion localRotation, float innerRadius, float outerRadius, - int points) - : base(proxy, CountVertices(points), Ring.Patterns.Get(points * 2), bone, localCenter, localRotation) + int pointCount) + : base(proxy, CountVertices(pointCount), Ring.Patterns.Get(pointCount * 2), bone, localCenter, localRotation) { _innerRadius = innerRadius; _outerRadius = outerRadius; @@ -78,13 +78,13 @@ protected override void ScaleSizes(float factor) _outerRadius *= factor; } - private static int CountVertices(int points) + private static int CountVertices(int pointCount) { - if (points < 3) + if (pointCount < 3) { - throw new ArgumentOutOfRangeException(nameof(points), points, "A star needs at least 3 points."); + throw new ArgumentOutOfRangeException(nameof(pointCount), pointCount, "A star needs at least 3 points."); } - return points * 2; + return pointCount * 2; } } } diff --git a/UnityPackage/Runtime/Shapes/Star/StarFactory.cs b/UnityPackage/Runtime/Shapes/Star/StarFactory.cs index fe7aec2..e6b29c9 100644 --- a/UnityPackage/Runtime/Shapes/Star/StarFactory.cs +++ b/UnityPackage/Runtime/Shapes/Star/StarFactory.cs @@ -6,7 +6,7 @@ namespace reromanlee.Wireframes public static class StarFactory { private const float DefaultInnerRadius = 0.2f; - private const int DefaultPoints = 5; + private const int DefaultPointCount = 5; /// /// Creates a white five-pointed star around the world origin, lying flat in the XZ plane, with an outer radius @@ -16,53 +16,53 @@ public static IStar CreateStar(this WireframeContainer container) { MeshProxy proxy = WireframeContainer.ProxyOf(container); return new Star( - proxy, null, Vector3.zero, Quaternion.identity, DefaultInnerRadius, ShapeDefaults.Radius, DefaultPoints); + proxy, null, Vector3.zero, Quaternion.identity, DefaultInnerRadius, ShapeDefaults.Radius, DefaultPointCount); } /// /// Creates a white star around a world position, lying flat in the world's XZ plane with its first point along /// +Z. /// - /// Number of points, at least 3. + /// Number of points, at least 3. public static IStar CreateStar( - this WireframeContainer container, Vector3 center, float innerRadius, float outerRadius, int points) + this WireframeContainer container, Vector3 center, float innerRadius, float outerRadius, int pointCount) { MeshProxy proxy = WireframeContainer.ProxyOf(container); - return new Star(proxy, null, center, Quaternion.identity, innerRadius, outerRadius, points); + return new Star(proxy, null, center, Quaternion.identity, innerRadius, outerRadius, pointCount); } /// /// Creates a white star that follows , around a position in the bone's local space and /// lying flat in the bone's XZ plane with its first point along the bone's +Z. /// - /// Number of points, at least 3. + /// Number of points, at least 3. public static IStar CreateStar( this WireframeContainer container, Transform bone, Vector3 localCenter, float innerRadius, float outerRadius, - int points) + int pointCount) { MeshProxy proxy = WireframeContainer.ProxyOf(container); - return new Star(proxy, bone, localCenter, Quaternion.identity, innerRadius, outerRadius, points); + return new Star(proxy, bone, localCenter, Quaternion.identity, innerRadius, outerRadius, pointCount); } /// /// Creates a white star in world space, lying in the XZ plane of with its first point /// along the rotation's +Z. /// - /// Number of points, at least 3. + /// Number of points, at least 3. public static IStar CreateStar( this WireframeContainer container, Vector3 center, Quaternion rotation, float innerRadius, float outerRadius, - int points) + int pointCount) { MeshProxy proxy = WireframeContainer.ProxyOf(container); - return new Star(proxy, null, center, rotation, innerRadius, outerRadius, points); + return new Star(proxy, null, center, rotation, innerRadius, outerRadius, pointCount); } } } diff --git a/UnityPackage/Tests/Runtime/CapsuleTests.cs b/UnityPackage/Tests/Runtime/CapsuleTests.cs index db6fea2..7eb58b5 100644 --- a/UnityPackage/Tests/Runtime/CapsuleTests.cs +++ b/UnityPackage/Tests/Runtime/CapsuleTests.cs @@ -77,7 +77,7 @@ public void NegativeLength_MirrorsTheCapsule() ICapsule capsule = container.CreateCapsule(Vector3.zero, Quaternion.identity, -4f, 1f, 0.5f, 8); - AssertApproximately(new Vector3(0f, 0f, -4f), capsule.WorldEnd); + AssertApproximately(new Vector3(0f, 0f, -4f), capsule.WorldEndB); Vector3[] vertices = BakeShape(container, capsule); Assert.That(Array.Exists(vertices, vertex => Vector3.Distance(vertex, new Vector3(0f, 0f, -4.5f)) < 1e-4f)); Assert.That(Array.Exists(vertices, vertex => Vector3.Distance(vertex, new Vector3(0f, 0f, 1f)) < 1e-4f)); @@ -91,7 +91,7 @@ public void CreateCapsule_WithoutArguments_HasHalfUnitSpheresOneUnitApart() Assert.That(capsule.Length, Is.EqualTo(1f)); Assert.That(capsule.RadiusA, Is.EqualTo(0.5f)); Assert.That(capsule.RadiusB, Is.EqualTo(0.5f)); - Assert.That(capsule.Segments, Is.EqualTo(32)); + Assert.That(capsule.SegmentCount, Is.EqualTo(32)); } [Test] @@ -104,11 +104,11 @@ public void CreateCapsuleOnBone_FollowsTheBone() upperArm.rotation = Quaternion.Euler(0f, 0f, 90f); Assert.That(arm.Bone, Is.SameAs(upperArm)); - AssertApproximately(upperArm.TransformPoint(new Vector3(0f, -0.3f, 0f)), arm.WorldEnd); + AssertApproximately(upperArm.TransformPoint(new Vector3(0f, -0.3f, 0f)), arm.WorldEndB); foreach (Vector3 vertex in BakeShape(container, arm)) { float offSphereA = Mathf.Abs(Vector3.Distance(upperArm.position, vertex) - 0.1f); - float offSphereB = Mathf.Abs(Vector3.Distance(arm.WorldEnd, vertex) - 0.08f); + float offSphereB = Mathf.Abs(Vector3.Distance(arm.WorldEndB, vertex) - 0.08f); Assert.That(Mathf.Min(offSphereA, offSphereB), Is.LessThan(1e-4f)); } } diff --git a/UnityPackage/Tests/Runtime/CircleTests.cs b/UnityPackage/Tests/Runtime/CircleTests.cs index e9d59f2..3ff7d50 100644 --- a/UnityPackage/Tests/Runtime/CircleTests.cs +++ b/UnityPackage/Tests/Runtime/CircleTests.cs @@ -40,7 +40,7 @@ public void CreateCircle_WithoutArguments_IsHalfUnitRadiusAtOrigin() ICircle circle = CreateContainer().CreateCircle(); Assert.That(circle.Radius, Is.EqualTo(0.5f)); - Assert.That(circle.Segments, Is.EqualTo(32)); + Assert.That(circle.SegmentCount, Is.EqualTo(32)); Assert.That(circle.WorldPosition, Is.EqualTo(Vector3.zero)); Assert.That(circle.WorldRotation, Is.EqualTo(Quaternion.identity)); Assert.That(circle.Bone, Is.Null); @@ -97,7 +97,7 @@ public void FewerThanThreeSegments_Throw() Assert.Throws(() => container.CreateCircle(Vector3.zero, 1f, 2)); Assert.Throws(() => container.CreateCircle(Vector3.zero, 1f, -1)); - Assert.That(container.CreateCircle(Vector3.zero, 1f, 3).Segments, Is.EqualTo(3)); + Assert.That(container.CreateCircle(Vector3.zero, 1f, 3).SegmentCount, Is.EqualTo(3)); Assert.That(ChunkOf(container).ShapeCount, Is.EqualTo(1), "A rejected circle must not stay in the mesh."); } } diff --git a/UnityPackage/Tests/Runtime/ConeTests.cs b/UnityPackage/Tests/Runtime/ConeTests.cs index b110be8..185bec3 100644 --- a/UnityPackage/Tests/Runtime/ConeTests.cs +++ b/UnityPackage/Tests/Runtime/ConeTests.cs @@ -31,7 +31,7 @@ public void Cone_IsABaseRingJoinedToTheTipByFourLines() AssertApproximately(vertices[line * 2], edges[8 + line].B); } AssertApproximately(2f, cone.Length); - AssertApproximately(baseCenter, cone.WorldEnd); + AssertApproximately(baseCenter, cone.WorldEndB); } [Test] @@ -41,8 +41,8 @@ public void CreateCone_WithoutArguments_PointsItsBaseAlongZ() Assert.That(cone.Length, Is.EqualTo(1f)); Assert.That(cone.Radius, Is.EqualTo(0.5f)); - Assert.That(cone.Segments, Is.EqualTo(32)); - Assert.That(cone.WorldEnd, Is.EqualTo(new Vector3(0f, 0f, 1f))); + Assert.That(cone.SegmentCount, Is.EqualTo(32)); + Assert.That(cone.WorldEndB, Is.EqualTo(new Vector3(0f, 0f, 1f))); } [Test] @@ -55,7 +55,7 @@ public void CreateConeOnBone_PutsTheTipOnTheBone() Assert.That(sight.Bone, Is.SameAs(head)); AssertApproximately(head.position, BakeShape(container, sight)[4]); - AssertApproximately(head.TransformPoint(new Vector3(0f, 0f, 10f)), sight.WorldEnd); + AssertApproximately(head.TransformPoint(new Vector3(0f, 0f, 10f)), sight.WorldEndB); } [Test] @@ -65,7 +65,7 @@ public void PoseForm_RunsAlongTheRotationsZ() ICone cone = CreateContainer().CreateCone(Vector3.zero, rotation, 5f, 1f); - AssertApproximately(new Vector3(5f, 0f, 0f), cone.WorldEnd); + AssertApproximately(new Vector3(5f, 0f, 0f), cone.WorldEndB); } [Test] diff --git a/UnityPackage/Tests/Runtime/CylinderTests.cs b/UnityPackage/Tests/Runtime/CylinderTests.cs index 94b4c9d..2be1462 100644 --- a/UnityPackage/Tests/Runtime/CylinderTests.cs +++ b/UnityPackage/Tests/Runtime/CylinderTests.cs @@ -44,8 +44,8 @@ public void CreateCylinder_WithoutArguments_RunsOneUnitAlongZ() Assert.That(cylinder.Length, Is.EqualTo(1f)); Assert.That(cylinder.Radius, Is.EqualTo(0.5f)); - Assert.That(cylinder.Segments, Is.EqualTo(32)); - Assert.That(cylinder.WorldEnd, Is.EqualTo(new Vector3(0f, 0f, 1f))); + Assert.That(cylinder.SegmentCount, Is.EqualTo(32)); + Assert.That(cylinder.WorldEndB, Is.EqualTo(new Vector3(0f, 0f, 1f))); } [Test] @@ -54,7 +54,7 @@ public void TwoPointForm_KeepsUpClosestToWorldUp() ICylinder cylinder = CreateContainer().CreateCylinder(Vector3.zero, new Vector3(3f, 4f, 0f), 1f); AssertApproximately(Vector3.zero, cylinder.WorldPosition); - AssertApproximately(new Vector3(3f, 4f, 0f), cylinder.WorldEnd); + AssertApproximately(new Vector3(3f, 4f, 0f), cylinder.WorldEndB); AssertApproximately(5f, cylinder.Length); // +X stays level, so +Y leans back from the axis toward world up. AssertApproximately(0f, (cylinder.WorldRotation * Vector3.right).y); @@ -67,7 +67,7 @@ public void TwoPointForm_AlongWorldUp_TurnsZUpByTheSmallestRotation() ICylinder cylinder = CreateContainer().CreateCylinder(Vector3.zero, new Vector3(0f, 5f, 0f), 1f); AssertApproximately(Quaternion.FromToRotation(Vector3.forward, Vector3.up), cylinder.WorldRotation); - AssertApproximately(new Vector3(0f, 5f, 0f), cylinder.WorldEnd); + AssertApproximately(new Vector3(0f, 5f, 0f), cylinder.WorldEndB); } [Test] @@ -75,22 +75,63 @@ public void SettingEnd_TurnsBySmallestRotationAndSetsLength() { ICylinder cylinder = CreateContainer().CreateCylinder(new Vector3(1f, 1f, 1f), new Vector3(3f, 1f, 1f), 0.5f); - cylinder.WorldEnd = new Vector3(1f, 1f, 4f); + cylinder.WorldEndB = new Vector3(1f, 1f, 4f); AssertApproximately(new Vector3(1f, 1f, 1f), cylinder.WorldPosition); - AssertApproximately(new Vector3(1f, 1f, 4f), cylinder.WorldEnd); + AssertApproximately(new Vector3(1f, 1f, 4f), cylinder.WorldEndB); AssertApproximately(3f, cylinder.Length); // A turn within the ground plane leaves the cylinder's +Y pointing up. AssertApproximately(Vector3.up, cylinder.WorldRotation * Vector3.up); } + [Test] + public void SettingEndA_KeepsEndBAndTurnsTheAxis() + { + ICylinder cylinder = CreateContainer().CreateCylinder(new Vector3(1f, 1f, 1f), new Vector3(3f, 1f, 1f), 0.5f); + + cylinder.WorldEndA = new Vector3(3f, 1f, -1f); + + AssertApproximately(new Vector3(3f, 1f, -1f), cylinder.WorldEndA); + AssertApproximately(cylinder.WorldEndA, cylinder.WorldPosition); + AssertApproximately(new Vector3(3f, 1f, 1f), cylinder.WorldEndB); + AssertApproximately(2f, cylinder.Length); + AssertApproximately(Vector3.up, cylinder.WorldRotation * Vector3.up); + } + + [Test] + public void SettingBothEnds_OnABone_WorksInItsLocalSpace() + { + Transform bone = CreateBone(new Vector3(0f, 2f, 0f), Quaternion.Euler(0f, 90f, 0f), 2f); + ICylinder cylinder = CreateContainer().CreateCylinder(bone, Vector3.zero, Vector3.forward, 0.25f); + + cylinder.WorldEndA = new Vector3(1f, 2f, 0f); + cylinder.WorldEndB = new Vector3(1f, 6f, 0f); + + AssertApproximately(new Vector3(1f, 2f, 0f), cylinder.WorldEndA); + AssertApproximately(new Vector3(1f, 6f, 0f), cylinder.WorldEndB); + AssertApproximately(bone.InverseTransformPoint(new Vector3(1f, 6f, 0f)), cylinder.LocalEndB); + AssertApproximately(2f, cylinder.Length); + } + + [Test] + public void SettingPosition_MovesBothEnds() + { + ICylinder cylinder = CreateContainer().CreateCylinder(Vector3.zero, new Vector3(0f, 0f, 2f), 0.5f); + + cylinder.WorldPosition = new Vector3(5f, 0f, 0f); + + AssertApproximately(new Vector3(5f, 0f, 0f), cylinder.WorldEndA); + AssertApproximately(new Vector3(5f, 0f, 2f), cylinder.WorldEndB); + AssertApproximately(2f, cylinder.Length); + } + [Test] public void SettingEndOnTopOfEndA_KeepsRotation() { ICylinder cylinder = CreateContainer().CreateCylinder(Vector3.zero, new Vector3(0f, 0f, -2f), 0.5f); Quaternion rotation = cylinder.WorldRotation; - cylinder.WorldEnd = Vector3.zero; + cylinder.WorldEndB = Vector3.zero; AssertApproximately(0f, cylinder.Length); AssertApproximately(rotation, cylinder.WorldRotation); @@ -108,7 +149,7 @@ public void CreateCylinderOnBone_FollowsTheBone() Assert.That(cylinder.Bone, Is.SameAs(bone)); AssertApproximately(localEndA, cylinder.LocalPosition); - AssertApproximately(localEndB, cylinder.LocalEnd); + AssertApproximately(localEndB, cylinder.LocalEndB); bone.rotation = Quaternion.Euler(90f, 0f, 0f); Vector3[] vertices = BakeShape(container, cylinder); AssertApproximately(bone.TransformPoint(localEndB), (vertices[4] + vertices[6]) * 0.5f); @@ -120,7 +161,7 @@ public void PoseForm_RunsAlongTheRotationsZ() Quaternion rotation = Quaternion.Euler(0f, -90f, 0f); ICylinder cylinder = CreateContainer().CreateCylinder(new Vector3(2f, 0f, 0f), rotation, 4f, 1f); - AssertApproximately(new Vector3(-2f, 0f, 0f), cylinder.WorldEnd); + AssertApproximately(new Vector3(-2f, 0f, 0f), cylinder.WorldEndB); AssertApproximately(rotation, cylinder.WorldRotation); } @@ -134,7 +175,7 @@ public void ChangingBone_ConvertsLengthAndRadius() AssertApproximately(2f, cylinder.Length); AssertApproximately(0.5f, cylinder.Radius); - AssertApproximately(new Vector3(0f, 0f, 4f), cylinder.WorldEnd); + AssertApproximately(new Vector3(0f, 0f, 4f), cylinder.WorldEndB); } [Test] @@ -145,7 +186,7 @@ public void SegmentsThatAreNotAMultipleOfFour_Throw() Assert.Throws(() => container.CreateCylinder(Vector3.zero, Vector3.up, 1f, 30)); Assert.Throws(() => container.CreateCylinder(Vector3.zero, Vector3.up, 1f, 0)); Assert.Throws(() => container.CreateCylinder(Vector3.zero, Vector3.up, 1f, -4)); - Assert.That(container.CreateCylinder(Vector3.zero, Vector3.up, 1f, 4).Segments, Is.EqualTo(4)); + Assert.That(container.CreateCylinder(Vector3.zero, Vector3.up, 1f, 4).SegmentCount, Is.EqualTo(4)); Assert.That(ChunkOf(container).ShapeCount, Is.EqualTo(1)); } } diff --git a/UnityPackage/Tests/Runtime/DegenerateShapeTests.cs b/UnityPackage/Tests/Runtime/DegenerateShapeTests.cs index c082522..b897029 100644 --- a/UnityPackage/Tests/Runtime/DegenerateShapeTests.cs +++ b/UnityPackage/Tests/Runtime/DegenerateShapeTests.cs @@ -58,7 +58,7 @@ public void SettingEndOnTopOfEndA_KeepsEveryLongShapeValid() foreach (IAxialShape shape in shapes) { - shape.WorldEnd = shape.WorldPosition; + shape.WorldEndB = shape.WorldPosition; AssertApproximately(0f, shape.Length); Assert.That(shape.WorldRotation, Is.EqualTo(Quaternion.identity)); diff --git a/UnityPackage/Tests/Runtime/EllipseTests.cs b/UnityPackage/Tests/Runtime/EllipseTests.cs index 1c8e4fb..ac407aa 100644 --- a/UnityPackage/Tests/Runtime/EllipseTests.cs +++ b/UnityPackage/Tests/Runtime/EllipseTests.cs @@ -77,7 +77,7 @@ public void CreateEllipse_WithoutArguments_IsOneUnitLongAlongZ() IEllipse ellipse = CreateContainer().CreateEllipse(); Assert.That(ellipse.Radii, Is.EqualTo(new Vector2(0.25f, 0.5f))); - Assert.That(ellipse.Segments, Is.EqualTo(32)); + Assert.That(ellipse.SegmentCount, Is.EqualTo(32)); } [Test] diff --git a/UnityPackage/Tests/Runtime/EllipsoidTests.cs b/UnityPackage/Tests/Runtime/EllipsoidTests.cs index 570e29c..a7e3490 100644 --- a/UnityPackage/Tests/Runtime/EllipsoidTests.cs +++ b/UnityPackage/Tests/Runtime/EllipsoidTests.cs @@ -58,7 +58,7 @@ public void CreateEllipsoid_WithoutArguments_IsOneUnitLongAlongZ() IEllipsoid ellipsoid = CreateContainer().CreateEllipsoid(); Assert.That(ellipsoid.Radii, Is.EqualTo(new Vector3(0.25f, 0.25f, 0.5f))); - Assert.That(ellipsoid.Segments, Is.EqualTo(32)); + Assert.That(ellipsoid.SegmentCount, Is.EqualTo(32)); } [Test] diff --git a/UnityPackage/Tests/Runtime/FrustumTests.cs b/UnityPackage/Tests/Runtime/FrustumTests.cs index ea2b839..083bfbc 100644 --- a/UnityPackage/Tests/Runtime/FrustumTests.cs +++ b/UnityPackage/Tests/Runtime/FrustumTests.cs @@ -15,7 +15,7 @@ public void Frustum_IsTwoPolygonsJoinedAtEveryCorner() Vector3[] vertices = BakeShape(container, frustum); (Vector3 A, Vector3 B)[] edges = BakeEdges(container, frustum); - Assert.That(frustum.Sides, Is.EqualTo(4)); + Assert.That(frustum.SideCount, Is.EqualTo(4)); Assert.That(vertices, Has.Length.EqualTo(8)); Assert.That(edges, Has.Length.EqualTo(12)); // A flat side faces down, so four sides make a square aligned with the axes. @@ -64,7 +64,7 @@ public void CreateFrustum_WithoutArguments_IsASquareFrustumAlongZ() { IFrustum frustum = CreateContainer().CreateFrustum(); - Assert.That(frustum.Sides, Is.EqualTo(4)); + Assert.That(frustum.SideCount, Is.EqualTo(4)); Assert.That(frustum.RadiusA, Is.EqualTo(0.25f)); Assert.That(frustum.RadiusB, Is.EqualTo(0.5f)); Assert.That(frustum.Length, Is.EqualTo(1f)); @@ -78,7 +78,7 @@ public void CreateFrustumOnBone_FollowsTheBone() IFrustum frustum = CreateContainer().CreateFrustum(bone, Vector3.zero, new Vector3(0f, 0f, 1f), 0.5f, 1f, 6); Assert.That(frustum.Bone, Is.SameAs(bone)); - AssertApproximately(bone.TransformPoint(new Vector3(0f, 0f, 1f)), frustum.WorldEnd); + AssertApproximately(bone.TransformPoint(new Vector3(0f, 0f, 1f)), frustum.WorldEndB); } [Test] diff --git a/UnityPackage/Tests/Runtime/PyramidTests.cs b/UnityPackage/Tests/Runtime/PyramidTests.cs index 36d5b38..b044166 100644 --- a/UnityPackage/Tests/Runtime/PyramidTests.cs +++ b/UnityPackage/Tests/Runtime/PyramidTests.cs @@ -47,7 +47,7 @@ public void CreatePyramid_WithoutArguments_HasAUnitBaseOneUnitAlongZ() IPyramid pyramid = CreateContainer().CreatePyramid(); Assert.That(pyramid.BaseSize, Is.EqualTo(Vector2.one)); - Assert.That(pyramid.WorldEnd, Is.EqualTo(new Vector3(0f, 0f, 1f))); + Assert.That(pyramid.WorldEndB, Is.EqualTo(new Vector3(0f, 0f, 1f))); } [Test] @@ -60,7 +60,7 @@ public void CreatePyramidOnBone_PutsTheTipOnTheBone() Assert.That(view.Bone, Is.SameAs(eye)); AssertApproximately(eye.position, BakeShape(container, view)[0]); - AssertApproximately(eye.TransformPoint(new Vector3(0f, 0f, 5f)), view.WorldEnd); + AssertApproximately(eye.TransformPoint(new Vector3(0f, 0f, 5f)), view.WorldEndB); } [Test] diff --git a/UnityPackage/Tests/Runtime/RigidShapeTests.cs b/UnityPackage/Tests/Runtime/RigidShapeTests.cs index 947a93a..3ddfc82 100644 --- a/UnityPackage/Tests/Runtime/RigidShapeTests.cs +++ b/UnityPackage/Tests/Runtime/RigidShapeTests.cs @@ -145,7 +145,7 @@ public void DisposedShape_ThrowsFromEveryMember() Assert.Throws(() => circle.WorldRotation = Quaternion.identity); Assert.Throws(() => circle.Bone = null); Assert.Throws(() => circle.Color = Color.red); - Assert.Throws(() => _ = circle.Segments); + Assert.Throws(() => _ = circle.SegmentCount); Assert.Throws(() => circle.Radius = 1f); Assert.DoesNotThrow(() => circle.Dispose()); } diff --git a/UnityPackage/Tests/Runtime/RoundedRectangleTests.cs b/UnityPackage/Tests/Runtime/RoundedRectangleTests.cs index 1736465..ecdb4d4 100644 --- a/UnityPackage/Tests/Runtime/RoundedRectangleTests.cs +++ b/UnityPackage/Tests/Runtime/RoundedRectangleTests.cs @@ -83,7 +83,7 @@ public void CreateRoundedRectangle_WithoutArguments_IsAUnitSquareWithQuarterRadi Assert.That(rectangle.Size, Is.EqualTo(Vector2.one)); Assert.That(rectangle.CornerRadius, Is.EqualTo(0.25f)); - Assert.That(rectangle.Segments, Is.EqualTo(32)); + Assert.That(rectangle.SegmentCount, Is.EqualTo(32)); } [Test] diff --git a/UnityPackage/Tests/Runtime/SphereTests.cs b/UnityPackage/Tests/Runtime/SphereTests.cs index a898ae5..e4bb9b1 100644 --- a/UnityPackage/Tests/Runtime/SphereTests.cs +++ b/UnityPackage/Tests/Runtime/SphereTests.cs @@ -39,7 +39,7 @@ public void CreateSphere_WithoutArguments_IsHalfUnitRadiusAtOrigin() ISphere sphere = CreateContainer().CreateSphere(); Assert.That(sphere.Radius, Is.EqualTo(0.5f)); - Assert.That(sphere.Segments, Is.EqualTo(32)); + Assert.That(sphere.SegmentCount, Is.EqualTo(32)); Assert.That(((Sphere)sphere).VertexCount, Is.EqualTo(96)); } diff --git a/UnityPackage/Tests/Runtime/StadiumTests.cs b/UnityPackage/Tests/Runtime/StadiumTests.cs index 584ca22..9e21662 100644 --- a/UnityPackage/Tests/Runtime/StadiumTests.cs +++ b/UnityPackage/Tests/Runtime/StadiumTests.cs @@ -105,7 +105,7 @@ public void CreateStadium_WithoutArguments_HasHalfUnitCirclesOneUnitApart() Assert.That(stadium.Length, Is.EqualTo(1f)); Assert.That(stadium.RadiusA, Is.EqualTo(0.5f)); Assert.That(stadium.RadiusB, Is.EqualTo(0.5f)); - Assert.That(stadium.Segments, Is.EqualTo(32)); + Assert.That(stadium.SegmentCount, Is.EqualTo(32)); } [Test] From 2bc8c9dc1e1818e068aba9073c28922be3962c42 Mon Sep 17 00:00:00 2001 From: Roman Likhadievski Date: Tue, 29 Sep 2026 10:10:50 +0300 Subject: [PATCH 03/13] feature: shader skinning --- UnityPackage/Runtime/Core/AxialShape.cs | 20 +- UnityPackage/Runtime/Core/BoneRegistry.cs | 161 -------------- UnityPackage/Runtime/Core/BoneWatcher.cs | 79 ------- UnityPackage/Runtime/Core/BoneWatcher.cs.meta | 2 - UnityPackage/Runtime/Core/PointShape.cs | 67 +++--- UnityPackage/Runtime/Core/RectangleShape.cs | 20 +- UnityPackage/Runtime/Core/RigidShape.cs | 66 +++--- UnityPackage/Runtime/Core/Shape.cs | 32 ++- UnityPackage/Runtime/Rendering.meta | 8 + .../Runtime/Rendering/BoneRegistry.cs | 168 ++++++++++++++ .../{Core => Rendering}/BoneRegistry.cs.meta | 0 UnityPackage/Runtime/Rendering/BoneTexture.cs | 83 +++++++ .../Runtime/Rendering/BoneTexture.cs.meta | 2 + .../Runtime/{Core => Rendering}/MeshChunk.cs | 126 ++++------- .../{Core => Rendering}/MeshChunk.cs.meta | 0 .../Runtime/{Core => Rendering}/MeshProxy.cs | 79 ++++++- .../{Core => Rendering}/MeshProxy.cs.meta | 0 .../reromanlee.Wireframes/Unlit.shader | 208 ++++++++++++++++++ ...exColors.shader.meta => Unlit.shader.meta} | 2 +- .../reromanlee.Wireframes/VertexColors.shader | 56 ----- UnityPackage/Runtime/Shaders.meta | 8 + UnityPackage/Runtime/Shaders/Wireframes.hlsl | 36 +++ .../Runtime/Shaders/Wireframes.hlsl.meta | 7 + UnityPackage/Runtime/Shapes/Box/Box.cs | 20 +- .../Runtime/Shapes/Capsule/Capsule.cs | 10 +- UnityPackage/Runtime/Shapes/Circle/Circle.cs | 6 +- UnityPackage/Runtime/Shapes/Cone/Cone.cs | 6 +- .../Runtime/Shapes/Cylinder/Cylinder.cs | 6 +- .../Runtime/Shapes/Ellipse/Ellipse.cs | 6 +- .../Runtime/Shapes/Ellipsoid/Ellipsoid.cs | 6 +- .../Runtime/Shapes/Frustum/Frustum.cs | 10 +- .../Runtime/Shapes/Polyline/Polyline.cs | 4 +- .../Runtime/Shapes/Pyramid/Pyramid.cs | 4 +- .../RoundedRectangle/RoundedRectangle.cs | 6 +- UnityPackage/Runtime/Shapes/Sphere/Sphere.cs | 6 +- .../Shapes/SpikedSphere/SpikedSphere.cs | 10 +- .../Runtime/Shapes/Stadium/Stadium.cs | 10 +- UnityPackage/Runtime/Shapes/Star/Star.cs | 10 +- UnityPackage/Tests/Runtime/LineTests.cs | 48 ++-- UnityPackage/Tests/Runtime/MeshBufferTests.cs | 16 +- UnityPackage/Tests/Runtime/PolylineTests.cs | 5 +- UnityPackage/Tests/Runtime/ShaderTests.cs | 133 +++++++++++ .../Tests/Runtime/ShaderTests.cs.meta | 2 + .../Tests/Runtime/WireframeContainerTests.cs | 10 +- .../Tests/Runtime/WireframesTestBase.cs | 18 +- 45 files changed, 1019 insertions(+), 563 deletions(-) delete mode 100644 UnityPackage/Runtime/Core/BoneRegistry.cs delete mode 100644 UnityPackage/Runtime/Core/BoneWatcher.cs delete mode 100644 UnityPackage/Runtime/Core/BoneWatcher.cs.meta create mode 100644 UnityPackage/Runtime/Rendering.meta create mode 100644 UnityPackage/Runtime/Rendering/BoneRegistry.cs rename UnityPackage/Runtime/{Core => Rendering}/BoneRegistry.cs.meta (100%) create mode 100644 UnityPackage/Runtime/Rendering/BoneTexture.cs create mode 100644 UnityPackage/Runtime/Rendering/BoneTexture.cs.meta rename UnityPackage/Runtime/{Core => Rendering}/MeshChunk.cs (78%) rename UnityPackage/Runtime/{Core => Rendering}/MeshChunk.cs.meta (100%) rename UnityPackage/Runtime/{Core => Rendering}/MeshProxy.cs (52%) rename UnityPackage/Runtime/{Core => Rendering}/MeshProxy.cs.meta (100%) create mode 100644 UnityPackage/Runtime/Resources/reromanlee.Wireframes/Unlit.shader rename UnityPackage/Runtime/Resources/reromanlee.Wireframes/{VertexColors.shader.meta => Unlit.shader.meta} (80%) delete mode 100644 UnityPackage/Runtime/Resources/reromanlee.Wireframes/VertexColors.shader create mode 100644 UnityPackage/Runtime/Shaders.meta create mode 100644 UnityPackage/Runtime/Shaders/Wireframes.hlsl create mode 100644 UnityPackage/Runtime/Shaders/Wireframes.hlsl.meta create mode 100644 UnityPackage/Tests/Runtime/ShaderTests.cs create mode 100644 UnityPackage/Tests/Runtime/ShaderTests.cs.meta diff --git a/UnityPackage/Runtime/Core/AxialShape.cs b/UnityPackage/Runtime/Core/AxialShape.cs index ef0e82d..842cdee 100644 --- a/UnityPackage/Runtime/Core/AxialShape.cs +++ b/UnityPackage/Runtime/Core/AxialShape.cs @@ -27,12 +27,12 @@ public float Length { get { - ThrowIfDisposed(); + EnsureUsable(); return _length; } set { - ThrowIfDisposed(); + EnsureUsable(); _length = value; MarkDirty(DirtyFlags.Positions); } @@ -42,12 +42,12 @@ public Vector3 LocalEndA { get { - ThrowIfDisposed(); + EnsureUsable(); return LocalPosition; } set { - ThrowIfDisposed(); + EnsureUsable(); Vector3 endB = LocalEndB; LocalPosition = value; LocalEndB = endB; @@ -58,12 +58,12 @@ public Vector3 LocalEndB { get { - ThrowIfDisposed(); + EnsureUsable(); return LocalPosition + LocalRotation * new Vector3(0f, 0f, _length); } set { - ThrowIfDisposed(); + EnsureUsable(); Vector3 axis = value - LocalPosition; float length = axis.magnitude; // An end on top of end A has no direction, so the rotation stays as it is. @@ -82,12 +82,12 @@ public Vector3 WorldEndA { get { - ThrowIfDisposed(); + EnsureUsable(); return ToWorld(Bone, LocalEndA); } set { - ThrowIfDisposed(); + EnsureUsable(); LocalEndA = ToLocal(Bone, value); } } @@ -96,12 +96,12 @@ public Vector3 WorldEndB { get { - ThrowIfDisposed(); + EnsureUsable(); return ToWorld(Bone, LocalEndB); } set { - ThrowIfDisposed(); + EnsureUsable(); LocalEndB = ToLocal(Bone, value); } } diff --git a/UnityPackage/Runtime/Core/BoneRegistry.cs b/UnityPackage/Runtime/Core/BoneRegistry.cs deleted file mode 100644 index 1ca20a9..0000000 --- a/UnityPackage/Runtime/Core/BoneRegistry.cs +++ /dev/null @@ -1,161 +0,0 @@ -using System; -using System.Collections.Generic; -using UnityEngine; - -namespace reromanlee.Wireframes -{ - /// - /// Maps the transforms that shapes follow to bone slots of one skinned mesh, counting the endpoints using each. - /// Slot 0 is the chunk's own transform and stands for world space. Freed slots are reused, so no other vertex - /// ever needs its bone index rewritten. - /// - internal sealed class BoneRegistry : IDisposable - { - private const int InitialCapacity = 16; - - private readonly Transform _root; - private readonly Action _onBoneDestroyed; - private readonly Dictionary _slots = new(); - private readonly Stack _freeSlots = new(); - // Watchers whose Awake hasn't run: Unity skips OnDestroy for them, so only these are checked every flush. - private readonly List _sleepers = new(); - - private Transform[] _transforms = new Transform[InitialCapacity]; - private BoneWatcher[] _watchers = new BoneWatcher[InitialCapacity]; - private int[] _referenceCounts = new int[InitialCapacity]; - private int _end = 1; - - internal BoneRegistry(Transform root, Action onBoneDestroyed) - { - _root = root; - _onBoneDestroyed = onBoneDestroyed; - Array.Fill(_transforms, root); - IsDirty = true; - } - - /// Bones for the renderer, sized to the capacity. Unused slots hold the root. - internal Transform[] Transforms - { - get => _transforms; - } - - /// True when changed since the renderer last received it. - internal bool IsDirty { get; private set; } - - /// Number of distinct transforms in use, not counting the root. - internal int Count - { - get => _slots.Count; - } - - internal void ClearDirty() - { - IsDirty = false; - } - - /// Returns the slot for , registering it on first use. Null means world space. - internal int Acquire(Transform bone) - { - if (bone == null) - { - return 0; - } - if (_slots.TryGetValue(bone, out int slot)) - { - _referenceCounts[slot]++; - return slot; - } - slot = _freeSlots.Count > 0 ? _freeSlots.Pop() : _end++; - EnsureCapacity(slot + 1); - BoneWatcher watcher = BoneWatcher.Watch(bone, this); - _slots.Add(bone, slot); - _transforms[slot] = bone; - _watchers[slot] = watcher; - _referenceCounts[slot] = 1; - if (!watcher.IsAwake) - { - _sleepers.Add(watcher); - } - IsDirty = true; - return slot; - } - - internal void Release(int slot) - { - if (slot == 0 || --_referenceCounts[slot] > 0) - { - return; - } - BoneWatcher watcher = _watchers[slot]; - // Dictionary lookups compare instance IDs, so this also works for a transform that was just destroyed. - _slots.Remove(_transforms[slot]); - _transforms[slot] = _root; - _watchers[slot] = null; - _freeSlots.Push(slot); - _sleepers.Remove(watcher); - watcher.RemoveListener(this); - IsDirty = true; - } - - /// Called by a watcher whose bone is being destroyed. - internal void OnBoneDestroyed(Transform bone) - { - _onBoneDestroyed(bone); - } - - /// Catches bones that were destroyed before their GameObject was ever active. - internal void PollSleepers() - { - for (int i = _sleepers.Count - 1; i >= 0; i--) - { - BoneWatcher watcher = _sleepers[i]; - if (watcher.IsAwake) - { - _sleepers.RemoveAt(i); - } - else if (watcher == null) - { - _sleepers.RemoveAt(i); - Debug.LogWarning( - "Wireframes: a bone was destroyed before its GameObject was ever active, so its last pose is " + - "unknown. The endpoints attached to it now use their local offsets as world positions."); - _onBoneDestroyed(watcher.Bone); - } - } - } - - public void Dispose() - { - for (int slot = 1; slot < _end; slot++) - { - BoneWatcher watcher = _watchers[slot]; - if (!ReferenceEquals(watcher, null)) - { - watcher.RemoveListener(this); - _watchers[slot] = null; - } - } - _slots.Clear(); - _sleepers.Clear(); - _freeSlots.Clear(); - } - - private void EnsureCapacity(int capacity) - { - int oldCapacity = _transforms.Length; - if (capacity <= oldCapacity) - { - return; - } - int newCapacity = oldCapacity; - while (newCapacity < capacity) - { - newCapacity *= 2; - } - Array.Resize(ref _transforms, newCapacity); - Array.Fill(_transforms, _root, oldCapacity, newCapacity - oldCapacity); - Array.Resize(ref _watchers, newCapacity); - Array.Resize(ref _referenceCounts, newCapacity); - } - } -} diff --git a/UnityPackage/Runtime/Core/BoneWatcher.cs b/UnityPackage/Runtime/Core/BoneWatcher.cs deleted file mode 100644 index d409e40..0000000 --- a/UnityPackage/Runtime/Core/BoneWatcher.cs +++ /dev/null @@ -1,79 +0,0 @@ -using System.Collections.Generic; -using UnityEngine; - -namespace reromanlee.Wireframes -{ - /// - /// Hidden component added to GameObjects used as bones. Its OnDestroy runs while the transform can still be read, - /// so attached endpoints can detach without moving. It removes itself once no registry uses the bone. - /// - [AddComponentMenu("")] - internal sealed class BoneWatcher : MonoBehaviour - { - private static readonly List Candidates = new(); - - private readonly List _registries = new(); - private bool _destroyRequested; - - internal Transform Bone { get; private set; } - - internal bool IsAwake { get; private set; } - - internal static BoneWatcher Watch(Transform bone, BoneRegistry registry) - { - BoneWatcher watcher = null; - bone.GetComponents(Candidates); - foreach (BoneWatcher candidate in Candidates) - { - // A watcher already scheduled for destruction can't be revived, so a fresh one is added instead. - if (!candidate._destroyRequested) - { - watcher = candidate; - break; - } - } - Candidates.Clear(); - - if (watcher == null) - { - watcher = bone.gameObject.AddComponent(); - watcher.hideFlags = HideFlags.HideInInspector; - watcher.Bone = bone; - } - watcher._registries.Add(registry); - return watcher; - } - - internal void RemoveListener(BoneRegistry registry) - { - _registries.Remove(registry); - // The null check covers a watcher that is already gone together with its bone. - if (_registries.Count == 0 && !_destroyRequested && this != null) - { - _destroyRequested = true; - UnityObjects.Destroy(this); - } - } - - private void Awake() - { - IsAwake = true; - } - - private void OnDestroy() - { - if (_destroyRequested || _registries.Count == 0) - { - return; - } - _destroyRequested = true; - // Registries release the bone while being notified, which edits the list, so they are notified from a copy. - BoneRegistry[] registries = _registries.ToArray(); - _registries.Clear(); - foreach (BoneRegistry registry in registries) - { - registry.OnBoneDestroyed(Bone); - } - } - } -} diff --git a/UnityPackage/Runtime/Core/BoneWatcher.cs.meta b/UnityPackage/Runtime/Core/BoneWatcher.cs.meta deleted file mode 100644 index 95200e3..0000000 --- a/UnityPackage/Runtime/Core/BoneWatcher.cs.meta +++ /dev/null @@ -1,2 +0,0 @@ -fileFormatVersion: 2 -guid: d1777ab170b84e4b9790709b14a44771 diff --git a/UnityPackage/Runtime/Core/PointShape.cs b/UnityPackage/Runtime/Core/PointShape.cs index 27ca802..d29141c 100644 --- a/UnityPackage/Runtime/Core/PointShape.cs +++ b/UnityPackage/Runtime/Core/PointShape.cs @@ -15,55 +15,55 @@ protected PointShape(MeshProxy proxy, int pointCount, int[] edgePattern) : base( public Vector3 GetLocalPosition(int index) { - ThrowIfDisposed(); - return Point(index).LocalPosition; + EnsureUsable(); + return LivePoint(index).LocalPosition; } public void SetLocalPosition(int index, Vector3 position) { - ThrowIfDisposed(); - Point(index).LocalPosition = position; + EnsureUsable(); + LivePoint(index).LocalPosition = position; MarkDirty(DirtyFlags.Positions); } public Vector3 GetWorldPosition(int index) { - ThrowIfDisposed(); - ref ShapePoint point = ref Point(index); + EnsureUsable(); + ref ShapePoint point = ref LivePoint(index); return ToWorld(point.Bone, point.LocalPosition); } public void SetWorldPosition(int index, Vector3 position) { - ThrowIfDisposed(); - ref ShapePoint point = ref Point(index); + EnsureUsable(); + ref ShapePoint point = ref LivePoint(index); point.LocalPosition = ToLocal(point.Bone, position); MarkDirty(DirtyFlags.Positions); } public Color GetColor(int index) { - ThrowIfDisposed(); + EnsureUsable(); return Point(index).Color; } public void SetColor(int index, Color color) { - ThrowIfDisposed(); + EnsureUsable(); Point(index).Color = color; MarkDirty(DirtyFlags.Colors); } public Transform GetBone(int index) { - ThrowIfDisposed(); - return Point(index).Bone; + EnsureUsable(); + return LivePoint(index).Bone; } public void SetBone(int index, Transform bone) { - ThrowIfDisposed(); - ref ShapePoint point = ref Point(index); + EnsureUsable(); + ref ShapePoint point = ref LivePoint(index); Vector3 worldPosition = ToWorld(point.Bone, point.LocalPosition); ReplaceBone(ref point.Bone, ref point.BoneSlot, bone); point.LocalPosition = ToLocal(point.Bone, worldPosition); @@ -72,7 +72,7 @@ public void SetBone(int index, Transform bone) public override void SetColor(Color color) { - ThrowIfDisposed(); + EnsureUsable(); for (int i = 0; i < VertexCount; i++) { Point(i).Color = color; @@ -96,23 +96,11 @@ internal override void WriteColors(Span colors) } } - internal override void WriteBones(Span bones) + internal override void WriteBoneIndices(Span boneIndices) { - for (int i = 0; i < bones.Length; i++) + for (int i = 0; i < boneIndices.Length; i++) { - bones[i] = (uint)Point(i).BoneSlot; - } - } - - internal override void OnBoneDestroyed(Transform bone) - { - for (int i = 0; i < VertexCount; i++) - { - // Reference comparison: a destroyed bone also compares equal to every unattached (null) point. - if (ReferenceEquals(Point(i).Bone, bone)) - { - SetBone(i, null); - } + boneIndices[i] = Point(i).BoneSlot; } } @@ -133,5 +121,24 @@ protected void AttachPoint(int index, Transform bone) /// Storage of point . protected abstract ref ShapePoint Point(int index); + + /// + /// Storage of point , switched to world space first if its bone was destroyed. Only the + /// point asked for is checked, so members of long polylines don't scan every point. + /// + private ref ShapePoint LivePoint(int index) + { + ref ShapePoint point = ref Point(index); + if (IsDestroyed(point.Bone)) + { + // The slot still holds the bone's last pose, so the point keeps its world position. + point.LocalPosition = Bones.MatrixOf(point.BoneSlot).MultiplyPoint3x4(point.LocalPosition); + Bones.Release(point.BoneSlot); + point.Bone = null; + point.BoneSlot = BoneRegistry.WorldSlot; + MarkDirty(DirtyFlags.Positions | DirtyFlags.Bones); + } + return ref point; + } } } diff --git a/UnityPackage/Runtime/Core/RectangleShape.cs b/UnityPackage/Runtime/Core/RectangleShape.cs index 9a1776b..18b0134 100644 --- a/UnityPackage/Runtime/Core/RectangleShape.cs +++ b/UnityPackage/Runtime/Core/RectangleShape.cs @@ -26,12 +26,12 @@ public Vector2 Size { get { - ThrowIfDisposed(); + EnsureUsable(); return _size; } set { - ThrowIfDisposed(); + EnsureUsable(); _size = value; MarkDirty(DirtyFlags.Positions); } @@ -41,12 +41,12 @@ public Vector3 LocalCornerA { get { - ThrowIfDisposed(); + EnsureUsable(); return LocalPosition - LocalRotation * HalfSize(); } set { - ThrowIfDisposed(); + EnsureUsable(); Vector3 cornerB = LocalCornerB; SpanCorners(OntoPlane(value, cornerB), cornerB); } @@ -56,12 +56,12 @@ public Vector3 LocalCornerB { get { - ThrowIfDisposed(); + EnsureUsable(); return LocalPosition + LocalRotation * HalfSize(); } set { - ThrowIfDisposed(); + EnsureUsable(); Vector3 cornerA = LocalCornerA; SpanCorners(cornerA, OntoPlane(value, cornerA)); } @@ -71,12 +71,12 @@ public Vector3 WorldCornerA { get { - ThrowIfDisposed(); + EnsureUsable(); return ToWorld(Bone, LocalCornerA); } set { - ThrowIfDisposed(); + EnsureUsable(); LocalCornerA = ToLocal(Bone, value); } } @@ -85,12 +85,12 @@ public Vector3 WorldCornerB { get { - ThrowIfDisposed(); + EnsureUsable(); return ToWorld(Bone, LocalCornerB); } set { - ThrowIfDisposed(); + EnsureUsable(); LocalCornerB = ToLocal(Bone, value); } } diff --git a/UnityPackage/Runtime/Core/RigidShape.cs b/UnityPackage/Runtime/Core/RigidShape.cs index 20c5421..6d81106 100644 --- a/UnityPackage/Runtime/Core/RigidShape.cs +++ b/UnityPackage/Runtime/Core/RigidShape.cs @@ -32,12 +32,12 @@ public Vector3 LocalPosition { get { - ThrowIfDisposed(); + EnsureUsable(); return _localPosition; } set { - ThrowIfDisposed(); + EnsureUsable(); _localPosition = value; MarkDirty(DirtyFlags.Positions); } @@ -47,12 +47,12 @@ public Vector3 WorldPosition { get { - ThrowIfDisposed(); + EnsureUsable(); return ToWorld(_bone, _localPosition); } set { - ThrowIfDisposed(); + EnsureUsable(); _localPosition = ToLocal(_bone, value); MarkDirty(DirtyFlags.Positions); } @@ -62,12 +62,12 @@ public Quaternion LocalRotation { get { - ThrowIfDisposed(); + EnsureUsable(); return _localRotation; } set { - ThrowIfDisposed(); + EnsureUsable(); // Normalizing also turns a zero quaternion, such as default, into no rotation. _localRotation = Quaternion.Normalize(value); MarkDirty(DirtyFlags.Positions); @@ -78,12 +78,12 @@ public Quaternion WorldRotation { get { - ThrowIfDisposed(); + EnsureUsable(); return ToWorld(_bone, _localRotation); } set { - ThrowIfDisposed(); + EnsureUsable(); _localRotation = ToLocal(_bone, Quaternion.Normalize(value)); MarkDirty(DirtyFlags.Positions); } @@ -93,12 +93,12 @@ public Color Color { get { - ThrowIfDisposed(); + EnsureUsable(); return _color; } set { - ThrowIfDisposed(); + EnsureUsable(); _color = value; MarkDirty(DirtyFlags.Colors); } @@ -108,12 +108,12 @@ public Transform Bone { get { - ThrowIfDisposed(); + EnsureUsable(); return _bone; } set { - ThrowIfDisposed(); + EnsureUsable(); Vector3 worldPosition = ToWorld(_bone, _localPosition); Quaternion worldRotation = ToWorld(_bone, _localRotation); float oldScale = UniformScale(_bone); @@ -149,23 +149,35 @@ internal sealed override void WriteColors(Span colors) colors.Fill(_color); } - internal sealed override void WriteBones(Span bones) + internal sealed override void WriteBoneIndices(Span boneIndices) { - bones.Fill((uint)_boneSlot); + boneIndices.Fill(_boneSlot); } - internal sealed override void OnBoneDestroyed(Transform bone) + protected sealed override void ReleaseBones(BoneRegistry bones) { - // Reference comparison: a destroyed bone also compares equal to null, which means "no bone". - if (ReferenceEquals(_bone, bone)) - { - Bone = null; - } + bones.Release(_boneSlot); } - protected sealed override void ReleaseBones(BoneRegistry bones) + protected sealed override void DetachFromDestroyedBones() { - bones.Release(_boneSlot); + if (!IsDestroyed(_bone)) + { + return; + } + // The slot still holds the bone's last pose, so the shape keeps its world position, rotation and size. + Matrix4x4 pose = Bones.MatrixOf(_boneSlot); + _localPosition = pose.MultiplyPoint3x4(_localPosition); + _localRotation = Quaternion.Normalize(pose.rotation * _localRotation); + float scale = UniformScale(pose.lossyScale); + if (scale > 0f && scale != 1f) + { + ScaleSizes(scale); + } + Bones.Release(_boneSlot); + _bone = null; + _boneSlot = BoneRegistry.WorldSlot; + MarkDirty(DirtyFlags.Positions | DirtyFlags.Bones); } /// Writes the shape's points along its own axes, before its rotation and position are applied. @@ -187,11 +199,11 @@ private static Quaternion ToLocal(Transform bone, Quaternion world) /// A bone's overall scale: the geometric mean of its lossy scale, so a uniform scale comes back as is. private static float UniformScale(Transform bone) { - if (bone == null) - { - return 1f; - } - Vector3 scale = bone.lossyScale; + return bone != null ? UniformScale(bone.lossyScale) : 1f; + } + + private static float UniformScale(Vector3 scale) + { return (float)Math.Pow(Math.Abs((double)scale.x * scale.y * scale.z), 1.0 / 3.0); } } diff --git a/UnityPackage/Runtime/Core/Shape.cs b/UnityPackage/Runtime/Core/Shape.cs index 641a6e7..a467a93 100644 --- a/UnityPackage/Runtime/Core/Shape.cs +++ b/UnityPackage/Runtime/Core/Shape.cs @@ -41,6 +41,12 @@ internal int EdgeCount get => _edgeSlots.Length; } + /// The registry of the bones this shape follows. + protected BoneRegistry Bones + { + get => _chunk.Bones; + } + public void Dispose() { if (_chunk == null) @@ -107,19 +113,29 @@ internal void WriteEdges(EdgeList edges, DirtyRanges ranges) internal abstract void WriteColors(Span colors); - internal abstract void WriteBones(Span bones); - - /// Detaches whatever follows , keeping it in place if the bone can still be read. - internal abstract void OnBoneDestroyed(Transform bone); + internal abstract void WriteBoneIndices(Span boneIndices); protected abstract void ReleaseBones(BoneRegistry bones); - protected void ThrowIfDisposed() + /// + /// Throws once the shape is disposed, and otherwise lets it notice a bone that was destroyed, so every member + /// sees the shape in world space from then on. + /// + protected void EnsureUsable() { if (_chunk == null) { throw new ObjectDisposedException(GetType().Name); } + DetachFromDestroyedBones(); + } + + /// + /// Switches whatever follows a destroyed bone to world space, where its bone slot's last matrix put it. Until + /// then that frozen matrix keeps drawing it in place. + /// + protected virtual void DetachFromDestroyedBones() + { } /// Points a bone field at and moves its registry slot along. @@ -137,6 +153,12 @@ protected void ReplaceBone(ref Transform bone, ref int slot, Transform value) slot = newSlot; } + /// True when was a transform that has since been destroyed. + protected static bool IsDestroyed(Transform bone) + { + return !ReferenceEquals(bone, null) && bone == null; + } + protected static Vector3 ToWorld(Transform bone, Vector3 local) { return bone != null ? bone.TransformPoint(local) : local; diff --git a/UnityPackage/Runtime/Rendering.meta b/UnityPackage/Runtime/Rendering.meta new file mode 100644 index 0000000..d3a1aea --- /dev/null +++ b/UnityPackage/Runtime/Rendering.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 44e3578807ea7cdbec282fab1f50e70f +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityPackage/Runtime/Rendering/BoneRegistry.cs b/UnityPackage/Runtime/Rendering/BoneRegistry.cs new file mode 100644 index 0000000..bb65794 --- /dev/null +++ b/UnityPackage/Runtime/Rendering/BoneRegistry.cs @@ -0,0 +1,168 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace reromanlee.Wireframes +{ + /// + /// Maps the transforms that shapes follow to bone slots, counting the uses of each, and keeps every slot's latest + /// local-to-world matrix for the shader. Slot 0 is the identity and stands for world space. When a transform is + /// destroyed, its slot stops being read and keeps its last matrix, which holds the shapes on it in place until each + /// of them switches to world space and releases the slot. No component is ever added to a bone. + /// + internal sealed class BoneRegistry + { + internal const int WorldSlot = 0; + private const int InitialCapacity = 16; + + private readonly Dictionary _slots = new(); + private readonly Stack _freeSlots = new(); + + private Transform[] _transforms = new Transform[InitialCapacity]; + private Matrix4x4[] _matrices = new Matrix4x4[InitialCapacity]; + private int[] _referenceCounts = new int[InitialCapacity]; + + // Slots whose transform is still alive, read every frame, and each slot's position among them (-1 when not). + private int[] _liveSlots = new int[InitialCapacity]; + private int[] _livePositions = new int[InitialCapacity]; + private int _liveCount; + private int _end = WorldSlot + 1; + + internal BoneRegistry() + { + _matrices[WorldSlot] = Matrix4x4.identity; + Array.Fill(_livePositions, -1); + HasChanges = true; + } + + /// One past the highest slot handed out so far. + internal int End + { + get => _end; + } + + /// Number of distinct transforms in use, not counting world space. + internal int Count + { + get => _slots.Count; + } + + /// Each slot's latest matrix, sized to the capacity. + internal Matrix4x4[] Matrices + { + get => _matrices; + } + + /// True when a matrix changed since . + internal bool HasChanges { get; private set; } + + internal Matrix4x4 MatrixOf(int slot) + { + return _matrices[slot]; + } + + internal void ClearChanges() + { + HasChanges = false; + } + + /// + /// Returns the slot for , registering it on first use. Null means world space; callers + /// pass a destroyed transform as null. + /// + internal int Acquire(Transform bone) + { + if (bone is null) + { + return WorldSlot; + } + if (_slots.TryGetValue(bone, out int slot)) + { + _referenceCounts[slot]++; + return slot; + } + slot = _freeSlots.Count > 0 ? _freeSlots.Pop() : _end++; + EnsureCapacity(slot + 1); + _slots.Add(bone, slot); + _transforms[slot] = bone; + _referenceCounts[slot] = 1; + // Read right away, so a bone destroyed before the next frame still leaves a pose to hold its shapes. + _matrices[slot] = bone.localToWorldMatrix; + _livePositions[slot] = _liveCount; + _liveSlots[_liveCount++] = slot; + HasChanges = true; + return slot; + } + + internal void Release(int slot) + { + if (slot == WorldSlot || --_referenceCounts[slot] > 0) + { + return; + } + // Dictionary lookups compare instance IDs, so this also works for a transform that was destroyed. + _slots.Remove(_transforms[slot]); + _transforms[slot] = null; + StopReading(slot); + _freeSlots.Push(slot); + } + + /// + /// Reads the matrix of every live bone and notes whether any changed. A destroyed bone's slot stops being read + /// and keeps its last matrix. + /// + internal void ReadMatrices() + { + // Backwards, so a slot that stops being read swaps in one that was already read. + for (int i = _liveCount - 1; i >= 0; i--) + { + int slot = _liveSlots[i]; + Transform bone = _transforms[slot]; + if (bone == null) + { + StopReading(slot); + continue; + } + Matrix4x4 matrix = bone.localToWorldMatrix; + if (!matrix.Equals(_matrices[slot])) + { + _matrices[slot] = matrix; + HasChanges = true; + } + } + } + + private void StopReading(int slot) + { + int position = _livePositions[slot]; + if (position < 0) + { + return; + } + int last = _liveSlots[--_liveCount]; + _liveSlots[position] = last; + _livePositions[last] = position; + _livePositions[slot] = -1; + } + + private void EnsureCapacity(int capacity) + { + int oldCapacity = _transforms.Length; + if (capacity <= oldCapacity) + { + return; + } + int newCapacity = oldCapacity; + while (newCapacity < capacity) + { + newCapacity *= 2; + } + Array.Resize(ref _transforms, newCapacity); + Array.Resize(ref _matrices, newCapacity); + Array.Resize(ref _referenceCounts, newCapacity); + Array.Resize(ref _liveSlots, newCapacity); + Array.Resize(ref _livePositions, newCapacity); + Array.Fill(_livePositions, -1, oldCapacity, newCapacity - oldCapacity); + } + } +} diff --git a/UnityPackage/Runtime/Core/BoneRegistry.cs.meta b/UnityPackage/Runtime/Rendering/BoneRegistry.cs.meta similarity index 100% rename from UnityPackage/Runtime/Core/BoneRegistry.cs.meta rename to UnityPackage/Runtime/Rendering/BoneRegistry.cs.meta diff --git a/UnityPackage/Runtime/Rendering/BoneTexture.cs b/UnityPackage/Runtime/Rendering/BoneTexture.cs new file mode 100644 index 0000000..afad45a --- /dev/null +++ b/UnityPackage/Runtime/Rendering/BoneTexture.cs @@ -0,0 +1,83 @@ +using System; +using Unity.Collections; +using UnityEngine; + +namespace reromanlee.Wireframes +{ + /// + /// The float texture that shaders read bone matrices from: three texels per bone, the rows of its local-to-world + /// matrix, and bones per texture row, as Wireframes.hlsl expects. A float texture works on + /// every platform, WebGL included, where structured buffers are not available to vertex shaders. + /// + internal sealed class BoneTexture : IDisposable + { + internal const int BonesPerRow = 256; + private const int TexelsPerBone = 3; + + internal static readonly int PropertyId = Shader.PropertyToID("_WireframesBones"); + + private Texture2D _texture; + + internal Texture2D Texture + { + get => _texture; + } + + /// + /// Writes every slot's matrix and uploads the texture when a matrix changed or the texture had to grow. Returns + /// true when a new texture was created, which the renderers then have to be given. + /// + internal bool Upload(BoneRegistry bones) + { + bool created = EnsureCapacity(bones.End); + if (!created && !bones.HasChanges) + { + return false; + } + NativeArray texels = _texture.GetPixelData(0); + Matrix4x4[] matrices = bones.Matrices; + int end = bones.End; + for (int slot = 0; slot < end; slot++) + { + Matrix4x4 matrix = matrices[slot]; + int texel = slot / BonesPerRow * BonesPerRow * TexelsPerBone + slot % BonesPerRow * TexelsPerBone; + texels[texel] = new Vector4(matrix.m00, matrix.m01, matrix.m02, matrix.m03); + texels[texel + 1] = new Vector4(matrix.m10, matrix.m11, matrix.m12, matrix.m13); + texels[texel + 2] = new Vector4(matrix.m20, matrix.m21, matrix.m22, matrix.m23); + } + _texture.Apply(false, false); + bones.ClearChanges(); + return created; + } + + public void Dispose() + { + UnityObjects.Destroy(_texture); + _texture = null; + } + + /// True when a new texture was created. + private bool EnsureCapacity(int slots) + { + int rows = Math.Max(1, (slots + BonesPerRow - 1) / BonesPerRow); + if (_texture != null && _texture.height >= rows) + { + return false; + } + int height = _texture != null ? _texture.height : 1; + while (height < rows) + { + height *= 2; + } + UnityObjects.Destroy(_texture); + _texture = new Texture2D(BonesPerRow * TexelsPerBone, height, TextureFormat.RGBAFloat, false, true) + { + name = "Wireframes Bones", + filterMode = FilterMode.Point, + wrapMode = TextureWrapMode.Clamp, + hideFlags = HideFlags.HideAndDontSave + }; + return true; + } + } +} diff --git a/UnityPackage/Runtime/Rendering/BoneTexture.cs.meta b/UnityPackage/Runtime/Rendering/BoneTexture.cs.meta new file mode 100644 index 0000000..7fc8805 --- /dev/null +++ b/UnityPackage/Runtime/Rendering/BoneTexture.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 156c1de0e84982811bd96a1da06dad41 \ No newline at end of file diff --git a/UnityPackage/Runtime/Core/MeshChunk.cs b/UnityPackage/Runtime/Rendering/MeshChunk.cs similarity index 78% rename from UnityPackage/Runtime/Core/MeshChunk.cs rename to UnityPackage/Runtime/Rendering/MeshChunk.cs index 708c77b..4810b2b 100644 --- a/UnityPackage/Runtime/Core/MeshChunk.cs +++ b/UnityPackage/Runtime/Rendering/MeshChunk.cs @@ -5,8 +5,9 @@ namespace reromanlee.Wireframes { /// - /// One skinned mesh holding many shapes. Shapes keep their own state; on each flush the chunk writes the queued - /// shapes into CPU copies of the vertex and index buffers and uploads only the ranges that changed. + /// One mesh holding many shapes. Shapes keep their own state; on each flush the chunk writes the queued shapes into + /// CPU copies of the vertex and index buffers and uploads only the ranges that changed. Each vertex is a position + /// relative to its bone plus that bone's slot, which the shader turns into a world position. /// internal sealed class MeshChunk : IDisposable { @@ -20,42 +21,37 @@ internal sealed class MeshChunk : IDisposable private const int PositionStream = 0; private const int ColorStream = 1; - private const int SkinStream = 2; + private const int BoneStream = 2; - // The layout Unity itself builds for one bone per vertex; skinned meshes must keep this stream arrangement. private static readonly VertexAttributeDescriptor[] VertexLayout = { new(VertexAttribute.Position, VertexAttributeFormat.Float32, 3, PositionStream), new(VertexAttribute.Color, VertexAttributeFormat.UNorm8, 4, ColorStream), - new(VertexAttribute.BlendIndices, VertexAttributeFormat.UInt32, 1, SkinStream) + new(VertexAttribute.TexCoord0, VertexAttributeFormat.Float32, 1, BoneStream) }; // Shapes follow arbitrary transforms, so the bounds are fixed and large instead of recomputed every frame. private static readonly Bounds FixedBounds = new(Vector3.zero, Vector3.one * 2000000f); - private const MeshUpdateFlags UploadFlags = - MeshUpdateFlags.DontValidateIndices | - MeshUpdateFlags.DontRecalculateBounds | - MeshUpdateFlags.DontResetBoneBounds; + private const MeshUpdateFlags UploadFlags = MeshUpdateFlags.DontValidateIndices | MeshUpdateFlags.DontRecalculateBounds; - private readonly SkinnedMeshRenderer _renderer; + private readonly MeshRenderer _renderer; private readonly Mesh _mesh; + private readonly BoneRegistry _bones; private readonly VertexAllocator _allocator = new(); private readonly EdgeList _edges = new(); - private readonly BoneRegistry _bones; private readonly DirtyRanges _positionRanges = new(); private readonly DirtyRanges _colorRanges = new(); - private readonly DirtyRanges _skinRanges = new(); + private readonly DirtyRanges _boneRanges = new(); private readonly DirtyRanges _edgeRanges = new(); // CPU copies of the three vertex streams, sized to the vertex capacity. private Vector3[] _positions = new Vector3[InitialVertexCapacity]; private Color32[] _colors = new Color32[InitialVertexCapacity]; - private uint[] _skin = new uint[InitialVertexCapacity]; + private float[] _boneIndices = new float[InitialVertexCapacity]; private ushort[] _shortIndices = Array.Empty(); - private Matrix4x4[] _bindposes = Array.Empty(); private Shape[] _shapes = new Shape[InitialShapeCapacity]; private int _shapeCount; @@ -68,29 +64,24 @@ internal sealed class MeshChunk : IDisposable private IndexFormat _meshIndexFormat; private int _meshEdgeCount = -1; - internal MeshChunk(Transform parent, Material material, WireframeContainerSettings settings) + internal MeshChunk(Transform parent, Material material, WireframeContainerSettings settings, BoneRegistry bones) { // Reserved before anything is created, so a device limit leaves nothing behind. EnsureVertexCapacity(settings.VertexCapacity); EnsureEdgeCapacity(settings.EdgeCapacity); + _bones = bones; GameObject chunkObject = new(ObjectName) { hideFlags = HideFlags.NotEditable, layer = settings.Layer }; - Transform root = chunkObject.transform; - root.SetParent(parent, false); - - _bones = new BoneRegistry(root, OnBoneDestroyed); + chunkObject.transform.SetParent(parent, false); _mesh = new Mesh { name = ObjectName }; _mesh.MarkDynamic(); _mesh.subMeshCount = 1; _mesh.bounds = FixedBounds; + chunkObject.AddComponent().sharedMesh = _mesh; - _renderer = chunkObject.AddComponent(); + _renderer = chunkObject.AddComponent(); _renderer.sharedMaterial = material; - _renderer.rootBone = root; - _renderer.quality = SkinQuality.Bone1; - _renderer.updateWhenOffscreen = false; - _renderer.skinnedMotionVectors = false; _renderer.motionVectorGenerationMode = MotionVectorGenerationMode.ForceNoMotion; _renderer.shadowCastingMode = ShadowCastingMode.Off; _renderer.receiveShadows = false; @@ -98,7 +89,7 @@ internal MeshChunk(Transform parent, Material material, WireframeContainerSettin _renderer.reflectionProbeUsage = ReflectionProbeUsage.Off; _renderer.allowOcclusionWhenDynamic = false; - // Creates the buffers and hands the mesh to the renderer. + // Creates the buffers. Flush(); } @@ -112,7 +103,7 @@ internal Mesh Mesh get => _mesh; } - internal SkinnedMeshRenderer Renderer + internal MeshRenderer Renderer { get => _renderer; } @@ -142,6 +133,18 @@ internal int EdgeCapacity get => _edges.Capacity; } + /// CPU copy of every vertex position, relative to its bone. + internal Vector3[] Positions + { + get => _positions; + } + + /// CPU copy of every vertex's bone slot. + internal float[] BoneIndices + { + get => _boneIndices; + } + internal void Add(Shape shape) { // Capacity is checked before anything changes, so a device limit leaves the chunk untouched. @@ -194,24 +197,13 @@ internal void Enqueue(Shape shape) /// Writes queued shapes into the buffers and uploads what changed. internal void Flush() { - _bones.PollSleepers(); if (_allocator.FreeCount >= CompactionThreshold && _allocator.FreeCount * 2 > _allocator.End) { Compact(); } WritePendingShapes(); - - // Bones go first: Unity doesn't range-check bone indices, so skin data must never reference a missing bone. - UploadBones(); - bool recreated = UploadVertices(); - recreated |= UploadIndices(); - if (recreated) - { - // Re-assigning the mesh makes the renderer drop skinning buffers sized for the old one. - _renderer.sharedMesh = null; - _renderer.sharedMesh = _mesh; - _renderer.localBounds = FixedBounds; - } + UploadVertices(); + UploadIndices(); } public void Dispose() @@ -224,19 +216,10 @@ public void Dispose() _shapeCount = 0; Array.Clear(_pending, 0, _pendingCount); _pendingCount = 0; - _bones.Dispose(); // The chunk's GameObject is a child of the proxy and goes away with it; the mesh is an asset and doesn't. UnityObjects.Destroy(_mesh); } - private void OnBoneDestroyed(Transform bone) - { - for (int i = 0; i < _shapeCount; i++) - { - _shapes[i].OnBoneDestroyed(bone); - } - } - /// Hands out vertex blocks again from vertex 0, packing live shapes together. private void Compact() { @@ -275,8 +258,8 @@ private void WritePendingShapes() } if ((dirty & DirtyFlags.Bones) != 0) { - shape.WriteBones(new Span(_skin, start, count)); - _skinRanges.Add(start, count); + shape.WriteBoneIndices(new Span(_boneIndices, start, count)); + _boneRanges.Add(start, count); } if ((dirty & DirtyFlags.Edges) != 0) { @@ -286,49 +269,28 @@ private void WritePendingShapes() _pendingCount = 0; } - private void UploadBones() - { - if (!_bones.IsDirty) - { - return; - } - Transform[] transforms = _bones.Transforms; - if (_bindposes.Length != transforms.Length) - { - // Endpoints are stored relative to their bone, so every bind pose is identity. - _bindposes = new Matrix4x4[transforms.Length]; - Array.Fill(_bindposes, Matrix4x4.identity); - _mesh.bindposes = _bindposes; - } - _renderer.bones = transforms; - _bones.ClearDirty(); - } - - /// True when the vertex buffer was recreated. - private bool UploadVertices() + private void UploadVertices() { int capacity = _positions.Length; if (_meshVertexCapacity == capacity) { UploadRanges(_positionRanges, _positions, PositionStream); UploadRanges(_colorRanges, _colors, ColorStream); - UploadRanges(_skinRanges, _skin, SkinStream); - return false; + UploadRanges(_boneRanges, _boneIndices, BoneStream); + return; } - // A resized buffer starts with garbage, and garbage bone indices crash skinning, so every stream is - // rewritten in full. + // A resized buffer starts with garbage, so every stream is rewritten in full. _mesh.SetVertexBufferParams(capacity, VertexLayout); _mesh.SetVertexBufferData(_positions, 0, 0, capacity, PositionStream, UploadFlags); _mesh.SetVertexBufferData(_colors, 0, 0, capacity, ColorStream, UploadFlags); - _mesh.SetVertexBufferData(_skin, 0, 0, capacity, SkinStream, UploadFlags); + _mesh.SetVertexBufferData(_boneIndices, 0, 0, capacity, BoneStream, UploadFlags); _positionRanges.Clear(); _colorRanges.Clear(); - _skinRanges.Clear(); + _boneRanges.Clear(); _meshVertexCapacity = capacity; // The sub-mesh records the vertex range it uses, so it has to be set again. _meshEdgeCount = -1; - return true; } private void UploadRanges(DirtyRanges ranges, T[] data, int stream) where T : struct @@ -342,13 +304,11 @@ private void UploadRanges(DirtyRanges ranges, T[] data, int stream) where T : ranges.Clear(); } - /// True when the index buffer was recreated. - private bool UploadIndices() + private void UploadIndices() { int edgeCount = _edges.Count; int indexCapacity = _edges.Capacity * 2; IndexFormat format = _meshVertexCapacity > MaxUInt16Vertices ? IndexFormat.UInt32 : IndexFormat.UInt16; - bool recreated = false; if (_meshIndexCapacity != indexCapacity || _meshIndexFormat != format) { @@ -358,7 +318,6 @@ private bool UploadIndices() _edgeRanges.Clear(); UploadIndexRange(0, edgeCount * 2); _meshEdgeCount = -1; - recreated = true; } else { @@ -384,7 +343,6 @@ private bool UploadIndices() _mesh.SetSubMesh(0, subMesh, UploadFlags); _meshEdgeCount = edgeCount; } - return recreated; } private void UploadIndexRange(int start, int count) @@ -423,10 +381,10 @@ private void EnsureVertexCapacity(int required) } // Positions are the widest stream. CheckBufferSize((long)capacity * 12); - // New elements default to zero, which is bone slot 0 (world space), a valid index for skinning. + // New elements default to zero, which is bone slot 0 (world space), a valid index for the shader. Array.Resize(ref _positions, capacity); Array.Resize(ref _colors, capacity); - Array.Resize(ref _skin, capacity); + Array.Resize(ref _boneIndices, capacity); } private void EnsureEdgeCapacity(int required) diff --git a/UnityPackage/Runtime/Core/MeshChunk.cs.meta b/UnityPackage/Runtime/Rendering/MeshChunk.cs.meta similarity index 100% rename from UnityPackage/Runtime/Core/MeshChunk.cs.meta rename to UnityPackage/Runtime/Rendering/MeshChunk.cs.meta diff --git a/UnityPackage/Runtime/Core/MeshProxy.cs b/UnityPackage/Runtime/Rendering/MeshProxy.cs similarity index 52% rename from UnityPackage/Runtime/Core/MeshProxy.cs rename to UnityPackage/Runtime/Rendering/MeshProxy.cs index 8bc1c05..f63ddab 100644 --- a/UnityPackage/Runtime/Core/MeshProxy.cs +++ b/UnityPackage/Runtime/Rendering/MeshProxy.cs @@ -1,23 +1,25 @@ using System; using System.Collections.Generic; using UnityEngine; +using UnityEngine.Rendering; namespace reromanlee.Wireframes { /// - /// Component on the container's GameObject. It applies queued shape edits once per frame and releases every + /// Component on the container's GameObject. Right before each render, it applies queued shape edits and reads the + /// bones' matrices, so everything that moved or changed earlier in the frame shows up in that frame. It releases every /// resource when its GameObject is destroyed, including when the scene unloads. /// [AddComponentMenu("")] [DisallowMultipleComponent] - [DefaultExecutionOrder(ExecutionOrder)] internal sealed class MeshProxy : MonoBehaviour { - // After default-order scripts' LateUpdate, and still before Unity skins meshes later in the frame. - private const int ExecutionOrder = 32000; - private const string ShaderName = "reromanlee/Wireframes/VertexColors"; + private const string ShaderName = "reromanlee/Wireframes/Unlit"; private readonly List _chunks = new(); + private readonly BoneRegistry _bones = new(); + private readonly BoneTexture _boneTexture = new(); + private MaterialPropertyBlock _propertyBlock; private WireframeContainer _container; private Material _material; private bool _ownsMaterial; @@ -28,6 +30,16 @@ internal IReadOnlyList Chunks get => _chunks; } + internal BoneRegistry Bones + { + get => _bones; + } + + internal BoneTexture BoneTexture + { + get => _boneTexture; + } + internal void Initialize(WireframeContainer container, WireframeContainerSettings settings) { _container = container; @@ -43,10 +55,13 @@ internal void Initialize(WireframeContainer container, WireframeContainerSetting { throw new InvalidOperationException($"Shader '{ShaderName}' was not found."); } - _material = new Material(shader) { name = ShaderName }; + _material = new Material(shader) { name = ShaderName, hideFlags = HideFlags.DontSave }; _ownsMaterial = true; } - _chunks.Add(new MeshChunk(transform, _material, settings)); + _propertyBlock = new MaterialPropertyBlock(); + _chunks.Add(new MeshChunk(transform, _material, settings, _bones)); + // Uploads the bone texture and hands it to the renderers. + Flush(); } /// Adds to a chunk and returns that chunk. @@ -58,12 +73,22 @@ internal MeshChunk Attach(Shape shape) return chunk; } + /// Reads the bones, writes queued shapes and uploads what changed. internal void Flush() { + _bones.ReadMatrices(); for (int i = 0; i < _chunks.Count; i++) { _chunks[i].Flush(); } + if (_boneTexture.Upload(_bones)) + { + _propertyBlock.SetTexture(BoneTexture.PropertyId, _boneTexture.Texture); + for (int i = 0; i < _chunks.Count; i++) + { + _chunks[i].Renderer.SetPropertyBlock(_propertyBlock); + } + } } internal void Shutdown() @@ -78,6 +103,7 @@ internal void Shutdown() _chunks[i].Dispose(); } _chunks.Clear(); + _boneTexture.Dispose(); if (_ownsMaterial) { UnityObjects.Destroy(_material); @@ -89,14 +115,49 @@ internal void Shutdown() } } - private void LateUpdate() + private void OnEnable() { - Flush(); + // Each fires only under its own pipeline, so both stay subscribed and a pipeline switch needs no handling. + RenderPipelineManager.beginContextRendering += OnBeginContextRendering; + Camera.onPreCull += OnPreCullCamera; + } + + private void OnDisable() + { + RenderPipelineManager.beginContextRendering -= OnBeginContextRendering; + Camera.onPreCull -= OnPreCullCamera; } private void OnDestroy() { Shutdown(); } + + private void OnBeginContextRendering(ScriptableRenderContext context, List cameras) + { + FlushBeforeRendering(); + } + + private void OnPreCullCamera(Camera camera) + { + FlushBeforeRendering(); + } + + private void FlushBeforeRendering() + { + if (_isShutDown) + { + return; + } + try + { + Flush(); + } + catch (Exception exception) + { + // An exception must never escape into Unity's rendering. + Debug.LogException(exception, this); + } + } } } diff --git a/UnityPackage/Runtime/Core/MeshProxy.cs.meta b/UnityPackage/Runtime/Rendering/MeshProxy.cs.meta similarity index 100% rename from UnityPackage/Runtime/Core/MeshProxy.cs.meta rename to UnityPackage/Runtime/Rendering/MeshProxy.cs.meta diff --git a/UnityPackage/Runtime/Resources/reromanlee.Wireframes/Unlit.shader b/UnityPackage/Runtime/Resources/reromanlee.Wireframes/Unlit.shader new file mode 100644 index 0000000..5645fb5 --- /dev/null +++ b/UnityPackage/Runtime/Resources/reromanlee.Wireframes/Unlit.shader @@ -0,0 +1,208 @@ +Shader "reromanlee/Wireframes/Unlit" +{ + // Unlit vertex colors on Wireframes meshes. Each pipeline gets its own SubShader: URP's and HDRP's only exist in + // projects that have that pipeline, and the last one serves the Built-in Render Pipeline. + Properties + { + [Enum(UnityEngine.Rendering.CompareFunction)] _ZTest ("Depth Test", Float) = 4 + [Enum(Off, 0, On, 1)] _ZWrite ("Depth Write", Float) = 1 + [Enum(UnityEngine.Rendering.BlendMode)] _SrcBlend ("Source Blend", Float) = 1 + [Enum(UnityEngine.Rendering.BlendMode)] _DstBlend ("Destination Blend", Float) = 0 + [ToggleUI] _WireframesVertexAlpha ("Use Vertex Alpha", Float) = 0 + _WireframesAlphaScale ("Alpha Scale", Range(0, 1)) = 1 + } + + SubShader + { + PackageRequirements + { + "com.unity.render-pipelines.universal" + } + + Tags { "RenderPipeline" = "UniversalPipeline" "RenderType" = "Opaque" "Queue" = "Geometry" } + + Pass + { + Name "Wireframes" + Tags { "LightMode" = "SRPDefaultUnlit" } + + ZTest [_ZTest] + ZWrite [_ZWrite] + Blend [_SrcBlend] [_DstBlend] + Cull Off + + HLSLPROGRAM + #pragma target 3.5 + #pragma vertex Vertex + #pragma fragment Fragment + + #include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl" + #include "Packages/com.reromanlee.wireframes/Runtime/Shaders/Wireframes.hlsl" + + CBUFFER_START(UnityPerMaterial) + half _WireframesVertexAlpha; + half _WireframesAlphaScale; + CBUFFER_END + + struct Attributes + { + float3 position : POSITION; + half4 color : COLOR; + float bone : TEXCOORD0; + UNITY_VERTEX_INPUT_INSTANCE_ID + }; + + struct Varyings + { + float4 positionCS : SV_POSITION; + half4 color : COLOR; + UNITY_VERTEX_OUTPUT_STEREO + }; + + Varyings Vertex(Attributes input) + { + Varyings output; + UNITY_SETUP_INSTANCE_ID(input); + UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(output); + output.positionCS = TransformWorldToHClip(WireframesSkin(input.position, input.bone)); + half alpha = lerp(1.0h, input.color.a, _WireframesVertexAlpha) * _WireframesAlphaScale; + output.color = half4(WireframesColor(input.color.rgb), alpha); + return output; + } + + half4 Fragment(Varyings input) : SV_Target + { + return input.color; + } + ENDHLSL + } + } + + SubShader + { + PackageRequirements + { + "com.unity.render-pipelines.high-definition" + } + + Tags { "RenderPipeline" = "HDRenderPipeline" "RenderType" = "Opaque" "Queue" = "Geometry" } + + Pass + { + Name "Wireframes" + Tags { "LightMode" = "ForwardOnly" } + + ZTest [_ZTest] + ZWrite [_ZWrite] + Blend [_SrcBlend] [_DstBlend] + Cull Off + + HLSLPROGRAM + #pragma target 4.5 + #pragma only_renderers d3d11 playstation xboxone xboxseries vulkan metal switch + #pragma vertex Vertex + #pragma fragment Fragment + + #include "Packages/com.unity.render-pipelines.core/ShaderLibrary/Common.hlsl" + #include "Packages/com.unity.render-pipelines.high-definition/Runtime/ShaderLibrary/ShaderVariables.hlsl" + #include "Packages/com.reromanlee.wireframes/Runtime/Shaders/Wireframes.hlsl" + + CBUFFER_START(UnityPerMaterial) + half _WireframesVertexAlpha; + half _WireframesAlphaScale; + CBUFFER_END + + struct Attributes + { + float3 position : POSITION; + half4 color : COLOR; + float bone : TEXCOORD0; + UNITY_VERTEX_INPUT_INSTANCE_ID + }; + + struct Varyings + { + float4 positionCS : SV_POSITION; + half4 color : COLOR; + UNITY_VERTEX_OUTPUT_STEREO + }; + + Varyings Vertex(Attributes input) + { + Varyings output; + UNITY_SETUP_INSTANCE_ID(input); + UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(output); + // HDRP renders relative to the camera. + float3 world = WireframesSkin(input.position, input.bone); + output.positionCS = TransformWorldToHClip(GetCameraRelativePositionWS(world)); + half alpha = lerp(1.0h, input.color.a, _WireframesVertexAlpha) * _WireframesAlphaScale; + output.color = half4(WireframesColor(input.color.rgb), alpha); + return output; + } + + half4 Fragment(Varyings input) : SV_Target + { + return input.color; + } + ENDHLSL + } + } + + SubShader + { + Tags { "RenderType" = "Opaque" "Queue" = "Geometry" } + + Pass + { + Name "Wireframes" + + ZTest [_ZTest] + ZWrite [_ZWrite] + Blend [_SrcBlend] [_DstBlend] + Cull Off + + CGPROGRAM + #pragma target 3.5 + #pragma vertex Vertex + #pragma fragment Fragment + + #include "UnityCG.cginc" + #include "Packages/com.reromanlee.wireframes/Runtime/Shaders/Wireframes.hlsl" + + half _WireframesVertexAlpha; + half _WireframesAlphaScale; + + struct Attributes + { + float3 position : POSITION; + half4 color : COLOR; + float bone : TEXCOORD0; + UNITY_VERTEX_INPUT_INSTANCE_ID + }; + + struct Varyings + { + float4 positionCS : SV_POSITION; + half4 color : COLOR; + UNITY_VERTEX_OUTPUT_STEREO + }; + + Varyings Vertex(Attributes input) + { + Varyings output; + UNITY_SETUP_INSTANCE_ID(input); + UNITY_INITIALIZE_VERTEX_OUTPUT_STEREO(output); + output.positionCS = mul(UNITY_MATRIX_VP, float4(WireframesSkin(input.position, input.bone), 1.0)); + half alpha = lerp(1.0h, input.color.a, _WireframesVertexAlpha) * _WireframesAlphaScale; + output.color = half4(WireframesColor(input.color.rgb), alpha); + return output; + } + + half4 Fragment(Varyings input) : SV_Target + { + return input.color; + } + ENDCG + } + } +} diff --git a/UnityPackage/Runtime/Resources/reromanlee.Wireframes/VertexColors.shader.meta b/UnityPackage/Runtime/Resources/reromanlee.Wireframes/Unlit.shader.meta similarity index 80% rename from UnityPackage/Runtime/Resources/reromanlee.Wireframes/VertexColors.shader.meta rename to UnityPackage/Runtime/Resources/reromanlee.Wireframes/Unlit.shader.meta index 56f9a7f..cbe998c 100644 --- a/UnityPackage/Runtime/Resources/reromanlee.Wireframes/VertexColors.shader.meta +++ b/UnityPackage/Runtime/Resources/reromanlee.Wireframes/Unlit.shader.meta @@ -1,5 +1,5 @@ fileFormatVersion: 2 -guid: 78e8aacfb9ff39f4abe263ee40e07643 +guid: d4544708320996e8d49dc8ee6b8f59ed ShaderImporter: externalObjects: {} defaultTextures: [] diff --git a/UnityPackage/Runtime/Resources/reromanlee.Wireframes/VertexColors.shader b/UnityPackage/Runtime/Resources/reromanlee.Wireframes/VertexColors.shader deleted file mode 100644 index 5b762b8..0000000 --- a/UnityPackage/Runtime/Resources/reromanlee.Wireframes/VertexColors.shader +++ /dev/null @@ -1,56 +0,0 @@ -Shader "reromanlee/Wireframes/VertexColors" { - - Properties { - - } - - SubShader { - - Tags { - "RenderType"="Opaque" - } - - LOD 100 - - Pass { - - CGPROGRAM - - #pragma vertex vert - #pragma fragment frag - - #include "UnityCG.cginc" - - struct appdata { - float4 vertex : POSITION; - half4 color : COLOR; - }; - - struct v2f { - float4 vertex : SV_POSITION; - half4 color : COLOR; - }; - - v2f vert (appdata v) { - v2f o; - o.vertex = UnityObjectToClipPos(v.vertex); - o.color = v.color; - // Unity passes vertex colors through unconverted, so in linear projects they are converted here to - // match how the same Color looks on a material. - #if !defined(UNITY_COLORSPACE_GAMMA) - o.color.rgb = GammaToLinearSpace(o.color.rgb); - #endif - return o; - } - - half4 frag (v2f i) : SV_Target { - return i.color; - } - - ENDCG - - } - - } - -} \ No newline at end of file diff --git a/UnityPackage/Runtime/Shaders.meta b/UnityPackage/Runtime/Shaders.meta new file mode 100644 index 0000000..2b00e2a --- /dev/null +++ b/UnityPackage/Runtime/Shaders.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: ad7b5a51d235e113b5af906db072a410 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityPackage/Runtime/Shaders/Wireframes.hlsl b/UnityPackage/Runtime/Shaders/Wireframes.hlsl new file mode 100644 index 0000000..31c2070 --- /dev/null +++ b/UnityPackage/Runtime/Shaders/Wireframes.hlsl @@ -0,0 +1,36 @@ +#ifndef REROMANLEE_WIREFRAMES_INCLUDED +#define REROMANLEE_WIREFRAMES_INCLUDED + +// Skinning for Wireframes meshes, usable from any render pipeline. Every vertex stores its position relative to one +// bone and that bone's index in TEXCOORD0; the package uploads each bone's local-to-world matrix into +// _WireframesBones once per frame, as three texels (the matrix rows) per bone and 256 bones per texture row. +// Bone 0 is the identity, which stands for world space. + +#define WIREFRAMES_BONES_PER_ROW 256u + +Texture2D _WireframesBones; + +// World position of a vertex stored relative to its bone. +float3 WireframesSkin(float3 position, float bone) +{ + uint slot = (uint)(bone + 0.5); + int3 texel = int3((slot % WIREFRAMES_BONES_PER_ROW) * 3u, slot / WIREFRAMES_BONES_PER_ROW, 0); + float4 local = float4(position, 1.0); + return float3( + dot(_WireframesBones.Load(texel), local), + dot(_WireframesBones.Load(texel + int3(1, 0, 0)), local), + dot(_WireframesBones.Load(texel + int3(2, 0, 0)), local)); +} + +// Vertex colors reach shaders unconverted, so in linear color space they are converted here to look the same as that +// color on a material. +half3 WireframesColor(half3 color) +{ +#if defined(UNITY_COLORSPACE_GAMMA) + return color; +#else + return color * (color * (color * 0.305306011h + 0.682171111h) + 0.012522878h); +#endif +} + +#endif diff --git a/UnityPackage/Runtime/Shaders/Wireframes.hlsl.meta b/UnityPackage/Runtime/Shaders/Wireframes.hlsl.meta new file mode 100644 index 0000000..85a4677 --- /dev/null +++ b/UnityPackage/Runtime/Shaders/Wireframes.hlsl.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 89bd75423cceea077971c0cd9978d40f +ShaderIncludeImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityPackage/Runtime/Shapes/Box/Box.cs b/UnityPackage/Runtime/Shapes/Box/Box.cs index 961a198..581972f 100644 --- a/UnityPackage/Runtime/Shapes/Box/Box.cs +++ b/UnityPackage/Runtime/Shapes/Box/Box.cs @@ -28,12 +28,12 @@ public Vector3 Size { get { - ThrowIfDisposed(); + EnsureUsable(); return _size; } set { - ThrowIfDisposed(); + EnsureUsable(); _size = value; MarkDirty(DirtyFlags.Positions); } @@ -43,12 +43,12 @@ public Vector3 LocalCornerA { get { - ThrowIfDisposed(); + EnsureUsable(); return LocalPosition - LocalRotation * (_size * 0.5f); } set { - ThrowIfDisposed(); + EnsureUsable(); SetCorners(value, LocalCornerB); } } @@ -57,12 +57,12 @@ public Vector3 LocalCornerB { get { - ThrowIfDisposed(); + EnsureUsable(); return LocalPosition + LocalRotation * (_size * 0.5f); } set { - ThrowIfDisposed(); + EnsureUsable(); SetCorners(LocalCornerA, value); } } @@ -71,12 +71,12 @@ public Vector3 WorldCornerA { get { - ThrowIfDisposed(); + EnsureUsable(); return ToWorld(Bone, LocalCornerA); } set { - ThrowIfDisposed(); + EnsureUsable(); LocalCornerA = ToLocal(Bone, value); } } @@ -85,12 +85,12 @@ public Vector3 WorldCornerB { get { - ThrowIfDisposed(); + EnsureUsable(); return ToWorld(Bone, LocalCornerB); } set { - ThrowIfDisposed(); + EnsureUsable(); LocalCornerB = ToLocal(Bone, value); } } diff --git a/UnityPackage/Runtime/Shapes/Capsule/Capsule.cs b/UnityPackage/Runtime/Shapes/Capsule/Capsule.cs index dace375..5be38fc 100644 --- a/UnityPackage/Runtime/Shapes/Capsule/Capsule.cs +++ b/UnityPackage/Runtime/Shapes/Capsule/Capsule.cs @@ -37,12 +37,12 @@ public float RadiusA { get { - ThrowIfDisposed(); + EnsureUsable(); return _radiusA; } set { - ThrowIfDisposed(); + EnsureUsable(); _radiusA = value; MarkDirty(DirtyFlags.Positions); } @@ -52,12 +52,12 @@ public float RadiusB { get { - ThrowIfDisposed(); + EnsureUsable(); return _radiusB; } set { - ThrowIfDisposed(); + EnsureUsable(); _radiusB = value; MarkDirty(DirtyFlags.Positions); } @@ -67,7 +67,7 @@ public int SegmentCount { get { - ThrowIfDisposed(); + EnsureUsable(); return (VertexCount + 6) / 4; } } diff --git a/UnityPackage/Runtime/Shapes/Circle/Circle.cs b/UnityPackage/Runtime/Shapes/Circle/Circle.cs index e29afda..97d5f55 100644 --- a/UnityPackage/Runtime/Shapes/Circle/Circle.cs +++ b/UnityPackage/Runtime/Shapes/Circle/Circle.cs @@ -23,12 +23,12 @@ public float Radius { get { - ThrowIfDisposed(); + EnsureUsable(); return _radius; } set { - ThrowIfDisposed(); + EnsureUsable(); _radius = value; MarkDirty(DirtyFlags.Positions); } @@ -38,7 +38,7 @@ public int SegmentCount { get { - ThrowIfDisposed(); + EnsureUsable(); return VertexCount; } } diff --git a/UnityPackage/Runtime/Shapes/Cone/Cone.cs b/UnityPackage/Runtime/Shapes/Cone/Cone.cs index 843ed14..ca90173 100644 --- a/UnityPackage/Runtime/Shapes/Cone/Cone.cs +++ b/UnityPackage/Runtime/Shapes/Cone/Cone.cs @@ -35,12 +35,12 @@ public float Radius { get { - ThrowIfDisposed(); + EnsureUsable(); return _radius; } set { - ThrowIfDisposed(); + EnsureUsable(); _radius = value; MarkDirty(DirtyFlags.Positions); } @@ -50,7 +50,7 @@ public int SegmentCount { get { - ThrowIfDisposed(); + EnsureUsable(); return VertexCount - TipCount; } } diff --git a/UnityPackage/Runtime/Shapes/Cylinder/Cylinder.cs b/UnityPackage/Runtime/Shapes/Cylinder/Cylinder.cs index 2702529..d98e326 100644 --- a/UnityPackage/Runtime/Shapes/Cylinder/Cylinder.cs +++ b/UnityPackage/Runtime/Shapes/Cylinder/Cylinder.cs @@ -35,12 +35,12 @@ public float Radius { get { - ThrowIfDisposed(); + EnsureUsable(); return _radius; } set { - ThrowIfDisposed(); + EnsureUsable(); _radius = value; MarkDirty(DirtyFlags.Positions); } @@ -50,7 +50,7 @@ public int SegmentCount { get { - ThrowIfDisposed(); + EnsureUsable(); return VertexCount / RingCount; } } diff --git a/UnityPackage/Runtime/Shapes/Ellipse/Ellipse.cs b/UnityPackage/Runtime/Shapes/Ellipse/Ellipse.cs index 55d3b69..1267a93 100644 --- a/UnityPackage/Runtime/Shapes/Ellipse/Ellipse.cs +++ b/UnityPackage/Runtime/Shapes/Ellipse/Ellipse.cs @@ -23,12 +23,12 @@ public Vector2 Radii { get { - ThrowIfDisposed(); + EnsureUsable(); return _radii; } set { - ThrowIfDisposed(); + EnsureUsable(); _radii = value; MarkDirty(DirtyFlags.Positions); } @@ -38,7 +38,7 @@ public int SegmentCount { get { - ThrowIfDisposed(); + EnsureUsable(); return VertexCount; } } diff --git a/UnityPackage/Runtime/Shapes/Ellipsoid/Ellipsoid.cs b/UnityPackage/Runtime/Shapes/Ellipsoid/Ellipsoid.cs index 3a2274a..2e426d6 100644 --- a/UnityPackage/Runtime/Shapes/Ellipsoid/Ellipsoid.cs +++ b/UnityPackage/Runtime/Shapes/Ellipsoid/Ellipsoid.cs @@ -29,12 +29,12 @@ public Vector3 Radii { get { - ThrowIfDisposed(); + EnsureUsable(); return _radii; } set { - ThrowIfDisposed(); + EnsureUsable(); _radii = value; MarkDirty(DirtyFlags.Positions); } @@ -44,7 +44,7 @@ public int SegmentCount { get { - ThrowIfDisposed(); + EnsureUsable(); return VertexCount / AxisRings.RingCount; } } diff --git a/UnityPackage/Runtime/Shapes/Frustum/Frustum.cs b/UnityPackage/Runtime/Shapes/Frustum/Frustum.cs index d4d3432..996d9af 100644 --- a/UnityPackage/Runtime/Shapes/Frustum/Frustum.cs +++ b/UnityPackage/Runtime/Shapes/Frustum/Frustum.cs @@ -30,12 +30,12 @@ public float RadiusA { get { - ThrowIfDisposed(); + EnsureUsable(); return _radiusA; } set { - ThrowIfDisposed(); + EnsureUsable(); _radiusA = value; MarkDirty(DirtyFlags.Positions); } @@ -45,12 +45,12 @@ public float RadiusB { get { - ThrowIfDisposed(); + EnsureUsable(); return _radiusB; } set { - ThrowIfDisposed(); + EnsureUsable(); _radiusB = value; MarkDirty(DirtyFlags.Positions); } @@ -60,7 +60,7 @@ public int SideCount { get { - ThrowIfDisposed(); + EnsureUsable(); return VertexCount / PolygonCount; } } diff --git a/UnityPackage/Runtime/Shapes/Polyline/Polyline.cs b/UnityPackage/Runtime/Shapes/Polyline/Polyline.cs index 025bb13..a9542ab 100644 --- a/UnityPackage/Runtime/Shapes/Polyline/Polyline.cs +++ b/UnityPackage/Runtime/Shapes/Polyline/Polyline.cs @@ -37,7 +37,7 @@ public int PointCount { get { - ThrowIfDisposed(); + EnsureUsable(); return _points.Length; } } @@ -46,7 +46,7 @@ public bool IsClosed { get { - ThrowIfDisposed(); + EnsureUsable(); return _isClosed; } } diff --git a/UnityPackage/Runtime/Shapes/Pyramid/Pyramid.cs b/UnityPackage/Runtime/Shapes/Pyramid/Pyramid.cs index 0462c4f..77952ec 100644 --- a/UnityPackage/Runtime/Shapes/Pyramid/Pyramid.cs +++ b/UnityPackage/Runtime/Shapes/Pyramid/Pyramid.cs @@ -31,12 +31,12 @@ public Vector2 BaseSize { get { - ThrowIfDisposed(); + EnsureUsable(); return _baseSize; } set { - ThrowIfDisposed(); + EnsureUsable(); _baseSize = value; MarkDirty(DirtyFlags.Positions); } diff --git a/UnityPackage/Runtime/Shapes/RoundedRectangle/RoundedRectangle.cs b/UnityPackage/Runtime/Shapes/RoundedRectangle/RoundedRectangle.cs index 8c58694..a8ffe3d 100644 --- a/UnityPackage/Runtime/Shapes/RoundedRectangle/RoundedRectangle.cs +++ b/UnityPackage/Runtime/Shapes/RoundedRectangle/RoundedRectangle.cs @@ -34,12 +34,12 @@ public float CornerRadius { get { - ThrowIfDisposed(); + EnsureUsable(); return _cornerRadius; } set { - ThrowIfDisposed(); + EnsureUsable(); _cornerRadius = value; MarkDirty(DirtyFlags.Positions); } @@ -49,7 +49,7 @@ public int SegmentCount { get { - ThrowIfDisposed(); + EnsureUsable(); return VertexCount - CornerCount; } } diff --git a/UnityPackage/Runtime/Shapes/Sphere/Sphere.cs b/UnityPackage/Runtime/Shapes/Sphere/Sphere.cs index e53fade..27f6726 100644 --- a/UnityPackage/Runtime/Shapes/Sphere/Sphere.cs +++ b/UnityPackage/Runtime/Shapes/Sphere/Sphere.cs @@ -29,12 +29,12 @@ public float Radius { get { - ThrowIfDisposed(); + EnsureUsable(); return _radius; } set { - ThrowIfDisposed(); + EnsureUsable(); _radius = value; MarkDirty(DirtyFlags.Positions); } @@ -44,7 +44,7 @@ public int SegmentCount { get { - ThrowIfDisposed(); + EnsureUsable(); return VertexCount / AxisRings.RingCount; } } diff --git a/UnityPackage/Runtime/Shapes/SpikedSphere/SpikedSphere.cs b/UnityPackage/Runtime/Shapes/SpikedSphere/SpikedSphere.cs index 33171bc..168248b 100644 --- a/UnityPackage/Runtime/Shapes/SpikedSphere/SpikedSphere.cs +++ b/UnityPackage/Runtime/Shapes/SpikedSphere/SpikedSphere.cs @@ -46,12 +46,12 @@ public float BaseRadius { get { - ThrowIfDisposed(); + EnsureUsable(); return _baseRadius; } set { - ThrowIfDisposed(); + EnsureUsable(); _baseRadius = value; MarkDirty(DirtyFlags.Positions); } @@ -61,12 +61,12 @@ public float SpikeLength { get { - ThrowIfDisposed(); + EnsureUsable(); return _spikeLength; } set { - ThrowIfDisposed(); + EnsureUsable(); _spikeLength = value; MarkDirty(DirtyFlags.Positions); } @@ -76,7 +76,7 @@ public int SpikeCount { get { - ThrowIfDisposed(); + EnsureUsable(); return _solid.Faces.Length; } } diff --git a/UnityPackage/Runtime/Shapes/Stadium/Stadium.cs b/UnityPackage/Runtime/Shapes/Stadium/Stadium.cs index 3b154d3..97da750 100644 --- a/UnityPackage/Runtime/Shapes/Stadium/Stadium.cs +++ b/UnityPackage/Runtime/Shapes/Stadium/Stadium.cs @@ -37,12 +37,12 @@ public float RadiusA { get { - ThrowIfDisposed(); + EnsureUsable(); return _radiusA; } set { - ThrowIfDisposed(); + EnsureUsable(); _radiusA = value; MarkDirty(DirtyFlags.Positions); } @@ -52,12 +52,12 @@ public float RadiusB { get { - ThrowIfDisposed(); + EnsureUsable(); return _radiusB; } set { - ThrowIfDisposed(); + EnsureUsable(); _radiusB = value; MarkDirty(DirtyFlags.Positions); } @@ -67,7 +67,7 @@ public int SegmentCount { get { - ThrowIfDisposed(); + EnsureUsable(); return VertexCount - ArcCount; } } diff --git a/UnityPackage/Runtime/Shapes/Star/Star.cs b/UnityPackage/Runtime/Shapes/Star/Star.cs index fdaa567..6464737 100644 --- a/UnityPackage/Runtime/Shapes/Star/Star.cs +++ b/UnityPackage/Runtime/Shapes/Star/Star.cs @@ -26,12 +26,12 @@ public float InnerRadius { get { - ThrowIfDisposed(); + EnsureUsable(); return _innerRadius; } set { - ThrowIfDisposed(); + EnsureUsable(); _innerRadius = value; MarkDirty(DirtyFlags.Positions); } @@ -41,12 +41,12 @@ public float OuterRadius { get { - ThrowIfDisposed(); + EnsureUsable(); return _outerRadius; } set { - ThrowIfDisposed(); + EnsureUsable(); _outerRadius = value; MarkDirty(DirtyFlags.Positions); } @@ -56,7 +56,7 @@ public int PointCount { get { - ThrowIfDisposed(); + EnsureUsable(); return VertexCount / 2; } } diff --git a/UnityPackage/Tests/Runtime/LineTests.cs b/UnityPackage/Tests/Runtime/LineTests.cs index 2cc2105..d05ab54 100644 --- a/UnityPackage/Tests/Runtime/LineTests.cs +++ b/UnityPackage/Tests/Runtime/LineTests.cs @@ -1,5 +1,4 @@ using System.Collections; -using System.Text.RegularExpressions; using NUnit.Framework; using UnityEngine; using UnityEngine.TestTools; @@ -139,19 +138,17 @@ public void SharedBone_IsRegisteredOnceAndReleasedWithLastUse() Assert.That(bones.Count, Is.Zero); } - [UnityTest] - public IEnumerator ReleasingLastUse_RemovesHiddenWatcher() + [Test] + public void AttachingABone_AddsNothingToItsGameObject() { Transform bone = CreateBone(Vector3.zero, Quaternion.identity); + int componentCount = bone.GetComponents().Length; ILine line = CreateContainer().CreateLine(); line.BoneA = bone; - Assert.That(bone.GetComponent() != null, Is.True); - - line.BoneA = null; - yield return null; + line.BoneB = bone; - Assert.That(bone.GetComponent() == null, Is.True); + Assert.That(bone.GetComponents(), Has.Length.EqualTo(componentCount)); } [UnityTest] @@ -163,16 +160,38 @@ public IEnumerator DestroyedBone_LeavesEndpointInPlace() line.BoneA = bone; line.LocalPositionA = new Vector3(0f, 0f, 1f); bone.position = new Vector3(5f, 1f, 0f); + // A drawn frame, which reads the bone's pose; a destroyed bone leaves its last drawn pose behind. + container.Proxy.Flush(); Vector3 expected = bone.TransformPoint(new Vector3(0f, 0f, 1f)); + int start = ((Line)line).VertexStart; Object.Destroy(bone.gameObject); yield return null; + // Before the line notices, the bone's frozen pose still draws it in place. + AssertApproximately(expected, FlushAndBake(container)[start]); Assert.That(line.BoneA, Is.Null); AssertApproximately(expected, line.WorldPositionA); Assert.That(ChunkOf(container).Bones.Count, Is.Zero); - Vector3[] baked = FlushAndBake(container); - AssertApproximately(expected, baked[((Line)line).VertexStart]); + AssertApproximately(expected, FlushAndBake(container)[start]); + } + + [UnityTest] + public IEnumerator DestroyedBone_StaysWhereItWasLastDrawn() + { + // Unity destroys objects before it renders, so a move in the frame of the destruction is never drawn. + WireframeContainer container = CreateContainer(); + Transform bone = CreateBone(new Vector3(1f, 0f, 0f), Quaternion.identity); + ILine line = container.CreateLine(bone, null); + bone.position = new Vector3(2f, 0f, 0f); + container.Proxy.Flush(); + + bone.position = new Vector3(3f, 0f, 0f); + Object.Destroy(bone.gameObject); + yield return null; + container.Proxy.Flush(); + + AssertApproximately(new Vector3(2f, 0f, 0f), line.WorldPositionA); } [UnityTest] @@ -193,9 +212,8 @@ public IEnumerator DestroyedParentOfBone_LeavesEndpointInPlace() } [UnityTest] - public IEnumerator ReattachingInTheSameFrame_KeepsWatchingTheBone() + public IEnumerator ReattachingInTheSameFrame_KeepsFollowingTheBone() { - // Releasing schedules the watcher's destruction for the end of the frame, so a fresh one has to take over. Transform bone = CreateBone(Vector3.zero, Quaternion.identity); ILine line = CreateContainer().CreateLine(); line.BoneA = bone; @@ -211,22 +229,22 @@ public IEnumerator ReattachingInTheSameFrame_KeepsWatchingTheBone() } [UnityTest] - public IEnumerator BoneDestroyedBeforeItWasActive_FallsBackToLocalOffset() + public IEnumerator BoneDestroyedBeforeItWasActive_StaysInPlace() { + // The pose is read when the bone is attached, so even a bone that never rendered leaves one behind. WireframeContainer container = CreateContainer(); Transform bone = CreateBone(new Vector3(9f, 9f, 9f), Quaternion.identity); bone.gameObject.SetActive(false); ILine line = container.CreateLine(); line.BoneA = bone; line.LocalPositionA = new Vector3(1f, 0f, 0f); - LogAssert.Expect(LogType.Warning, new Regex("was destroyed before its GameObject was ever active")); Object.Destroy(bone.gameObject); yield return null; container.Proxy.Flush(); Assert.That(line.BoneA, Is.Null); - AssertApproximately(new Vector3(1f, 0f, 0f), line.WorldPositionA); + AssertApproximately(new Vector3(10f, 9f, 9f), line.WorldPositionA); Assert.That(ChunkOf(container).Bones.Count, Is.Zero); } } diff --git a/UnityPackage/Tests/Runtime/MeshBufferTests.cs b/UnityPackage/Tests/Runtime/MeshBufferTests.cs index 3e46edf..9861520 100644 --- a/UnityPackage/Tests/Runtime/MeshBufferTests.cs +++ b/UnityPackage/Tests/Runtime/MeshBufferTests.cs @@ -119,11 +119,12 @@ public void ManyVertices_SwitchTheIndexBufferTo32Bit() } [Test] - public void ManyBones_GrowTheBoneArrayWithMatchingBindposes() + public void ManyBones_GrowTheBoneTextureWithEveryMatrix() { + // More bones than one texture row holds, so the texture grows while the matrices stay in their slots. WireframeContainer container = CreateContainer(); List lines = new(); - for (int i = 0; i < 100; i++) + for (int i = 0; i < BoneTexture.BonesPerRow + 44; i++) { ILine line = container.CreateLine(); line.BoneA = CreateBone(new Vector3(i, 0f, 0f), Quaternion.identity); @@ -133,13 +134,18 @@ public void ManyBones_GrowTheBoneArrayWithMatchingBindposes() Vector3[] baked = FlushAndBake(container); - MeshChunk chunk = ChunkOf(container); - Assert.That(chunk.Renderer.bones, Has.Length.EqualTo(chunk.Mesh.bindposes.Length)); - Assert.That(chunk.Bones.Count, Is.EqualTo(100)); + Texture2D texture = container.Proxy.BoneTexture.Texture; + Assert.That(ChunkOf(container).Bones.Count, Is.EqualTo(lines.Count)); + Assert.That(texture.height, Is.GreaterThanOrEqualTo(2)); for (int i = 0; i < lines.Count; i++) { AssertApproximately(new Vector3(i, 0f, 0f), baked[((Line)lines[i]).VertexStart]); } + // The translation of the last bone, read back from where the shader finds it. + int slot = lines.Count; + int texel = slot / BoneTexture.BonesPerRow * BoneTexture.BonesPerRow * 3 + slot % BoneTexture.BonesPerRow * 3; + Unity.Collections.NativeArray texels = texture.GetPixelData(0); + AssertApproximately(lines.Count - 1f, texels[texel].w); } } } diff --git a/UnityPackage/Tests/Runtime/PolylineTests.cs b/UnityPackage/Tests/Runtime/PolylineTests.cs index 22a9494..397a01a 100644 --- a/UnityPackage/Tests/Runtime/PolylineTests.cs +++ b/UnityPackage/Tests/Runtime/PolylineTests.cs @@ -162,11 +162,14 @@ public void Dispose_ReleasesEveryBone() [UnityTest] public IEnumerator DestroyedBone_LeavesItsPointsInPlace() { + WireframeContainer container = CreateContainer(); Transform bone = CreateBone(new Vector3(2f, 0f, 0f), Quaternion.Euler(0f, 90f, 0f)); - IPolyline polyline = CreateContainer().CreatePolyline(Square); + IPolyline polyline = container.CreatePolyline(Square); polyline.SetBone(0, bone); polyline.SetBone(3, bone); bone.position = new Vector3(5f, 1f, 0f); + // A drawn frame, which reads the bone's pose; a destroyed bone leaves its last drawn pose behind. + container.Proxy.Flush(); Vector3 expected0 = polyline.GetWorldPosition(0); Vector3 expected3 = polyline.GetWorldPosition(3); diff --git a/UnityPackage/Tests/Runtime/ShaderTests.cs b/UnityPackage/Tests/Runtime/ShaderTests.cs new file mode 100644 index 0000000..f21e79c --- /dev/null +++ b/UnityPackage/Tests/Runtime/ShaderTests.cs @@ -0,0 +1,133 @@ +using System.Collections; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.TestTools; + +namespace reromanlee.Wireframes.Tests +{ + /// + /// Renders shapes with a camera and reads the pixels back, to check that the shader puts every vertex where its + /// bone's matrix says: the same place the CPU-side tests expect. + /// + public class ShaderTests : WireframesTestBase + { + private const int Size = 64; + // The camera sees world X and Y from -HalfExtent to +HalfExtent. + private const float HalfExtent = 4f; + + private Camera _camera; + private RenderTexture _target; + private Texture2D _readback; + + [SetUp] + public void CreateCamera() + { + _target = Track(new RenderTexture(Size, Size, 24, RenderTextureFormat.ARGB32)); + _readback = Track(new Texture2D(Size, Size, TextureFormat.RGBA32, false)); + _camera = Track(new GameObject("Shader Test Camera")).AddComponent(); + _camera.enabled = false; + // Without multisampling, a one-pixel line lands on one row at full brightness. + _camera.allowMSAA = false; + _camera.orthographic = true; + _camera.orthographicSize = HalfExtent; + _camera.transform.position = new Vector3(0f, 0f, -10f); + _camera.clearFlags = CameraClearFlags.SolidColor; + _camera.backgroundColor = Color.black; + _camera.nearClipPlane = 0.1f; + _camera.farClipPlane = 100f; + _camera.targetTexture = _target; + } + + [Test] + public void BonePose_PlacesVerticesWhereTheCpuReferenceDoes() + { + WireframeContainer container = CreateContainer(); + // Turned a quarter around Z and scaled 2, so local +Y runs toward world -X, twice as long. + Transform bone = CreateBone(new Vector3(2f, 0f, 0f), Quaternion.Euler(0f, 0f, 90f), 2f); + ILine skinned = container.CreateLine(bone, bone); + skinned.LocalPositionB = new Vector3(0f, 1f, 0f); + skinned.SetColor(Color.red); + ILine world = container.CreateLine(new Vector3(-3f, 2f, 0f), new Vector3(-1f, 2f, 0f)); + world.SetColor(Color.red); + + Vector3[] vertices = BakeShape(container, skinned); + AssertApproximately(new Vector3(2f, 0f, 0f), vertices[0]); + AssertApproximately(Vector3.zero, vertices[1]); + Render(); + + Assert.That(IsLit(Vector3.Lerp(vertices[0], vertices[1], 0.5f)), Is.True, "The skinned line isn't drawn."); + Assert.That(IsLit(new Vector3(-2f, 2f, 0f)), Is.True, "The world-space line isn't drawn."); + Assert.That(IsLit(new Vector3(-2f, 0f, 0f)), Is.False, "Something is drawn where nothing should be."); + Assert.That(IsLit(new Vector3(1f, -2f, 0f)), Is.False, "Something is drawn where nothing should be."); + } + + [Test] + public void MovingABone_MovesItsShapesWithoutAnyEdit() + { + WireframeContainer container = CreateContainer(); + Transform bone = CreateBone(new Vector3(0f, 2f, 0f), Quaternion.identity); + ILine line = container.CreateLine(bone, bone); + line.LocalPositionA = new Vector3(-1f, 0f, 0f); + line.LocalPositionB = new Vector3(1f, 0f, 0f); + line.SetColor(Color.red); + Render(); + Assert.That(IsLit(new Vector3(0f, 2f, 0f)), Is.True); + + bone.position = new Vector3(0f, -2f, 0f); + Render(); + + Assert.That(IsLit(new Vector3(0f, -2f, 0f)), Is.True); + Assert.That(IsLit(new Vector3(0f, 2f, 0f)), Is.False); + } + + [UnityTest] + public IEnumerator DestroyedBone_KeepsDrawingItsShapesInPlace() + { + WireframeContainer container = CreateContainer(); + Transform bone = CreateBone(new Vector3(0f, -2f, 0f), Quaternion.identity); + ICircle circle = container.CreateCircle(bone, Vector3.zero, 1f); + circle.LocalRotation = Quaternion.Euler(-90f, 0f, 0f); + circle.Color = Color.red; + Render(); + + Object.Destroy(bone.gameObject); + yield return null; + Render(); + + // The circle faces the camera, so its rim passes through these points. + Assert.That(IsLit(new Vector3(1f, -2f, 0f)), Is.True); + Assert.That(IsLit(new Vector3(-1f, -2f, 0f)), Is.True); + Assert.That(IsLit(new Vector3(0f, 2f, 0f)), Is.False); + } + + private void Render() + { + _camera.Render(); + RenderTexture previous = RenderTexture.active; + RenderTexture.active = _target; + _readback.ReadPixels(new Rect(0f, 0f, Size, Size), 0, 0); + _readback.Apply(); + RenderTexture.active = previous; + } + + /// True when any pixel around is red, allowing a pixel for line rasterization. + private bool IsLit(Vector3 world) + { + int x = Mathf.RoundToInt((world.x + HalfExtent) / (HalfExtent * 2f) * Size); + int y = Mathf.RoundToInt((world.y + HalfExtent) / (HalfExtent * 2f) * Size); + for (int dy = -1; dy <= 1; dy++) + { + for (int dx = -1; dx <= 1; dx++) + { + int px = Mathf.Clamp(x + dx, 0, Size - 1); + int py = Mathf.Clamp(y + dy, 0, Size - 1); + if (_readback.GetPixel(px, py).r > 0.25f) + { + return true; + } + } + } + return false; + } + } +} diff --git a/UnityPackage/Tests/Runtime/ShaderTests.cs.meta b/UnityPackage/Tests/Runtime/ShaderTests.cs.meta new file mode 100644 index 0000000..ecdd95f --- /dev/null +++ b/UnityPackage/Tests/Runtime/ShaderTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: cdc57b58a300117640baeee7ec3f61a8 \ No newline at end of file diff --git a/UnityPackage/Tests/Runtime/WireframeContainerTests.cs b/UnityPackage/Tests/Runtime/WireframeContainerTests.cs index 445eda7..6953349 100644 --- a/UnityPackage/Tests/Runtime/WireframeContainerTests.cs +++ b/UnityPackage/Tests/Runtime/WireframeContainerTests.cs @@ -13,7 +13,7 @@ namespace reromanlee.Wireframes.Tests { public class WireframeContainerTests : WireframesTestBase { - private const string ShaderName = "reromanlee/Wireframes/VertexColors"; + private const string ShaderName = "reromanlee/Wireframes/Unlit"; [Test] public void Constructor_CreatesProxyInActiveSceneWithPackageShader() @@ -229,10 +229,13 @@ private static string Describe(MethodInfo method) [UnityTest] public IEnumerator Rendering_LogsNoErrorsWhileBuffersGrow() { - // The test framework fails a test on any logged error, e.g. a bone/bind pose mismatch while rendering. - // The shapes added here outgrow 16-bit indices, so the index format switches while a camera renders. + // The test framework fails a test on any logged error while rendering. The shapes added here outgrow + // 16-bit indices, so the index format switches while a camera renders. Batch mode draws no screen cameras, + // so this one renders into a texture, which is also what applies the edits. + RenderTexture target = Track(new RenderTexture(64, 64, 24)); Camera camera = Track(new GameObject("Camera")).AddComponent(); camera.transform.position = new Vector3(0f, 0f, -20f); + camera.targetTexture = target; WireframeContainer container = CreateContainer(); Transform bone = CreateBone(Vector3.zero, Quaternion.identity); @@ -251,6 +254,7 @@ public IEnumerator Rendering_LogsNoErrorsWhileBuffersGrow() } } bone.position += Vector3.right; + camera.Render(); yield return null; } diff --git a/UnityPackage/Tests/Runtime/WireframesTestBase.cs b/UnityPackage/Tests/Runtime/WireframesTestBase.cs index 504adfd..6ee8b7e 100644 --- a/UnityPackage/Tests/Runtime/WireframesTestBase.cs +++ b/UnityPackage/Tests/Runtime/WireframesTestBase.cs @@ -59,14 +59,22 @@ private protected static MeshChunk ChunkOf(WireframeContainer container) return container.Proxy.Chunks[0]; } - /// Applies pending edits, then skins the mesh on the CPU and returns world-space vertices. + /// + /// Applies pending edits and reads the bones, then skins the uploaded vertices on the CPU with the matrices the + /// shader receives, and returns world-space vertices. ShaderTests checks that the GPU draws the same. + /// protected static Vector3[] FlushAndBake(WireframeContainer container) { container.Proxy.Flush(); - Mesh baked = new(); - ChunkOf(container).Renderer.BakeMesh(baked); - Vector3[] vertices = baked.vertices; - Object.Destroy(baked); + MeshChunk chunk = ChunkOf(container); + BoneRegistry bones = container.Proxy.Bones; + Vector3[] positions = chunk.Positions; + float[] boneIndices = chunk.BoneIndices; + Vector3[] vertices = new Vector3[positions.Length]; + for (int i = 0; i < vertices.Length; i++) + { + vertices[i] = bones.MatrixOf((int)boneIndices[i]).MultiplyPoint3x4(positions[i]); + } return vertices; } From 578db54dd9b5840cda7094b1c3a59fa711d28154 Mon Sep 17 00:00:00 2001 From: Roman Likhadievski Date: Tue, 29 Sep 2026 15:22:13 +0300 Subject: [PATCH 04/13] feature: occlusion modes --- .../Runtime/Container/WireframeContainer.cs | 2 +- .../Container/WireframeContainerSettings.cs | 31 +++++++ .../Runtime/Container/WireframeOcclusion.cs | 15 ++++ .../Container/WireframeOcclusion.cs.meta | 2 + UnityPackage/Runtime/Rendering/MeshChunk.cs | 13 ++- UnityPackage/Runtime/Rendering/MeshProxy.cs | 27 +++--- .../Runtime/Rendering/WireframeMaterials.cs | 79 +++++++++++++++++ .../Rendering/WireframeMaterials.cs.meta | 2 + UnityPackage/Tests/Runtime/OcclusionTests.cs | 87 +++++++++++++++++++ .../Tests/Runtime/OcclusionTests.cs.meta | 2 + UnityPackage/Tests/Runtime/ShaderTests.cs | 78 +++++++++++++++-- .../Tests/Runtime/WireframesTestBase.cs | 8 +- 12 files changed, 322 insertions(+), 24 deletions(-) create mode 100644 UnityPackage/Runtime/Container/WireframeOcclusion.cs create mode 100644 UnityPackage/Runtime/Container/WireframeOcclusion.cs.meta create mode 100644 UnityPackage/Runtime/Rendering/WireframeMaterials.cs create mode 100644 UnityPackage/Runtime/Rendering/WireframeMaterials.cs.meta create mode 100644 UnityPackage/Tests/Runtime/OcclusionTests.cs create mode 100644 UnityPackage/Tests/Runtime/OcclusionTests.cs.meta diff --git a/UnityPackage/Runtime/Container/WireframeContainer.cs b/UnityPackage/Runtime/Container/WireframeContainer.cs index 1d60474..1a540dd 100644 --- a/UnityPackage/Runtime/Container/WireframeContainer.cs +++ b/UnityPackage/Runtime/Container/WireframeContainer.cs @@ -11,7 +11,7 @@ namespace reromanlee.Wireframes /// /// Main thread only. Each shape type adds its Create methods as extension methods, declared in a factory class next /// to the shape, such as . The container creates a GameObject in the active scene; edits - /// made to its shapes are uploaded once per frame at the end of LateUpdate. + /// made to its shapes are uploaded right before a camera renders them. /// public sealed class WireframeContainer : IDisposable { diff --git a/UnityPackage/Runtime/Container/WireframeContainerSettings.cs b/UnityPackage/Runtime/Container/WireframeContainerSettings.cs index 771d68b..2a4896a 100644 --- a/UnityPackage/Runtime/Container/WireframeContainerSettings.cs +++ b/UnityPackage/Runtime/Container/WireframeContainerSettings.cs @@ -30,6 +30,12 @@ public sealed class WireframeContainerSettings [Tooltip("Edges reserved up front, so a load known in advance never grows the buffers later.")] [SerializeField, Min(0)] private int _edgeCapacity; + [Tooltip("What is drawn of lines that other geometry hides. Ignored with a custom material.")] + [SerializeField] private WireframeOcclusion _occlusion = WireframeOcclusion.Hide; + + [Tooltip("Blends each color by its alpha. Ignored with a custom material.")] + [SerializeField] private bool _useAlpha; + /// Name of the container's GameObject in the Hierarchy. Null or empty uses "Wireframes". public string Name { @@ -81,6 +87,26 @@ public int EdgeCapacity set => _edgeCapacity = value; } + /// + /// What is drawn of lines that other geometry hides: nothing (the default), everything, or a dimmer line. It + /// applies to the package's material and is ignored with a custom . + /// + public WireframeOcclusion Occlusion + { + get => _occlusion; + set => _occlusion = value; + } + + /// + /// True blends each color by its alpha, drawing the lines as transparent; false, the default, draws them opaque. + /// It applies to the package's material and is ignored with a custom . + /// + public bool UseAlpha + { + get => _useAlpha; + set => _useAlpha = value; + } + internal string ResolvedName { get => string.IsNullOrEmpty(_name) ? DefaultName : _name; @@ -104,6 +130,11 @@ internal void Validate(string parameterName) throw new ArgumentOutOfRangeException( parameterName, _edgeCapacity, $"{nameof(EdgeCapacity)} can't be negative."); } + if (_occlusion < WireframeOcclusion.Hide || _occlusion > WireframeOcclusion.Fade) + { + throw new ArgumentOutOfRangeException( + parameterName, _occlusion, $"{nameof(Occlusion)} isn't a {nameof(WireframeOcclusion)} value."); + } } } } diff --git a/UnityPackage/Runtime/Container/WireframeOcclusion.cs b/UnityPackage/Runtime/Container/WireframeOcclusion.cs new file mode 100644 index 0000000..c129ae9 --- /dev/null +++ b/UnityPackage/Runtime/Container/WireframeOcclusion.cs @@ -0,0 +1,15 @@ +namespace reromanlee.Wireframes +{ + /// What a container draws of lines that other geometry hides. + public enum WireframeOcclusion + { + /// Hidden parts are not drawn, like any other object. + Hide, + + /// Lines are drawn over everything, so hidden parts show at full strength. + Show, + + /// Hidden parts are drawn dimmer, like the Scene view's handles. It takes a second draw call. + Fade + } +} diff --git a/UnityPackage/Runtime/Container/WireframeOcclusion.cs.meta b/UnityPackage/Runtime/Container/WireframeOcclusion.cs.meta new file mode 100644 index 0000000..63dc497 --- /dev/null +++ b/UnityPackage/Runtime/Container/WireframeOcclusion.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f1a6c8c469a6e13906618b5a616c11b1 \ No newline at end of file diff --git a/UnityPackage/Runtime/Rendering/MeshChunk.cs b/UnityPackage/Runtime/Rendering/MeshChunk.cs index 4810b2b..9931e85 100644 --- a/UnityPackage/Runtime/Rendering/MeshChunk.cs +++ b/UnityPackage/Runtime/Rendering/MeshChunk.cs @@ -64,7 +64,10 @@ internal sealed class MeshChunk : IDisposable private IndexFormat _meshIndexFormat; private int _meshEdgeCount = -1; - internal MeshChunk(Transform parent, Material material, WireframeContainerSettings settings, BoneRegistry bones) + /// + /// Materials the mesh is drawn with, each drawing all of it; the chunk leaves them to their owner. + /// + internal MeshChunk(Transform parent, Material[] materials, WireframeContainerSettings settings, BoneRegistry bones) { // Reserved before anything is created, so a device limit leaves nothing behind. EnsureVertexCapacity(settings.VertexCapacity); @@ -81,7 +84,8 @@ internal MeshChunk(Transform parent, Material material, WireframeContainerSettin chunkObject.AddComponent().sharedMesh = _mesh; _renderer = chunkObject.AddComponent(); - _renderer.sharedMaterial = material; + // A renderer with more materials than sub-meshes draws its last sub-mesh once per extra material. + _renderer.sharedMaterials = materials; _renderer.motionVectorGenerationMode = MotionVectorGenerationMode.ForceNoMotion; _renderer.shadowCastingMode = ShadowCastingMode.Off; _renderer.receiveShadows = false; @@ -216,6 +220,11 @@ public void Dispose() _shapeCount = 0; Array.Clear(_pending, 0, _pendingCount); _pendingCount = 0; + // Play Mode destroys objects at the end of the frame, and a render before that must not draw the chunk. + if (_renderer != null) + { + _renderer.enabled = false; + } // The chunk's GameObject is a child of the proxy and goes away with it; the mesh is an asset and doesn't. UnityObjects.Destroy(_mesh); } diff --git a/UnityPackage/Runtime/Rendering/MeshProxy.cs b/UnityPackage/Runtime/Rendering/MeshProxy.cs index f63ddab..bab1d86 100644 --- a/UnityPackage/Runtime/Rendering/MeshProxy.cs +++ b/UnityPackage/Runtime/Rendering/MeshProxy.cs @@ -14,15 +14,13 @@ namespace reromanlee.Wireframes [DisallowMultipleComponent] internal sealed class MeshProxy : MonoBehaviour { - private const string ShaderName = "reromanlee/Wireframes/Unlit"; - private readonly List _chunks = new(); private readonly BoneRegistry _bones = new(); private readonly BoneTexture _boneTexture = new(); private MaterialPropertyBlock _propertyBlock; private WireframeContainer _container; - private Material _material; - private bool _ownsMaterial; + private Material[] _materials; + private bool _ownsMaterials; private bool _isShutDown; internal IReadOnlyList Chunks @@ -45,21 +43,21 @@ internal void Initialize(WireframeContainer container, WireframeContainerSetting _container = container; if (settings.Material != null) { - _material = settings.Material; + _materials = new[] { settings.Material }; } else { // The shader sits in a Resources folder, which keeps it in player builds. - Shader shader = Shader.Find(ShaderName); + Shader shader = Shader.Find(WireframeMaterials.ShaderName); if (shader == null) { - throw new InvalidOperationException($"Shader '{ShaderName}' was not found."); + throw new InvalidOperationException($"Shader '{WireframeMaterials.ShaderName}' was not found."); } - _material = new Material(shader) { name = ShaderName, hideFlags = HideFlags.DontSave }; - _ownsMaterial = true; + _materials = WireframeMaterials.Create(shader, settings.Occlusion, settings.UseAlpha); + _ownsMaterials = true; } _propertyBlock = new MaterialPropertyBlock(); - _chunks.Add(new MeshChunk(transform, _material, settings, _bones)); + _chunks.Add(new MeshChunk(transform, _materials, settings, _bones)); // Uploads the bone texture and hands it to the renderers. Flush(); } @@ -104,11 +102,14 @@ internal void Shutdown() } _chunks.Clear(); _boneTexture.Dispose(); - if (_ownsMaterial) + if (_ownsMaterials) { - UnityObjects.Destroy(_material); + foreach (Material material in _materials) + { + UnityObjects.Destroy(material); + } } - _material = null; + _materials = null; if (_container != null) { _container.OnProxyShutdown(); diff --git a/UnityPackage/Runtime/Rendering/WireframeMaterials.cs b/UnityPackage/Runtime/Rendering/WireframeMaterials.cs new file mode 100644 index 0000000..d047b06 --- /dev/null +++ b/UnityPackage/Runtime/Rendering/WireframeMaterials.cs @@ -0,0 +1,79 @@ +using UnityEngine; +using UnityEngine.Rendering; + +namespace reromanlee.Wireframes +{ + /// + /// Builds the package's materials for a container's settings. Every look is the same shader with a different depth + /// test and blending; with , a second material draws the same mesh again, only + /// where other geometry hides it, and dimmer. + /// + internal static class WireframeMaterials + { + internal const string ShaderName = "reromanlee/Wireframes/Unlit"; + + // Share of a color's alpha that hidden parts keep with Fade. + private const float FadedAlpha = 0.3f; + + private static readonly int DepthTestId = Shader.PropertyToID("_ZTest"); + private static readonly int DepthWriteId = Shader.PropertyToID("_ZWrite"); + private static readonly int SourceBlendId = Shader.PropertyToID("_SrcBlend"); + private static readonly int DestinationBlendId = Shader.PropertyToID("_DstBlend"); + private static readonly int VertexAlphaId = Shader.PropertyToID("_WireframesVertexAlpha"); + private static readonly int AlphaScaleId = Shader.PropertyToID("_WireframesAlphaScale"); + + /// The renderer's materials: the lines, then with Fade the dimmed hidden parts. The caller owns them. + internal static Material[] Create(Shader shader, WireframeOcclusion occlusion, bool useAlpha) + { + Material lines = CreateMaterial(shader, "Wireframes"); + if (occlusion == WireframeOcclusion.Show) + { + // Drawn after the scene's opaque geometry, over all of it. + SetState(lines, CompareFunction.Always, false, useAlpha); + SetQueue(lines, RenderQueue.Transparent); + } + else + { + SetState(lines, CompareFunction.LessEqual, !useAlpha, useAlpha); + SetQueue(lines, useAlpha ? RenderQueue.Transparent : RenderQueue.Geometry); + } + SetAlpha(lines, useAlpha, 1f); + if (occlusion != WireframeOcclusion.Fade) + { + return new[] { lines }; + } + + // Only where the depth buffer already holds something closer: the parts that geometry hides. + Material hidden = CreateMaterial(shader, "Wireframes Hidden"); + SetState(hidden, CompareFunction.Greater, false, true); + SetQueue(hidden, RenderQueue.Transparent); + SetAlpha(hidden, useAlpha, FadedAlpha); + return new[] { lines, hidden }; + } + + private static Material CreateMaterial(Shader shader, string name) + { + return new Material(shader) { name = name, hideFlags = HideFlags.DontSave }; + } + + private static void SetState(Material material, CompareFunction depthTest, bool depthWrite, bool blend) + { + material.SetFloat(DepthTestId, (float)depthTest); + material.SetFloat(DepthWriteId, depthWrite ? 1f : 0f); + material.SetFloat(SourceBlendId, (float)(blend ? BlendMode.SrcAlpha : BlendMode.One)); + material.SetFloat(DestinationBlendId, (float)(blend ? BlendMode.OneMinusSrcAlpha : BlendMode.Zero)); + } + + private static void SetQueue(Material material, RenderQueue queue) + { + material.renderQueue = (int)queue; + material.SetOverrideTag("RenderType", queue == RenderQueue.Geometry ? "Opaque" : "Transparent"); + } + + private static void SetAlpha(Material material, bool useVertexAlpha, float scale) + { + material.SetFloat(VertexAlphaId, useVertexAlpha ? 1f : 0f); + material.SetFloat(AlphaScaleId, scale); + } + } +} diff --git a/UnityPackage/Runtime/Rendering/WireframeMaterials.cs.meta b/UnityPackage/Runtime/Rendering/WireframeMaterials.cs.meta new file mode 100644 index 0000000..a0533c0 --- /dev/null +++ b/UnityPackage/Runtime/Rendering/WireframeMaterials.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 543a46b730814377e0dddbe0737e9247 \ No newline at end of file diff --git a/UnityPackage/Tests/Runtime/OcclusionTests.cs b/UnityPackage/Tests/Runtime/OcclusionTests.cs new file mode 100644 index 0000000..16dd4cc --- /dev/null +++ b/UnityPackage/Tests/Runtime/OcclusionTests.cs @@ -0,0 +1,87 @@ +using System; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.Rendering; + +namespace reromanlee.Wireframes.Tests +{ + /// The materials a container builds for its occlusion and alpha settings. ShaderTests checks the pixels. + public class OcclusionTests : WireframesTestBase + { + [Test] + public void Default_IsOneOpaqueDepthTestedMaterial() + { + Material[] materials = MaterialsOf(new WireframeContainerSettings()); + + Assert.That(materials, Has.Length.EqualTo(1)); + AssertState(materials[0], CompareFunction.LessEqual, true, BlendMode.One, BlendMode.Zero); + Assert.That(materials[0].renderQueue, Is.EqualTo((int)RenderQueue.Geometry)); + Assert.That(materials[0].GetFloat("_WireframesVertexAlpha"), Is.Zero); + } + + [Test] + public void UseAlpha_BlendsByVertexAlphaWithoutWritingDepth() + { + Material[] materials = MaterialsOf(new WireframeContainerSettings { UseAlpha = true }); + + AssertState(materials[0], CompareFunction.LessEqual, false, BlendMode.SrcAlpha, BlendMode.OneMinusSrcAlpha); + Assert.That(materials[0].renderQueue, Is.EqualTo((int)RenderQueue.Transparent)); + Assert.That(materials[0].GetFloat("_WireframesVertexAlpha"), Is.EqualTo(1f)); + } + + [Test] + public void Show_DrawsOverEverythingAfterOpaqueGeometry() + { + Material[] materials = MaterialsOf(new WireframeContainerSettings { Occlusion = WireframeOcclusion.Show }); + + Assert.That(materials, Has.Length.EqualTo(1)); + AssertState(materials[0], CompareFunction.Always, false, BlendMode.One, BlendMode.Zero); + Assert.That(materials[0].renderQueue, Is.EqualTo((int)RenderQueue.Transparent)); + } + + [Test] + public void Fade_AddsASecondDimmedMaterialForHiddenParts() + { + Material[] materials = MaterialsOf(new WireframeContainerSettings { Occlusion = WireframeOcclusion.Fade }); + + Assert.That(materials, Has.Length.EqualTo(2)); + AssertState(materials[0], CompareFunction.LessEqual, true, BlendMode.One, BlendMode.Zero); + AssertState(materials[1], CompareFunction.Greater, false, BlendMode.SrcAlpha, BlendMode.OneMinusSrcAlpha); + Assert.That(materials[1].GetFloat("_WireframesAlphaScale"), Is.LessThan(1f)); + Assert.That(materials[1].renderQueue, Is.GreaterThan(materials[0].renderQueue)); + } + + [Test] + public void CustomMaterial_IsUsedAsIsWhateverTheOcclusion() + { + Material custom = Track(new Material(Shader.Find(WireframeMaterials.ShaderName))); + + Material[] materials = MaterialsOf( + new WireframeContainerSettings { Material = custom, Occlusion = WireframeOcclusion.Fade, UseAlpha = true }); + + Assert.That(materials, Has.Length.EqualTo(1)); + Assert.That(materials[0], Is.SameAs(custom)); + } + + [Test] + public void UndefinedOcclusion_Throws() + { + Assert.Throws( + () => CreateContainer(new WireframeContainerSettings { Occlusion = (WireframeOcclusion)7 })); + } + + private Material[] MaterialsOf(WireframeContainerSettings settings) + { + return ChunkOf(CreateContainer(settings)).Renderer.sharedMaterials; + } + + private static void AssertState( + Material material, CompareFunction depthTest, bool depthWrite, BlendMode source, BlendMode destination) + { + Assert.That(material.GetFloat("_ZTest"), Is.EqualTo((float)depthTest), "Depth test"); + Assert.That(material.GetFloat("_ZWrite"), Is.EqualTo(depthWrite ? 1f : 0f), "Depth write"); + Assert.That(material.GetFloat("_SrcBlend"), Is.EqualTo((float)source), "Source blend"); + Assert.That(material.GetFloat("_DstBlend"), Is.EqualTo((float)destination), "Destination blend"); + } + } +} diff --git a/UnityPackage/Tests/Runtime/OcclusionTests.cs.meta b/UnityPackage/Tests/Runtime/OcclusionTests.cs.meta new file mode 100644 index 0000000..f22d569 --- /dev/null +++ b/UnityPackage/Tests/Runtime/OcclusionTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 320970207f8ab2fdbdb3260ad688af86 \ No newline at end of file diff --git a/UnityPackage/Tests/Runtime/ShaderTests.cs b/UnityPackage/Tests/Runtime/ShaderTests.cs index f21e79c..bb4f602 100644 --- a/UnityPackage/Tests/Runtime/ShaderTests.cs +++ b/UnityPackage/Tests/Runtime/ShaderTests.cs @@ -100,6 +100,70 @@ public IEnumerator DestroyedBone_KeepsDrawingItsShapesInPlace() Assert.That(IsLit(new Vector3(0f, 2f, 0f)), Is.False); } + [Test] + public void Dispose_StopsDrawingRightAway() + { + WireframeContainer container = CreateContainer(); + container.CreateLine(new Vector3(-2f, 0f, 0f), new Vector3(2f, 0f, 0f)).SetColor(Color.red); + Render(); + Assert.That(IsLit(Vector3.zero), Is.True); + + container.Dispose(); + // Still the same frame, before Unity destroys anything. + Render(); + + Assert.That(IsLit(Vector3.zero), Is.False); + } + + [Test] + public void OcclusionHide_HidesLinesBehindGeometry() + { + Assert.That(RedBehindAWall(WireframeOcclusion.Hide), Is.LessThan(0.05f)); + } + + [Test] + public void OcclusionShow_DrawsHiddenLinesAtFullStrength() + { + Assert.That(RedBehindAWall(WireframeOcclusion.Show), Is.GreaterThan(0.9f)); + } + + [Test] + public void OcclusionFade_DrawsHiddenLinesDimmerAndVisibleOnesFully() + { + float hidden = RedBehindAWall(WireframeOcclusion.Fade); + + // Faded in linear or gamma space, depending on the project's color space. + Assert.That(hidden, Is.GreaterThan(0.15f).And.LessThan(0.8f)); + Assert.That(RedAround(new Vector3(1.8f, 0f, 0f)), Is.GreaterThan(0.9f)); + } + + [Test] + public void UseAlpha_BlendsLinesByTheirColorsAlpha() + { + Color halfRed = new(1f, 0f, 0f, 0.5f); + CreateContainer(new WireframeContainerSettings { UseAlpha = true }) + .CreateLine(new Vector3(-3f, 2f, 0f), new Vector3(3f, 2f, 0f)).SetColor(halfRed); + CreateContainer().CreateLine(new Vector3(-3f, -2f, 0f), new Vector3(3f, -2f, 0f)).SetColor(halfRed); + Render(); + + // Half of the red over black, blended in linear or gamma space depending on the project's color space. + Assert.That(RedAround(new Vector3(0f, 2f, 0f)), Is.GreaterThan(0.3f).And.LessThan(0.8f)); + Assert.That(RedAround(new Vector3(0f, -2f, 0f)), Is.GreaterThan(0.9f)); + } + + /// Draws a red line with part of it behind a blue wall, and returns the red seen through the wall. + private float RedBehindAWall(WireframeOcclusion occlusion) + { + WireframeContainer container = CreateContainer(new WireframeContainerSettings { Occlusion = occlusion }); + container.CreateLine(new Vector3(-2f, 0f, 0f), new Vector3(2f, 0f, 0f)).SetColor(Color.red); + GameObject wall = Track(GameObject.CreatePrimitive(PrimitiveType.Cube)); + wall.transform.position = new Vector3(0f, 0f, -2f); + wall.transform.localScale = new Vector3(3f, 3f, 0.5f); + wall.GetComponent().sharedMaterial = Track(new Material(Shader.Find("Unlit/Color")) { color = Color.blue }); + Render(); + return RedAround(Vector3.zero); + } + private void Render() { _camera.Render(); @@ -112,22 +176,26 @@ private void Render() /// True when any pixel around is red, allowing a pixel for line rasterization. private bool IsLit(Vector3 world) + { + return RedAround(world) > 0.25f; + } + + /// The most red among the pixels around . + private float RedAround(Vector3 world) { int x = Mathf.RoundToInt((world.x + HalfExtent) / (HalfExtent * 2f) * Size); int y = Mathf.RoundToInt((world.y + HalfExtent) / (HalfExtent * 2f) * Size); + float red = 0f; for (int dy = -1; dy <= 1; dy++) { for (int dx = -1; dx <= 1; dx++) { int px = Mathf.Clamp(x + dx, 0, Size - 1); int py = Mathf.Clamp(y + dy, 0, Size - 1); - if (_readback.GetPixel(px, py).r > 0.25f) - { - return true; - } + red = Mathf.Max(red, _readback.GetPixel(px, py).r); } } - return false; + return red; } } } diff --git a/UnityPackage/Tests/Runtime/WireframesTestBase.cs b/UnityPackage/Tests/Runtime/WireframesTestBase.cs index 6ee8b7e..7defd73 100644 --- a/UnityPackage/Tests/Runtime/WireframesTestBase.cs +++ b/UnityPackage/Tests/Runtime/WireframesTestBase.cs @@ -20,11 +20,13 @@ public void DestroyTestObjects() { container.Dispose(); } - foreach (Object target in _objects) + // Right away, because tests without a yield share one frame and would otherwise see each other's objects, + // and newest first, so nothing is destroyed while an object created after it still uses it. + for (int i = _objects.Count - 1; i >= 0; i--) { - if (target != null) + if (_objects[i] != null) { - Object.Destroy(target); + Object.DestroyImmediate(_objects[i]); } } _containers.Clear(); From c004218c339b7c35b4b296715bd6fc992ca1fc68 Mon Sep 17 00:00:00 2001 From: Roman Likhadievski Date: Tue, 29 Sep 2026 15:36:33 +0300 Subject: [PATCH 05/13] feature: chunked storage --- .../Runtime/Container/WireframeContainer.cs | 4 +- .../Container/WireframeContainerSettings.cs | 10 +- UnityPackage/Runtime/Core/EdgeList.cs | 36 ++- UnityPackage/Runtime/Core/Shape.cs | 6 + .../Runtime/Rendering/ChunkAllocator.cs | 209 ++++++++++++++++++ .../Runtime/Rendering/ChunkAllocator.cs.meta | 2 + UnityPackage/Runtime/Rendering/MeshChunk.cs | 162 ++++++++++---- UnityPackage/Runtime/Rendering/MeshProxy.cs | 47 ++-- UnityPackage/Tests/Editor/EdgeListTests.cs | 28 ++- UnityPackage/Tests/Runtime/CapsuleTests.cs | 2 +- .../Tests/Runtime/ChunkAllocatorTests.cs | 198 +++++++++++++++++ .../Tests/Runtime/ChunkAllocatorTests.cs.meta | 2 + UnityPackage/Tests/Runtime/CircleTests.cs | 2 +- UnityPackage/Tests/Runtime/ConeTests.cs | 2 +- UnityPackage/Tests/Runtime/CylinderTests.cs | 2 +- UnityPackage/Tests/Runtime/EllipseTests.cs | 2 +- UnityPackage/Tests/Runtime/EllipsoidTests.cs | 2 +- UnityPackage/Tests/Runtime/FrustumTests.cs | 2 +- UnityPackage/Tests/Runtime/LineTests.cs | 6 +- UnityPackage/Tests/Runtime/MeshBufferTests.cs | 43 ++-- UnityPackage/Tests/Runtime/OcclusionTests.cs | 2 +- .../Tests/Runtime/PerformanceTests.cs | 4 +- UnityPackage/Tests/Runtime/PolylineTests.cs | 6 +- .../Tests/Runtime/RoundedRectangleTests.cs | 2 +- UnityPackage/Tests/Runtime/ShaderTests.cs | 31 +++ UnityPackage/Tests/Runtime/SphereTests.cs | 2 +- .../Tests/Runtime/SpikedSphereTests.cs | 2 +- UnityPackage/Tests/Runtime/StadiumTests.cs | 2 +- UnityPackage/Tests/Runtime/StarTests.cs | 2 +- .../Tests/Runtime/WireframeContainerTests.cs | 32 ++- .../Tests/Runtime/WireframesTestBase.cs | 31 ++- 31 files changed, 756 insertions(+), 127 deletions(-) create mode 100644 UnityPackage/Runtime/Rendering/ChunkAllocator.cs create mode 100644 UnityPackage/Runtime/Rendering/ChunkAllocator.cs.meta create mode 100644 UnityPackage/Tests/Runtime/ChunkAllocatorTests.cs create mode 100644 UnityPackage/Tests/Runtime/ChunkAllocatorTests.cs.meta diff --git a/UnityPackage/Runtime/Container/WireframeContainer.cs b/UnityPackage/Runtime/Container/WireframeContainer.cs index 1a540dd..c8ccd09 100644 --- a/UnityPackage/Runtime/Container/WireframeContainer.cs +++ b/UnityPackage/Runtime/Container/WireframeContainer.cs @@ -5,8 +5,8 @@ namespace reromanlee.Wireframes { /// - /// Creates shapes and draws all of them with one GPU-skinned mesh. Disposing the container, or unloading the - /// scene it was created in, disposes every shape it created. + /// Creates shapes and draws them with GPU-skinned meshes of up to 65,535 vertices each, plus one of its own for any + /// bigger shape. Disposing the container, or unloading the scene it was created in, disposes every shape it created. /// /// /// Main thread only. Each shape type adds its Create methods as extension methods, declared in a factory class next diff --git a/UnityPackage/Runtime/Container/WireframeContainerSettings.cs b/UnityPackage/Runtime/Container/WireframeContainerSettings.cs index 2a4896a..832d25f 100644 --- a/UnityPackage/Runtime/Container/WireframeContainerSettings.cs +++ b/UnityPackage/Runtime/Container/WireframeContainerSettings.cs @@ -73,14 +73,20 @@ public bool PersistAcrossScenes set => _persistAcrossScenes = value; } - /// Vertices reserved up front, 0 or more, so a load known in advance never grows the buffers later. + /// + /// Vertices reserved up front, 0 or more, so a load known in advance never grows the buffers later. Reserved + /// memory stays until the container is disposed; memory past it is given back as shapes are disposed. + /// public int VertexCapacity { get => _vertexCapacity; set => _vertexCapacity = value; } - /// Edges reserved up front, 0 or more, so a load known in advance never grows the buffers later. + /// + /// Edges reserved up front, 0 or more, so a load known in advance never grows the buffers later. Reserved memory + /// stays until the container is disposed; memory past it is given back as shapes are disposed. + /// public int EdgeCapacity { get => _edgeCapacity; diff --git a/UnityPackage/Runtime/Core/EdgeList.cs b/UnityPackage/Runtime/Core/EdgeList.cs index 2731d4e..6025e6b 100644 --- a/UnityPackage/Runtime/Core/EdgeList.cs +++ b/UnityPackage/Runtime/Core/EdgeList.cs @@ -8,11 +8,16 @@ namespace reromanlee.Wireframes /// internal sealed class EdgeList { - private const int InitialCapacity = 128; + private int[] _indices; + private IEdgeOwner[] _owners; + private int[] _ownerEdges; - private int[] _indices = new int[InitialCapacity * 2]; - private IEdgeOwner[] _owners = new IEdgeOwner[InitialCapacity]; - private int[] _ownerEdges = new int[InitialCapacity]; + internal EdgeList(int capacity) + { + _indices = new int[capacity * 2]; + _owners = new IEdgeOwner[capacity]; + _ownerEdges = new int[capacity]; + } internal int Count { get; private set; } @@ -33,14 +38,22 @@ internal void EnsureCapacity(int capacity) { return; } - int newCapacity = _owners.Length; + int newCapacity = Math.Max(_owners.Length, 1); while (newCapacity < capacity) { newCapacity *= 2; } - Array.Resize(ref _indices, newCapacity * 2); - Array.Resize(ref _owners, newCapacity); - Array.Resize(ref _ownerEdges, newCapacity); + Resize(newCapacity); + } + + /// Lowers the capacity to , but never below the edges it holds. + internal void Shrink(int capacity) + { + capacity = Math.Max(capacity, Count); + if (capacity < _owners.Length) + { + Resize(capacity); + } } /// Appends an edge of and returns its slot. Its indices are set later. @@ -77,5 +90,12 @@ internal bool RemoveAt(int slot) _owners[last] = null; return moved; } + + private void Resize(int capacity) + { + Array.Resize(ref _indices, capacity * 2); + Array.Resize(ref _owners, capacity); + Array.Resize(ref _ownerEdges, capacity); + } } } diff --git a/UnityPackage/Runtime/Core/Shape.cs b/UnityPackage/Runtime/Core/Shape.cs index a467a93..04d1faf 100644 --- a/UnityPackage/Runtime/Core/Shape.cs +++ b/UnityPackage/Runtime/Core/Shape.cs @@ -29,6 +29,12 @@ public bool IsDisposed get => _chunk == null; } + /// The chunk holding the shape, or null once it is disposed. + internal MeshChunk Chunk + { + get => _chunk; + } + internal int VertexCount { get; } internal int VertexStart { get; set; } diff --git a/UnityPackage/Runtime/Rendering/ChunkAllocator.cs b/UnityPackage/Runtime/Rendering/ChunkAllocator.cs new file mode 100644 index 0000000..da9804d --- /dev/null +++ b/UnityPackage/Runtime/Rendering/ChunkAllocator.cs @@ -0,0 +1,209 @@ +using System; +using System.Collections.Generic; +using UnityEngine; + +namespace reromanlee.Wireframes +{ + /// + /// A container's chunks. Every new shape goes where room already exists, else into a chunk that can grow, else + /// into a new chunk, so a buffer only grows or appears when the others are full; a shape too big for any chunk + /// gets a large chunk of its own. Chunks reserved by the capacity settings stay for the container's lifetime, and + /// the others are released after staying empty for seconds. + /// + internal sealed class ChunkAllocator : IDisposable + { + /// Seconds an unreserved chunk stays empty before it is released, so passing gaps don't churn chunks. + internal const float ReleaseDelay = 5f; + + private readonly List _chunks = new(); + private readonly Transform _parent; + private readonly Material[] _materials; + private readonly int _layer; + private readonly BoneRegistry _bones; + private readonly MaterialPropertyBlock _propertyBlock = new(); + // The first chunks are the reserved ones; releasing others keeps them in front. + private int _reservedCount; + private int _lastCompactionFrame = -1; + + /// Transform the chunks' GameObjects are created under; destroying it destroys them. + /// Materials every chunk draws with; the allocator leaves them to their owner. + internal ChunkAllocator(Transform parent, Material[] materials, int layer, BoneRegistry bones) + { + _parent = parent; + _materials = materials; + _layer = layer; + _bones = bones; + } + + internal IReadOnlyList Chunks + { + get => _chunks; + } + + /// + /// Creates chunks with room for vertices and edges, + /// split evenly between as few chunks as that takes. They are never released. + /// + internal void Reserve(int vertexCount, int edgeCount) + { + if (vertexCount <= 0 && edgeCount <= 0) + { + return; + } + int chunkCount = Math.Max(1, CeilingDivide(vertexCount, MeshChunk.MaxVertexCount)); + int vertices = Math.Max(MeshChunk.InitialVertexCapacity, CeilingDivide(vertexCount, chunkCount)); + int edges = Math.Max(MeshChunk.InitialEdgeCapacity, CeilingDivide(edgeCount, chunkCount)); + for (int i = 0; i < chunkCount; i++) + { + CreateChunk(vertices, edges); + _reservedCount++; + } + } + + /// Adds to a chunk and returns that chunk. + internal MeshChunk Attach(Shape shape) + { + int vertexCount = shape.VertexCount; + MeshChunk chunk = FindChunk(vertexCount); + if (chunk == null) + { + chunk = vertexCount > MeshChunk.MaxVertexCount + ? CreateChunk(vertexCount, Math.Max(MeshChunk.InitialEdgeCapacity, shape.EdgeCount)) + : CreateChunk(GrownCapacity(MeshChunk.InitialVertexCapacity, vertexCount), MeshChunk.InitialEdgeCapacity); + } + chunk.Add(shape); + return chunk; + } + + /// Hands the bone texture to every chunk, including the ones created later. + internal void SetBoneTexture(Texture texture) + { + _propertyBlock.SetTexture(BoneTexture.PropertyId, texture); + for (int i = 0; i < _chunks.Count; i++) + { + _chunks[i].Renderer.SetPropertyBlock(_propertyBlock); + } + } + + /// Compacts at most one chunk per frame, then writes and uploads every chunk's queued shapes. + internal void Flush() + { + CompactOneChunk(); + for (int i = 0; i < _chunks.Count; i++) + { + _chunks[i].Flush(); + } + } + + /// + /// Releases the unreserved chunks that stayed empty for seconds up to + /// . Their GameObjects are destroyed with , so this must + /// not run where Unity forbids destroying objects right away, such as in a render callback in Edit Mode. + /// + /// Seconds on a clock that keeps running while the game is paused. + internal void ReleaseIdleChunks(float time) + { + for (int i = _chunks.Count - 1; i >= _reservedCount; i--) + { + MeshChunk chunk = _chunks[i]; + if (chunk.ShapeCount > 0) + { + continue; + } + if (float.IsNaN(chunk.EmptySince)) + { + chunk.EmptySince = time; + continue; + } + if (time - chunk.EmptySince >= ReleaseDelay) + { + _chunks.RemoveAt(i); + chunk.Dispose(); + UnityObjects.Destroy(chunk.Renderer.gameObject); + } + } + } + + /// Disposes every chunk and its shapes. The chunks' GameObjects go away with the parent. + public void Dispose() + { + for (int i = 0; i < _chunks.Count; i++) + { + _chunks[i].Dispose(); + } + _chunks.Clear(); + _reservedCount = 0; + } + + private MeshChunk FindChunk(int vertexCount) + { + // Oldest chunks first, so newer ones are the ones that empty out and get released. + bool isLarge = vertexCount > MeshChunk.MaxVertexCount; + for (int i = 0; i < _chunks.Count; i++) + { + MeshChunk chunk = _chunks[i]; + // An idle large chunk is reused only when the shape fills at least half of it. + if (chunk.IsLarge == isLarge && chunk.HasRoomFor(vertexCount) + && (!isLarge || chunk.VertexCapacity <= vertexCount * 2L)) + { + return chunk; + } + } + if (isLarge) + { + return null; + } + for (int i = 0; i < _chunks.Count; i++) + { + MeshChunk chunk = _chunks[i]; + if (!chunk.IsLarge && chunk.CanGrowToFit(vertexCount)) + { + return chunk; + } + } + return null; + } + + private MeshChunk CreateChunk(int vertexCapacity, int edgeCapacity) + { + MeshChunk chunk = new(_parent, _materials, _layer, _bones, vertexCapacity, edgeCapacity); + chunk.Renderer.SetPropertyBlock(_propertyBlock); + _chunks.Add(chunk); + return chunk; + } + + private void CompactOneChunk() + { + // Compacting rewrites a whole chunk, so spreading it over frames keeps each frame's cost bounded. + int frame = Time.frameCount; + if (frame == _lastCompactionFrame) + { + return; + } + for (int i = 0; i < _chunks.Count; i++) + { + if (_chunks[i].NeedsCompaction) + { + _chunks[i].Compact(); + _lastCompactionFrame = frame; + return; + } + } + } + + /// doubled until it holds , up to a chunk's limit. + private static int GrownCapacity(int capacity, int required) + { + while (capacity < required) + { + capacity *= 2; + } + return Math.Min(capacity, MeshChunk.MaxVertexCount); + } + + private static int CeilingDivide(int value, int divisor) + { + return (int)((value + (long)divisor - 1) / divisor); + } + } +} diff --git a/UnityPackage/Runtime/Rendering/ChunkAllocator.cs.meta b/UnityPackage/Runtime/Rendering/ChunkAllocator.cs.meta new file mode 100644 index 0000000..3d748b2 --- /dev/null +++ b/UnityPackage/Runtime/Rendering/ChunkAllocator.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d0e46d53b02cc94624ce6c0ba42686b4 \ No newline at end of file diff --git a/UnityPackage/Runtime/Rendering/MeshChunk.cs b/UnityPackage/Runtime/Rendering/MeshChunk.cs index 9931e85..c5ac679 100644 --- a/UnityPackage/Runtime/Rendering/MeshChunk.cs +++ b/UnityPackage/Runtime/Rendering/MeshChunk.cs @@ -5,19 +5,31 @@ namespace reromanlee.Wireframes { /// - /// One mesh holding many shapes. Shapes keep their own state; on each flush the chunk writes the queued shapes into - /// CPU copies of the vertex and index buffers and uploads only the ranges that changed. Each vertex is a position - /// relative to its bone plus that bone's slot, which the shader turns into a world position. + /// One mesh holding many shapes, drawn with its own draw call. Shapes keep their own state; on each flush the chunk + /// writes the queued shapes into CPU copies of the vertex and index buffers and uploads only the ranges that + /// changed. Each vertex is a position relative to its bone plus that bone's slot, which the shader turns into a + /// world position. /// + /// + /// A chunk grows up to vertices, so its indices stay 16-bit. A chunk created with more + /// is a large chunk: it keeps 32-bit indices for a shape bigger than that and never grows. + /// internal sealed class MeshChunk : IDisposable { + /// + /// Most vertices a chunk with 16-bit indices holds. Index 0xFFFF stays unused because some graphics APIs reserve + /// it for primitive restart. + /// + internal const int MaxVertexCount = ushort.MaxValue; + + internal const int InitialVertexCapacity = 256; + internal const int InitialEdgeCapacity = 128; + private const string ObjectName = "Chunk"; - private const int InitialVertexCapacity = 256; private const int InitialShapeCapacity = 64; - // Index 0xFFFF is left unused because some graphics APIs reserve it for primitive restart. - private const int MaxUInt16Vertices = ushort.MaxValue; // Freed vertex blocks are packed away once they fill half the buffer, but never in small meshes. private const int CompactionThreshold = 1024; + private const int PositionSize = 12; private const int PositionStream = 0; private const int ColorStream = 1; @@ -40,17 +52,22 @@ internal sealed class MeshChunk : IDisposable private readonly BoneRegistry _bones; private readonly VertexAllocator _allocator = new(); - private readonly EdgeList _edges = new(); + private readonly EdgeList _edges; private readonly DirtyRanges _positionRanges = new(); private readonly DirtyRanges _colorRanges = new(); private readonly DirtyRanges _boneRanges = new(); private readonly DirtyRanges _edgeRanges = new(); + // Growth stops here; shrinking stops at the capacities the chunk was created with. + private readonly int _vertexLimit; + private readonly int _minimumVertexCapacity; + private readonly int _minimumEdgeCapacity; + // CPU copies of the three vertex streams, sized to the vertex capacity. - private Vector3[] _positions = new Vector3[InitialVertexCapacity]; - private Color32[] _colors = new Color32[InitialVertexCapacity]; - private float[] _boneIndices = new float[InitialVertexCapacity]; + private Vector3[] _positions; + private Color32[] _colors; + private float[] _boneIndices; private ushort[] _shortIndices = Array.Empty(); private Shape[] _shapes = new Shape[InitialShapeCapacity]; @@ -67,14 +84,26 @@ internal sealed class MeshChunk : IDisposable /// /// Materials the mesh is drawn with, each drawing all of it; the chunk leaves them to their owner. /// - internal MeshChunk(Transform parent, Material[] materials, WireframeContainerSettings settings, BoneRegistry bones) + /// + /// Vertices the chunk starts with. Past , it is a large chunk. + /// + /// Edges the chunk starts with. + internal MeshChunk( + Transform parent, Material[] materials, int layer, BoneRegistry bones, int vertexCapacity, int edgeCapacity) { - // Reserved before anything is created, so a device limit leaves nothing behind. - EnsureVertexCapacity(settings.VertexCapacity); - EnsureEdgeCapacity(settings.EdgeCapacity); + _vertexLimit = Math.Max(vertexCapacity, MaxVertexCount); + // Checked before anything is created, so a device limit leaves nothing behind. + CheckBufferSize((long)vertexCapacity * PositionSize); + CheckBufferSize((long)edgeCapacity * 2 * IndexSize); + _minimumVertexCapacity = vertexCapacity; + _minimumEdgeCapacity = edgeCapacity; + _positions = new Vector3[vertexCapacity]; + _colors = new Color32[vertexCapacity]; + _boneIndices = new float[vertexCapacity]; + _edges = new EdgeList(edgeCapacity); _bones = bones; - GameObject chunkObject = new(ObjectName) { hideFlags = HideFlags.NotEditable, layer = settings.Layer }; + GameObject chunkObject = new(ObjectName) { hideFlags = HideFlags.NotEditable, layer = layer }; chunkObject.transform.SetParent(parent, false); _mesh = new Mesh { name = ObjectName }; @@ -117,6 +146,12 @@ internal VertexAllocator Allocator get => _allocator; } + /// True for a chunk made for a shape bigger than . + internal bool IsLarge + { + get => _vertexLimit > MaxVertexCount; + } + internal int ShapeCount { get => _shapeCount; @@ -137,6 +172,17 @@ internal int EdgeCapacity get => _edges.Capacity; } + /// True once freed vertex blocks take up enough of the chunk to be worth packing away. + internal bool NeedsCompaction + { + get => _allocator.FreeCount >= CompactionThreshold && _allocator.FreeCount * 2 > _allocator.End; + } + + /// + /// Time the chunk was first seen empty, kept by its ; NaN while it holds shapes. + /// + internal float EmptySince { get; set; } = float.NaN; + /// CPU copy of every vertex position, relative to its bone. internal Vector3[] Positions { @@ -149,6 +195,23 @@ internal float[] BoneIndices get => _boneIndices; } + private int IndexSize + { + get => IsLarge ? sizeof(uint) : sizeof(ushort); + } + + /// True when vertices fit in the buffers as they are. + internal bool HasRoomFor(int vertexCount) + { + return _allocator.EndAfterAllocate(vertexCount) <= _positions.Length; + } + + /// True when vertices fit once the buffers grow to their limit. + internal bool CanGrowToFit(int vertexCount) + { + return _allocator.EndAfterAllocate(vertexCount) <= _vertexLimit; + } + internal void Add(Shape shape) { // Capacity is checked before anything changes, so a device limit leaves the chunk untouched. @@ -166,6 +229,7 @@ internal void Add(Shape shape) } shape.ShapeIndex = _shapeCount; _shapes[_shapeCount++] = shape; + EmptySince = float.NaN; } internal void Remove(Shape shape) @@ -201,15 +265,33 @@ internal void Enqueue(Shape shape) /// Writes queued shapes into the buffers and uploads what changed. internal void Flush() { - if (_allocator.FreeCount >= CompactionThreshold && _allocator.FreeCount * 2 > _allocator.End) - { - Compact(); - } WritePendingShapes(); UploadVertices(); UploadIndices(); } + /// + /// Hands out vertex blocks again from vertex 0, packing live shapes together, and gives back memory that the + /// packed shapes leave unused. Every shape is written and uploaded again on the next flush. + /// + internal void Compact() + { + _allocator.Reset(); + for (int i = 0; i < _shapeCount; i++) + { + Shape shape = _shapes[i]; + shape.VertexStart = _allocator.Allocate(shape.VertexCount); + shape.MarkDirty(DirtyFlags.All); + } + + int vertexCapacity = ShrunkCapacity(_positions.Length, _allocator.End, _minimumVertexCapacity); + if (vertexCapacity < _positions.Length) + { + ResizeVertexStreams(vertexCapacity); + } + _edges.Shrink(ShrunkCapacity(_edges.Capacity, _edges.Count, _minimumEdgeCapacity)); + } + public void Dispose() { for (int i = 0; i < _shapeCount; i++) @@ -225,22 +307,10 @@ public void Dispose() { _renderer.enabled = false; } - // The chunk's GameObject is a child of the proxy and goes away with it; the mesh is an asset and doesn't. + // The chunk's GameObject belongs to its owner, which destroys it; the mesh is an asset and goes here. UnityObjects.Destroy(_mesh); } - /// Hands out vertex blocks again from vertex 0, packing live shapes together. - private void Compact() - { - _allocator.Reset(); - for (int i = 0; i < _shapeCount; i++) - { - Shape shape = _shapes[i]; - shape.VertexStart = _allocator.Allocate(shape.VertexCount); - shape.MarkDirty(DirtyFlags.All); - } - } - private void WritePendingShapes() { for (int i = 0; i < _pendingCount; i++) @@ -317,7 +387,7 @@ private void UploadIndices() { int edgeCount = _edges.Count; int indexCapacity = _edges.Capacity * 2; - IndexFormat format = _meshVertexCapacity > MaxUInt16Vertices ? IndexFormat.UInt32 : IndexFormat.UInt16; + IndexFormat format = IsLarge ? IndexFormat.UInt32 : IndexFormat.UInt16; if (_meshIndexCapacity != indexCapacity || _meshIndexFormat != format) { @@ -384,12 +454,21 @@ private void EnsureVertexCapacity(int required) { return; } + if (required > _vertexLimit) + { + throw new InvalidOperationException( + $"A chunk holds at most {_vertexLimit} vertices, but {required} were requested."); + } + capacity = Math.Max(capacity, 1); while (capacity < required) { capacity *= 2; } - // Positions are the widest stream. - CheckBufferSize((long)capacity * 12); + ResizeVertexStreams(Math.Min(capacity, _vertexLimit)); + } + + private void ResizeVertexStreams(int capacity) + { // New elements default to zero, which is bone slot 0 (world space), a valid index for the shader. Array.Resize(ref _positions, capacity); Array.Resize(ref _colors, capacity); @@ -403,15 +482,24 @@ private void EnsureEdgeCapacity(int required) { return; } + capacity = Math.Max(capacity, 1); while (capacity < required) { capacity *= 2; } - // Two 32-bit indices per edge at worst. - CheckBufferSize((long)capacity * 8); + CheckBufferSize((long)capacity * 2 * IndexSize); _edges.EnsureCapacity(capacity); } + /// + /// The capacity to shrink to: twice what is used once that is at most a quarter, so it takes doubling the load + /// to grow again, and never below . + /// + private static int ShrunkCapacity(int capacity, int used, int minimum) + { + return used <= capacity / 4 ? Math.Min(capacity, Math.Max(minimum, used * 2)) : capacity; + } + private static void CheckBufferSize(long bytes) { // Without a graphics device (batch mode with -nographics) the limit reads as zero. diff --git a/UnityPackage/Runtime/Rendering/MeshProxy.cs b/UnityPackage/Runtime/Rendering/MeshProxy.cs index bab1d86..6e8c490 100644 --- a/UnityPackage/Runtime/Rendering/MeshProxy.cs +++ b/UnityPackage/Runtime/Rendering/MeshProxy.cs @@ -14,20 +14,30 @@ namespace reromanlee.Wireframes [DisallowMultipleComponent] internal sealed class MeshProxy : MonoBehaviour { - private readonly List _chunks = new(); private readonly BoneRegistry _bones = new(); private readonly BoneTexture _boneTexture = new(); - private MaterialPropertyBlock _propertyBlock; + private ChunkAllocator _chunks; private WireframeContainer _container; private Material[] _materials; private bool _ownsMaterials; private bool _isShutDown; internal IReadOnlyList Chunks + { + get => _chunks.Chunks; + } + + internal ChunkAllocator ChunkAllocator { get => _chunks; } + /// The materials every chunk draws with. + internal Material[] Materials + { + get => _materials; + } + internal BoneRegistry Bones { get => _bones; @@ -56,37 +66,27 @@ internal void Initialize(WireframeContainer container, WireframeContainerSetting _materials = WireframeMaterials.Create(shader, settings.Occlusion, settings.UseAlpha); _ownsMaterials = true; } - _propertyBlock = new MaterialPropertyBlock(); - _chunks.Add(new MeshChunk(transform, _materials, settings, _bones)); - // Uploads the bone texture and hands it to the renderers. + _chunks = new ChunkAllocator(transform, _materials, settings.Layer, _bones); + _chunks.Reserve(settings.VertexCapacity, settings.EdgeCapacity); + // Uploads the bone texture and hands it to the chunks. Flush(); } /// Adds to a chunk and returns that chunk. internal MeshChunk Attach(Shape shape) { - // One chunk covers every size a device can hold; choosing a chunk with room would go here. - MeshChunk chunk = _chunks[0]; - chunk.Add(shape); - return chunk; + return _chunks.Attach(shape); } /// Reads the bones, writes queued shapes and uploads what changed. internal void Flush() { _bones.ReadMatrices(); - for (int i = 0; i < _chunks.Count; i++) - { - _chunks[i].Flush(); - } if (_boneTexture.Upload(_bones)) { - _propertyBlock.SetTexture(BoneTexture.PropertyId, _boneTexture.Texture); - for (int i = 0; i < _chunks.Count; i++) - { - _chunks[i].Renderer.SetPropertyBlock(_propertyBlock); - } + _chunks.SetBoneTexture(_boneTexture.Texture); } + _chunks.Flush(); } internal void Shutdown() @@ -96,11 +96,7 @@ internal void Shutdown() return; } _isShutDown = true; - for (int i = 0; i < _chunks.Count; i++) - { - _chunks[i].Dispose(); - } - _chunks.Clear(); + _chunks?.Dispose(); _boneTexture.Dispose(); if (_ownsMaterials) { @@ -153,6 +149,11 @@ private void FlushBeforeRendering() try { Flush(); + // Play Mode only, where destroying waits for the end of the frame and is allowed in a render callback. + if (Application.isPlaying) + { + _chunks.ReleaseIdleChunks(Time.realtimeSinceStartup); + } } catch (Exception exception) { diff --git a/UnityPackage/Tests/Editor/EdgeListTests.cs b/UnityPackage/Tests/Editor/EdgeListTests.cs index 229d46a..f28cc57 100644 --- a/UnityPackage/Tests/Editor/EdgeListTests.cs +++ b/UnityPackage/Tests/Editor/EdgeListTests.cs @@ -17,7 +17,7 @@ public void OnEdgeMoved(int edge, int slot) [Test] public void RemoveAt_MovesLastEdgeIntoFreedSlot() { - EdgeList edges = new(); + EdgeList edges = new(4); Owner first = new(); Owner second = new(); first.Slots[0] = edges.Add(first, 0); @@ -38,7 +38,7 @@ public void RemoveAt_MovesLastEdgeIntoFreedSlot() [Test] public void RemoveAt_LastSlotMovesNothing() { - EdgeList edges = new(); + EdgeList edges = new(4); Owner owner = new(); edges.Add(owner, 0); int last = edges.Add(owner, 1); @@ -51,7 +51,7 @@ public void RemoveAt_LastSlotMovesNothing() public void RemovingEveryEdgeOfOneOwner_LeavesOtherOwnersIntact() { // Mirrors MeshChunk.Remove: an owner's later edge can be moved into one of its own earlier slots. - EdgeList edges = new(); + EdgeList edges = new(4); Owner removed = new(); Owner kept = new(); for (int edge = 0; edge < 3; edge++) @@ -75,7 +75,7 @@ public void RemovingEveryEdgeOfOneOwner_LeavesOtherOwnersIntact() [Test] public void EnsureCapacity_DoublesAndKeepsEdges() { - EdgeList edges = new(); + EdgeList edges = new(4); Owner owner = new(); int slot = edges.Add(owner, 0); edges.Set(slot, 3, 4); @@ -88,5 +88,25 @@ public void EnsureCapacity_DoublesAndKeepsEdges() Assert.That(edges.Indices[0], Is.EqualTo(3)); Assert.That(edges.Indices[1], Is.EqualTo(4)); } + + [Test] + public void Shrink_KeepsEdgesAndNeverDropsBelowTheirCount() + { + EdgeList edges = new(64); + Owner owner = new(); + for (int edge = 0; edge < 3; edge++) + { + edges.Set(edges.Add(owner, edge), edge, edge + 1); + } + + edges.Shrink(8); + Assert.That(edges.Capacity, Is.EqualTo(8)); + Assert.That(edges.Indices.Length, Is.EqualTo(16)); + Assert.That(edges.Indices[4], Is.EqualTo(2)); + Assert.That(edges.Indices[5], Is.EqualTo(3)); + + edges.Shrink(1); + Assert.That(edges.Capacity, Is.EqualTo(3)); + } } } diff --git a/UnityPackage/Tests/Runtime/CapsuleTests.cs b/UnityPackage/Tests/Runtime/CapsuleTests.cs index 7eb58b5..6783b3c 100644 --- a/UnityPackage/Tests/Runtime/CapsuleTests.cs +++ b/UnityPackage/Tests/Runtime/CapsuleTests.cs @@ -119,7 +119,7 @@ public void SegmentsThatAreNotAMultipleOfFour_Throw() WireframeContainer container = CreateContainer(); Assert.Throws(() => container.CreateCapsule(Vector3.zero, Vector3.up, 1f, 6)); - Assert.That(ChunkOf(container).ShapeCount, Is.Zero); + Assert.That(ShapeCountOf(container), Is.Zero); } } } diff --git a/UnityPackage/Tests/Runtime/ChunkAllocatorTests.cs b/UnityPackage/Tests/Runtime/ChunkAllocatorTests.cs new file mode 100644 index 0000000..9f44acf --- /dev/null +++ b/UnityPackage/Tests/Runtime/ChunkAllocatorTests.cs @@ -0,0 +1,198 @@ +using System.Collections; +using System.Collections.Generic; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.Rendering; +using UnityEngine.TestTools; + +namespace reromanlee.Wireframes.Tests +{ + /// How shapes spread over chunks, and how chunks are reserved, compacted and released. + public class ChunkAllocatorTests : WireframesTestBase + { + // Two-vertex lines that fill a chunk up to its 16-bit limit. + private const int LinesPerChunk = MeshChunk.MaxVertexCount / 2; + // Any time works for the release clock; only differences count. + private const float Now = 1000f; + + [Test] + public void Shapes_FillAChunkToItsLimitBeforeStartingAnother() + { + WireframeContainer container = CreateContainer(); + List lines = CreateLines(container, LinesPerChunk + 10); + + IReadOnlyList chunks = container.Proxy.Chunks; + Assert.That(chunks, Has.Count.EqualTo(2)); + Assert.That(chunks[0].VertexCapacity, Is.EqualTo(MeshChunk.MaxVertexCount)); + Assert.That(chunks[0].ShapeCount, Is.EqualTo(LinesPerChunk)); + Assert.That(chunks[1].ShapeCount, Is.EqualTo(10)); + ILine last = lines[lines.Count - 1]; + Assert.That(ChunkOf(last), Is.SameAs(chunks[1])); + (Vector3 A, Vector3 B)[] edges = BakeEdges(container, last); + AssertApproximately(new Vector3(lines.Count - 1, 0f, 0f), edges[0].A); + AssertApproximately(new Vector3(lines.Count - 1, 1f, 0f), edges[0].B); + foreach (MeshChunk chunk in chunks) + { + Assert.That(chunk.Mesh.indexFormat, Is.EqualTo(IndexFormat.UInt16)); + } + } + + [Test] + public void ShapeBiggerThanAChunk_GetsALargeChunkOfItsOwn() + { + WireframeContainer container = CreateContainer(); + Vector3[] points = new Vector3[MeshChunk.MaxVertexCount + 1000]; + for (int i = 0; i < points.Length; i++) + { + points[i] = new Vector3(i, 0f, 0f); + } + + IPolyline polyline = container.CreatePolyline(points); + ILine line = container.CreateLine(); + + MeshChunk large = ChunkOf(polyline); + Assert.That(large.IsLarge, Is.True); + Assert.That(large.VertexCapacity, Is.EqualTo(points.Length)); + Assert.That(ChunkOf(line), Is.Not.SameAs(large)); + (Vector3 A, Vector3 B)[] edges = BakeEdges(container, polyline); + AssertApproximately(points[points.Length - 2], edges[edges.Length - 1].A); + AssertApproximately(points[points.Length - 1], edges[edges.Length - 1].B); + Assert.That(large.Mesh.indexFormat, Is.EqualTo(IndexFormat.UInt32)); + Assert.That(ChunkOf(line).Mesh.indexFormat, Is.EqualTo(IndexFormat.UInt16)); + } + + [Test] + public void FreedRoomInAnOlderChunk_IsUsedBeforeANewerOneGrows() + { + WireframeContainer container = CreateContainer(); + List lines = CreateLines(container, LinesPerChunk + 1); + lines[100].Dispose(); + + ILine replacement = container.CreateLine(); + + Assert.That(ChunkOf(replacement), Is.SameAs(container.Proxy.Chunks[0])); + } + + [UnityTest] + public IEnumerator EmptyChunk_IsReleasedOnceItStayedEmptyForTheDelay() + { + WireframeContainer container = CreateContainer(); + List lines = CreateLines(container, LinesPerChunk + 10); + MeshChunk second = container.Proxy.Chunks[1]; + for (int i = LinesPerChunk; i < lines.Count; i++) + { + lines[i].Dispose(); + } + ChunkAllocator allocator = container.Proxy.ChunkAllocator; + + allocator.ReleaseIdleChunks(Now); + allocator.ReleaseIdleChunks(Now + ChunkAllocator.ReleaseDelay - 0.5f); + Assert.That(container.Proxy.Chunks, Has.Count.EqualTo(2)); + + allocator.ReleaseIdleChunks(Now + ChunkAllocator.ReleaseDelay); + + Assert.That(container.Proxy.Chunks, Has.Count.EqualTo(1)); + Assert.That(second.Renderer.enabled, Is.False); + yield return null; + Assert.That(second.Renderer == null, Is.True, "The released chunk's GameObject still exists."); + Assert.That(second.Mesh == null, Is.True, "The released chunk's mesh still exists."); + } + + [Test] + public void EmptyChunk_ThatGetsAShapeInTime_IsKept() + { + WireframeContainer container = CreateContainer(); + List lines = CreateLines(container, LinesPerChunk + 10); + for (int i = LinesPerChunk; i < lines.Count; i++) + { + lines[i].Dispose(); + } + ChunkAllocator allocator = container.Proxy.ChunkAllocator; + allocator.ReleaseIdleChunks(Now); + + // The first chunk is full, so the new line goes into the empty one. + ILine line = container.CreateLine(); + allocator.ReleaseIdleChunks(Now + ChunkAllocator.ReleaseDelay * 2f); + + Assert.That(container.Proxy.Chunks, Has.Count.EqualTo(2)); + Assert.That(ChunkOf(line), Is.SameAs(container.Proxy.Chunks[1])); + } + + [Test] + public void ReservedCapacity_IsSplitEvenlyAndNeverReleased() + { + WireframeContainer container = CreateContainer( + new WireframeContainerSettings { VertexCapacity = 100000, EdgeCapacity = 60000 }); + IReadOnlyList chunks = container.Proxy.Chunks; + + Assert.That(chunks, Has.Count.EqualTo(2)); + foreach (MeshChunk chunk in chunks) + { + Assert.That(chunk.VertexCapacity, Is.EqualTo(50000)); + Assert.That(chunk.EdgeCapacity, Is.EqualTo(30000)); + } + container.Proxy.ChunkAllocator.ReleaseIdleChunks(Now); + container.Proxy.ChunkAllocator.ReleaseIdleChunks(Now + ChunkAllocator.ReleaseDelay * 2f); + Assert.That(chunks, Has.Count.EqualTo(2)); + } + + [UnityTest] + public IEnumerator Compaction_RunsForOneChunkPerFrame() + { + WireframeContainer container = CreateContainer(); + List lines = CreateLines(container, LinesPerChunk + 4000); + container.Proxy.Flush(); + // Three lines in five go, in both chunks. + for (int i = 0; i < lines.Count; i++) + { + if (i % 5 < 3) + { + lines[i].Dispose(); + } + } + MeshChunk first = container.Proxy.Chunks[0]; + MeshChunk second = container.Proxy.Chunks[1]; + Assert.That(first.NeedsCompaction, Is.True); + Assert.That(second.NeedsCompaction, Is.True); + + container.Proxy.Flush(); + container.Proxy.Flush(); + Assert.That(first.NeedsCompaction, Is.False); + Assert.That(second.NeedsCompaction, Is.True); + + yield return null; + container.Proxy.Flush(); + Assert.That(second.NeedsCompaction, Is.False); + Vector3[] firstVertices = FlushAndBake(container, first); + Vector3[] secondVertices = FlushAndBake(container, second); + for (int i = 3; i < lines.Count; i += 5) + { + Line line = (Line)lines[i]; + Vector3[] vertices = line.Chunk == first ? firstVertices : secondVertices; + AssertApproximately(new Vector3(i, 0f, 0f), vertices[line.VertexStart]); + } + } + + [Test] + public void Dispose_DisposesTheShapesOfEveryChunk() + { + WireframeContainer container = CreateContainer(); + List lines = CreateLines(container, LinesPerChunk + 1); + + container.Dispose(); + + Assert.That(lines[0].IsDisposed, Is.True); + Assert.That(lines[lines.Count - 1].IsDisposed, Is.True); + } + + private static List CreateLines(WireframeContainer container, int count) + { + List lines = new(count); + for (int i = 0; i < count; i++) + { + lines.Add(container.CreateLine(new Vector3(i, 0f, 0f), new Vector3(i, 1f, 0f))); + } + return lines; + } + } +} diff --git a/UnityPackage/Tests/Runtime/ChunkAllocatorTests.cs.meta b/UnityPackage/Tests/Runtime/ChunkAllocatorTests.cs.meta new file mode 100644 index 0000000..960a971 --- /dev/null +++ b/UnityPackage/Tests/Runtime/ChunkAllocatorTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: b09749d6a82daf2f99ac263c9eb4d289 \ No newline at end of file diff --git a/UnityPackage/Tests/Runtime/CircleTests.cs b/UnityPackage/Tests/Runtime/CircleTests.cs index 3ff7d50..db01192 100644 --- a/UnityPackage/Tests/Runtime/CircleTests.cs +++ b/UnityPackage/Tests/Runtime/CircleTests.cs @@ -98,7 +98,7 @@ public void FewerThanThreeSegments_Throw() Assert.Throws(() => container.CreateCircle(Vector3.zero, 1f, 2)); Assert.Throws(() => container.CreateCircle(Vector3.zero, 1f, -1)); Assert.That(container.CreateCircle(Vector3.zero, 1f, 3).SegmentCount, Is.EqualTo(3)); - Assert.That(ChunkOf(container).ShapeCount, Is.EqualTo(1), "A rejected circle must not stay in the mesh."); + Assert.That(ShapeCountOf(container), Is.EqualTo(1), "A rejected circle must not stay in the mesh."); } } } diff --git a/UnityPackage/Tests/Runtime/ConeTests.cs b/UnityPackage/Tests/Runtime/ConeTests.cs index 185bec3..40667d4 100644 --- a/UnityPackage/Tests/Runtime/ConeTests.cs +++ b/UnityPackage/Tests/Runtime/ConeTests.cs @@ -74,7 +74,7 @@ public void SegmentsThatAreNotAMultipleOfFour_Throw() WireframeContainer container = CreateContainer(); Assert.Throws(() => container.CreateCone(Vector3.zero, Vector3.forward, 1f, 10)); - Assert.That(ChunkOf(container).ShapeCount, Is.Zero); + Assert.That(ShapeCountOf(container), Is.Zero); } } } diff --git a/UnityPackage/Tests/Runtime/CylinderTests.cs b/UnityPackage/Tests/Runtime/CylinderTests.cs index 2be1462..c901047 100644 --- a/UnityPackage/Tests/Runtime/CylinderTests.cs +++ b/UnityPackage/Tests/Runtime/CylinderTests.cs @@ -187,7 +187,7 @@ public void SegmentsThatAreNotAMultipleOfFour_Throw() Assert.Throws(() => container.CreateCylinder(Vector3.zero, Vector3.up, 1f, 0)); Assert.Throws(() => container.CreateCylinder(Vector3.zero, Vector3.up, 1f, -4)); Assert.That(container.CreateCylinder(Vector3.zero, Vector3.up, 1f, 4).SegmentCount, Is.EqualTo(4)); - Assert.That(ChunkOf(container).ShapeCount, Is.EqualTo(1)); + Assert.That(ShapeCountOf(container), Is.EqualTo(1)); } } } diff --git a/UnityPackage/Tests/Runtime/EllipseTests.cs b/UnityPackage/Tests/Runtime/EllipseTests.cs index ac407aa..4aba864 100644 --- a/UnityPackage/Tests/Runtime/EllipseTests.cs +++ b/UnityPackage/Tests/Runtime/EllipseTests.cs @@ -87,7 +87,7 @@ public void FewerThanThreeSegments_Throw() Assert.Throws( () => container.CreateEllipse(Vector3.zero, Quaternion.identity, Vector2.one, 2)); - Assert.That(ChunkOf(container).ShapeCount, Is.Zero); + Assert.That(ShapeCountOf(container), Is.Zero); } } } diff --git a/UnityPackage/Tests/Runtime/EllipsoidTests.cs b/UnityPackage/Tests/Runtime/EllipsoidTests.cs index a7e3490..55baa49 100644 --- a/UnityPackage/Tests/Runtime/EllipsoidTests.cs +++ b/UnityPackage/Tests/Runtime/EllipsoidTests.cs @@ -68,7 +68,7 @@ public void FewerThanThreeSegments_Throw() Assert.Throws( () => container.CreateEllipsoid(Vector3.zero, Vector3.up, 1f, 1)); - Assert.That(ChunkOf(container).ShapeCount, Is.Zero); + Assert.That(ShapeCountOf(container), Is.Zero); } } } diff --git a/UnityPackage/Tests/Runtime/FrustumTests.cs b/UnityPackage/Tests/Runtime/FrustumTests.cs index 083bfbc..2b924ed 100644 --- a/UnityPackage/Tests/Runtime/FrustumTests.cs +++ b/UnityPackage/Tests/Runtime/FrustumTests.cs @@ -87,7 +87,7 @@ public void FewerThanThreeSides_Throw() WireframeContainer container = CreateContainer(); Assert.Throws(() => container.CreateFrustum(Vector3.zero, Vector3.up, 1f, 1f, 2)); - Assert.That(ChunkOf(container).ShapeCount, Is.Zero); + Assert.That(ShapeCountOf(container), Is.Zero); } } } diff --git a/UnityPackage/Tests/Runtime/LineTests.cs b/UnityPackage/Tests/Runtime/LineTests.cs index d05ab54..90453fd 100644 --- a/UnityPackage/Tests/Runtime/LineTests.cs +++ b/UnityPackage/Tests/Runtime/LineTests.cs @@ -119,7 +119,7 @@ public void Colors_AreUploadedPerEndpoint() public void SharedBone_IsRegisteredOnceAndReleasedWithLastUse() { WireframeContainer container = CreateContainer(); - BoneRegistry bones = ChunkOf(container).Bones; + BoneRegistry bones = container.Proxy.Bones; Transform bone = CreateBone(Vector3.zero, Quaternion.identity); ILine first = container.CreateLine(); ILine second = container.CreateLine(); @@ -172,7 +172,7 @@ public IEnumerator DestroyedBone_LeavesEndpointInPlace() AssertApproximately(expected, FlushAndBake(container)[start]); Assert.That(line.BoneA, Is.Null); AssertApproximately(expected, line.WorldPositionA); - Assert.That(ChunkOf(container).Bones.Count, Is.Zero); + Assert.That(container.Proxy.Bones.Count, Is.Zero); AssertApproximately(expected, FlushAndBake(container)[start]); } @@ -245,7 +245,7 @@ public IEnumerator BoneDestroyedBeforeItWasActive_StaysInPlace() Assert.That(line.BoneA, Is.Null); AssertApproximately(new Vector3(10f, 9f, 9f), line.WorldPositionA); - Assert.That(ChunkOf(container).Bones.Count, Is.Zero); + Assert.That(container.Proxy.Bones.Count, Is.Zero); } } } diff --git a/UnityPackage/Tests/Runtime/MeshBufferTests.cs b/UnityPackage/Tests/Runtime/MeshBufferTests.cs index 9861520..c3f3224 100644 --- a/UnityPackage/Tests/Runtime/MeshBufferTests.cs +++ b/UnityPackage/Tests/Runtime/MeshBufferTests.cs @@ -1,7 +1,6 @@ using System.Collections.Generic; using NUnit.Framework; using UnityEngine; -using UnityEngine.Rendering; namespace reromanlee.Wireframes.Tests { @@ -80,7 +79,8 @@ public void FreedBlocks_AreCompactedOnceTheyFillHalfTheBuffer() { lines[i].Dispose(); } - VertexAllocator allocator = ChunkOf(container).Allocator; + MeshChunk chunk = ChunkOf(container); + VertexAllocator allocator = chunk.Allocator; Assert.That(allocator.FreeCount, Is.EqualTo(3000)); Vector3[] baked = FlushAndBake(container); @@ -93,29 +93,42 @@ public void FreedBlocks_AreCompactedOnceTheyFillHalfTheBuffer() AssertApproximately(new Vector3(i, 0f, 0f), baked[start]); AssertApproximately(new Vector3(i, 1f, 0f), baked[start + 1]); } - Assert.That(ChunkOf(container).Mesh.GetIndices(0), Has.Length.EqualTo(1000)); + Assert.That(chunk.Mesh.GetIndices(0), Has.Length.EqualTo(1000)); } [Test] - public void ManyVertices_SwitchTheIndexBufferTo32Bit() + public void Compaction_ShrinksBuffersToTwiceWhatLiveShapesUse() { WireframeContainer container = CreateContainer(); + List lines = new(); + for (int i = 0; i < 20000; i++) + { + lines.Add(container.CreateLine(new Vector3(i, 0f, 0f), new Vector3(i, 1f, 0f))); + } container.Proxy.Flush(); - Assert.That(ChunkOf(container).Mesh.indexFormat, Is.EqualTo(IndexFormat.UInt16)); + MeshChunk chunk = ChunkOf(container); + Assert.That(chunk.VertexCapacity, Is.EqualTo(65535)); - ILine last = null; - for (int i = 0; i < 40000; i++) + // A fifth of the lines stay, scattered through the buffer: 8000 vertices and 4000 edges. + for (int i = 0; i < lines.Count; i++) { - last = container.CreateLine(new Vector3(i, 0f, 0f), new Vector3(i, 1f, 0f)); + if (i % 5 != 0) + { + lines[i].Dispose(); + } } Vector3[] baked = FlushAndBake(container); - Mesh mesh = ChunkOf(container).Mesh; - Assert.That(mesh.indexFormat, Is.EqualTo(IndexFormat.UInt32)); - Assert.That(mesh.GetIndexCount(0), Is.EqualTo(80000u)); - int start = ((Line)last).VertexStart; - AssertApproximately(new Vector3(39999f, 0f, 0f), baked[start]); - AssertApproximately(new Vector3(39999f, 1f, 0f), baked[start + 1]); + Assert.That(chunk.VertexCapacity, Is.EqualTo(16000)); + Assert.That(chunk.EdgeCapacity, Is.EqualTo(8000)); + Assert.That(chunk.Mesh.vertexCount, Is.EqualTo(16000)); + Assert.That(chunk.Mesh.GetIndices(0), Has.Length.EqualTo(8000)); + for (int i = 0; i < lines.Count; i += 5) + { + int start = ((Line)lines[i]).VertexStart; + AssertApproximately(new Vector3(i, 0f, 0f), baked[start]); + AssertApproximately(new Vector3(i, 1f, 0f), baked[start + 1]); + } } [Test] @@ -135,7 +148,7 @@ public void ManyBones_GrowTheBoneTextureWithEveryMatrix() Vector3[] baked = FlushAndBake(container); Texture2D texture = container.Proxy.BoneTexture.Texture; - Assert.That(ChunkOf(container).Bones.Count, Is.EqualTo(lines.Count)); + Assert.That(container.Proxy.Bones.Count, Is.EqualTo(lines.Count)); Assert.That(texture.height, Is.GreaterThanOrEqualTo(2)); for (int i = 0; i < lines.Count; i++) { diff --git a/UnityPackage/Tests/Runtime/OcclusionTests.cs b/UnityPackage/Tests/Runtime/OcclusionTests.cs index 16dd4cc..fc7d9e7 100644 --- a/UnityPackage/Tests/Runtime/OcclusionTests.cs +++ b/UnityPackage/Tests/Runtime/OcclusionTests.cs @@ -72,7 +72,7 @@ public void UndefinedOcclusion_Throws() private Material[] MaterialsOf(WireframeContainerSettings settings) { - return ChunkOf(CreateContainer(settings)).Renderer.sharedMaterials; + return CreateContainer(settings).Proxy.Materials; } private static void AssertState( diff --git a/UnityPackage/Tests/Runtime/PerformanceTests.cs b/UnityPackage/Tests/Runtime/PerformanceTests.cs index 4bc5349..5b783e5 100644 --- a/UnityPackage/Tests/Runtime/PerformanceTests.cs +++ b/UnityPackage/Tests/Runtime/PerformanceTests.cs @@ -65,7 +65,7 @@ public void LogLineTimings() $"idle flush {idleFlush * 1000.0:F1} us, flush after moving every bone {flushAfterBonesMoved * 1000.0:F1} us, " + $"{EditCount} scattered color edits {edits:F2} ms + flush {editFlush:F2} ms, " + $"dispose {LineCount / 2} lines {dispose:F2} ms + flush {disposeFlush:F2} ms"); - Assert.That(container.Proxy.Chunks[0].ShapeCount, Is.EqualTo(LineCount / 2)); + Assert.That(ShapeCountOf(container), Is.EqualTo(LineCount / 2)); } [Test] @@ -120,7 +120,7 @@ public void LogSphereTimings() $"flush after moving every bone {flushAfterBonesMoved * 1000.0:F1} us, " + $"{ResizeCount} scattered radius edits {resizes:F2} ms + flush {resizeFlush:F2} ms, " + $"dispose {SphereCount / 2} spheres {dispose:F2} ms + flush {disposeFlush:F2} ms"); - Assert.That(container.Proxy.Chunks[0].ShapeCount, Is.EqualTo(SphereCount / 2)); + Assert.That(ShapeCountOf(container), Is.EqualTo(SphereCount / 2)); } private Transform[] CreateBones() diff --git a/UnityPackage/Tests/Runtime/PolylineTests.cs b/UnityPackage/Tests/Runtime/PolylineTests.cs index 397a01a..bc96024 100644 --- a/UnityPackage/Tests/Runtime/PolylineTests.cs +++ b/UnityPackage/Tests/Runtime/PolylineTests.cs @@ -80,7 +80,7 @@ public void TooFewPoints_Throw() Assert.Throws(() => container.CreatePolygon(Vector3.zero, Vector3.one)); Assert.Throws(() => container.CreatePolyline((Vector3[])null)); Assert.Throws(() => container.CreatePolygon((Transform[])null)); - Assert.That(ChunkOf(container).ShapeCount, Is.Zero); + Assert.That(ShapeCountOf(container), Is.Zero); } [Test] @@ -151,11 +151,11 @@ public void Dispose_ReleasesEveryBone() WireframeContainer container = CreateContainer(); Transform bone = CreateBone(Vector3.zero, Quaternion.identity); IPolyline polyline = container.CreatePolygon(bone, bone, CreateBone(Vector3.one, Quaternion.identity)); - Assert.That(ChunkOf(container).Bones.Count, Is.EqualTo(2)); + Assert.That(container.Proxy.Bones.Count, Is.EqualTo(2)); polyline.Dispose(); - Assert.That(ChunkOf(container).Bones.Count, Is.Zero); + Assert.That(container.Proxy.Bones.Count, Is.Zero); Assert.Throws(() => _ = polyline.PointCount); } diff --git a/UnityPackage/Tests/Runtime/RoundedRectangleTests.cs b/UnityPackage/Tests/Runtime/RoundedRectangleTests.cs index ecdb4d4..cd7da64 100644 --- a/UnityPackage/Tests/Runtime/RoundedRectangleTests.cs +++ b/UnityPackage/Tests/Runtime/RoundedRectangleTests.cs @@ -104,7 +104,7 @@ public void SegmentsThatAreNotAMultipleOfFour_Throw() Assert.Throws( () => container.CreateRoundedRectangle(Vector3.zero, Vector3.one, 0.1f, 6)); - Assert.That(ChunkOf(container).ShapeCount, Is.Zero); + Assert.That(ShapeCountOf(container), Is.Zero); } } } diff --git a/UnityPackage/Tests/Runtime/ShaderTests.cs b/UnityPackage/Tests/Runtime/ShaderTests.cs index bb4f602..6f099da 100644 --- a/UnityPackage/Tests/Runtime/ShaderTests.cs +++ b/UnityPackage/Tests/Runtime/ShaderTests.cs @@ -100,6 +100,37 @@ public IEnumerator DestroyedBone_KeepsDrawingItsShapesInPlace() Assert.That(IsLit(new Vector3(0f, 2f, 0f)), Is.False); } + [Test] + public void ShapesInALaterChunk_FollowTheirBonesWhileTheBoneTextureGrows() + { + WireframeContainer container = CreateContainer(); + // Fills the first chunk with lines outside the camera's view. + for (int i = 0; i < MeshChunk.MaxVertexCount / 2; i++) + { + container.CreateLine(new Vector3(50f, 0f, 0f), new Vector3(51f, 0f, 0f)); + } + Transform bone = CreateBone(new Vector3(0f, 2f, 0f), Quaternion.identity); + ILine line = container.CreateLine(bone, bone); + line.LocalPositionA = new Vector3(-1f, 0f, 0f); + line.LocalPositionB = new Vector3(1f, 0f, 0f); + line.SetColor(Color.red); + Assert.That(ChunkOf(line), Is.SameAs(container.Proxy.Chunks[1])); + Render(); + Assert.That(IsLit(new Vector3(0f, 2f, 0f)), Is.True); + + // A row of bones more than the texture holds, so it is recreated and every chunk has to get the new one. + for (int i = 0; i < BoneTexture.BonesPerRow; i++) + { + Transform offscreen = CreateBone(new Vector3(50f, i, 0f), Quaternion.identity); + container.CreateLine(offscreen, offscreen); + } + bone.position = new Vector3(0f, -2f, 0f); + Render(); + + Assert.That(IsLit(new Vector3(0f, -2f, 0f)), Is.True); + Assert.That(IsLit(new Vector3(0f, 2f, 0f)), Is.False); + } + [Test] public void Dispose_StopsDrawingRightAway() { diff --git a/UnityPackage/Tests/Runtime/SphereTests.cs b/UnityPackage/Tests/Runtime/SphereTests.cs index e4bb9b1..733e3a1 100644 --- a/UnityPackage/Tests/Runtime/SphereTests.cs +++ b/UnityPackage/Tests/Runtime/SphereTests.cs @@ -63,7 +63,7 @@ public void FewerThanThreeSegments_Throw() WireframeContainer container = CreateContainer(); Assert.Throws(() => container.CreateSphere(Vector3.zero, 1f, 0)); - Assert.That(ChunkOf(container).ShapeCount, Is.Zero); + Assert.That(ShapeCountOf(container), Is.Zero); } } } diff --git a/UnityPackage/Tests/Runtime/SpikedSphereTests.cs b/UnityPackage/Tests/Runtime/SpikedSphereTests.cs index e12c985..3f3ca6b 100644 --- a/UnityPackage/Tests/Runtime/SpikedSphereTests.cs +++ b/UnityPackage/Tests/Runtime/SpikedSphereTests.cs @@ -106,7 +106,7 @@ public void SpikeCountThatIsNotAPlatonicSolid_Throws(int spikes) WireframeContainer container = CreateContainer(); Assert.Throws(() => container.CreateSpikedSphere(Vector3.zero, 1f, 1f, spikes)); - Assert.That(ChunkOf(container).ShapeCount, Is.Zero); + Assert.That(ShapeCountOf(container), Is.Zero); } } } diff --git a/UnityPackage/Tests/Runtime/StadiumTests.cs b/UnityPackage/Tests/Runtime/StadiumTests.cs index 9e21662..d8a0685 100644 --- a/UnityPackage/Tests/Runtime/StadiumTests.cs +++ b/UnityPackage/Tests/Runtime/StadiumTests.cs @@ -114,7 +114,7 @@ public void SegmentsThatAreNotAMultipleOfFour_Throw() WireframeContainer container = CreateContainer(); Assert.Throws(() => container.CreateStadium(Vector3.zero, Vector3.right, 1f, 2)); - Assert.That(ChunkOf(container).ShapeCount, Is.Zero); + Assert.That(ShapeCountOf(container), Is.Zero); } } } diff --git a/UnityPackage/Tests/Runtime/StarTests.cs b/UnityPackage/Tests/Runtime/StarTests.cs index ea2f736..c1e5916 100644 --- a/UnityPackage/Tests/Runtime/StarTests.cs +++ b/UnityPackage/Tests/Runtime/StarTests.cs @@ -68,7 +68,7 @@ public void FewerThanThreePoints_Throw() Assert.Throws(() => container.CreateStar(Vector3.zero, 0.5f, 1f, 2)); Assert.Throws(() => container.CreateStar(Vector3.zero, 0.5f, 1f, -1)); - Assert.That(ChunkOf(container).ShapeCount, Is.Zero); + Assert.That(ShapeCountOf(container), Is.Zero); } } } diff --git a/UnityPackage/Tests/Runtime/WireframeContainerTests.cs b/UnityPackage/Tests/Runtime/WireframeContainerTests.cs index 6953349..57c7d7a 100644 --- a/UnityPackage/Tests/Runtime/WireframeContainerTests.cs +++ b/UnityPackage/Tests/Runtime/WireframeContainerTests.cs @@ -22,7 +22,19 @@ public void Constructor_CreatesProxyInActiveSceneWithPackageShader() Assert.That(container.IsDisposed, Is.False); Assert.That(container.Proxy.gameObject.scene, Is.EqualTo(SceneManager.GetActiveScene())); - Assert.That(ChunkOf(container).Renderer.sharedMaterial.shader.name, Is.EqualTo(ShaderName)); + Assert.That(container.Proxy.Materials[0].shader.name, Is.EqualTo(ShaderName)); + } + + [Test] + public void Constructor_CreatesNoChunkUntilTheFirstShape() + { + WireframeContainer container = CreateContainer(); + Assert.That(container.Proxy.Chunks, Is.Empty); + + ILine line = container.CreateLine(); + + Assert.That(container.Proxy.Chunks, Has.Count.EqualTo(1)); + Assert.That(ChunkOf(line).Renderer.sharedMaterials, Is.EqualTo(container.Proxy.Materials)); } [Test] @@ -96,7 +108,7 @@ public IEnumerator Dispose_LeavesCallerMaterialAlive() Material material = Track(new Material(Shader.Find(ShaderName))); WireframeContainer container = CreateContainer(new WireframeContainerSettings { Material = material }); - Assert.That(ChunkOf(container).Renderer.sharedMaterial, Is.SameAs(material)); + Assert.That(ChunkOf(container.CreateLine()).Renderer.sharedMaterial, Is.SameAs(material)); container.Dispose(); yield return null; @@ -110,7 +122,7 @@ public void Settings_NameAndLayer_ApplyToTheContainersGameObjects() Assert.That(container.Proxy.gameObject.name, Is.EqualTo("Debug Lines")); Assert.That(container.Proxy.gameObject.layer, Is.EqualTo(5)); - Assert.That(ChunkOf(container).Renderer.gameObject.layer, Is.EqualTo(5)); + Assert.That(ChunkOf(container.CreateLine()).Renderer.gameObject.layer, Is.EqualTo(5)); } [Test] @@ -229,9 +241,9 @@ private static string Describe(MethodInfo method) [UnityTest] public IEnumerator Rendering_LogsNoErrorsWhileBuffersGrow() { - // The test framework fails a test on any logged error while rendering. The shapes added here outgrow - // 16-bit indices, so the index format switches while a camera renders. Batch mode draws no screen cameras, - // so this one renders into a texture, which is also what applies the edits. + // The test framework fails a test on any logged error while rendering. The shapes added here grow the first + // chunk to its limit and start a second one between renders. Batch mode draws no screen cameras, so this + // one renders into a texture, which is also what applies the edits. RenderTexture target = Track(new RenderTexture(64, 64, 24)); Camera camera = Track(new GameObject("Camera")).AddComponent(); camera.transform.position = new Vector3(0f, 0f, -20f); @@ -258,8 +270,12 @@ public IEnumerator Rendering_LogsNoErrorsWhileBuffersGrow() yield return null; } - Assert.That(ChunkOf(container).ShapeCount, Is.GreaterThan(21000)); - Assert.That(ChunkOf(container).Mesh.indexFormat, Is.EqualTo(UnityEngine.Rendering.IndexFormat.UInt32)); + Assert.That(ShapeCountOf(container), Is.GreaterThan(21000)); + Assert.That(container.Proxy.Chunks, Has.Count.GreaterThanOrEqualTo(2)); + foreach (MeshChunk chunk in container.Proxy.Chunks) + { + Assert.That(chunk.Mesh.indexFormat, Is.EqualTo(UnityEngine.Rendering.IndexFormat.UInt16)); + } } } } diff --git a/UnityPackage/Tests/Runtime/WireframesTestBase.cs b/UnityPackage/Tests/Runtime/WireframesTestBase.cs index 7defd73..b1ac46a 100644 --- a/UnityPackage/Tests/Runtime/WireframesTestBase.cs +++ b/UnityPackage/Tests/Runtime/WireframesTestBase.cs @@ -56,19 +56,36 @@ protected T Track(T target) where T : Object return target; } + /// The container's first chunk, which holds every shape of a test that creates only a few. private protected static MeshChunk ChunkOf(WireframeContainer container) { return container.Proxy.Chunks[0]; } + private protected static MeshChunk ChunkOf(IShape shape) + { + return ((Shape)shape).Chunk; + } + + protected static int ShapeCountOf(WireframeContainer container) + { + int count = 0; + foreach (MeshChunk chunk in container.Proxy.Chunks) + { + count += chunk.ShapeCount; + } + return count; + } + /// - /// Applies pending edits and reads the bones, then skins the uploaded vertices on the CPU with the matrices the - /// shader receives, and returns world-space vertices. ShaderTests checks that the GPU draws the same. + /// Applies pending edits and reads the bones, then skins the vertices uploaded to , or + /// to the first chunk, on the CPU with the matrices the shader receives, and returns world-space vertices. + /// ShaderTests checks that the GPU draws the same. /// - protected static Vector3[] FlushAndBake(WireframeContainer container) + private protected static Vector3[] FlushAndBake(WireframeContainer container, MeshChunk chunk = null) { container.Proxy.Flush(); - MeshChunk chunk = ChunkOf(container); + chunk ??= ChunkOf(container); BoneRegistry bones = container.Proxy.Bones; Vector3[] positions = chunk.Positions; float[] boneIndices = chunk.BoneIndices; @@ -83,7 +100,7 @@ protected static Vector3[] FlushAndBake(WireframeContainer container) /// Applies pending edits, then skins the mesh on the CPU and returns the shape's world-space vertices. protected static Vector3[] BakeShape(WireframeContainer container, IShape shape) { - Vector3[] baked = FlushAndBake(container); + Vector3[] baked = FlushAndBake(container, ChunkOf(shape)); Shape target = (Shape)shape; Vector3[] vertices = new Vector3[target.VertexCount]; Array.Copy(baked, target.VertexStart, vertices, 0, vertices.Length); @@ -93,8 +110,8 @@ protected static Vector3[] BakeShape(WireframeContainer container, IShape shape) /// Applies pending edits, then skins the mesh on the CPU and returns the world-space ends of the shape's edges. protected static (Vector3 A, Vector3 B)[] BakeEdges(WireframeContainer container, IShape shape) { - Vector3[] baked = FlushAndBake(container); - int[] indices = ChunkOf(container).Mesh.GetIndices(0); + Vector3[] baked = FlushAndBake(container, ChunkOf(shape)); + int[] indices = ChunkOf(shape).Mesh.GetIndices(0); Shape target = (Shape)shape; (Vector3 A, Vector3 B)[] edges = new (Vector3, Vector3)[target.EdgeCount]; for (int edge = 0; edge < edges.Length; edge++) From 64cf5481a413ebcbfc6adeb337f2e0e706ced28a Mon Sep 17 00:00:00 2001 From: Roman Likhadievski Date: Tue, 29 Sep 2026 16:01:48 +0300 Subject: [PATCH 06/13] fix: reliability --- .../Runtime/Container/WireframeContainer.cs | 17 +- UnityPackage/Runtime/Core/AxialShape.cs | 5 +- UnityPackage/Runtime/Core/CirclePoints.cs | 41 ++++ .../Runtime/Core/CirclePoints.cs.meta | 2 + UnityPackage/Runtime/Core/DirtyRanges.cs | 27 ++- UnityPackage/Runtime/Core/EdgeSource.cs | 44 ++++ UnityPackage/Runtime/Core/EdgeSource.cs.meta | 2 + UnityPackage/Runtime/Core/IShapeHost.cs | 14 ++ UnityPackage/Runtime/Core/IShapeHost.cs.meta | 2 + UnityPackage/Runtime/Core/MainThread.cs | 37 +++ UnityPackage/Runtime/Core/MainThread.cs.meta | 2 + UnityPackage/Runtime/Core/PatternCache.cs | 51 +++- UnityPackage/Runtime/Core/PointShape.cs | 25 +- UnityPackage/Runtime/Core/RectangleShape.cs | 5 +- UnityPackage/Runtime/Core/RigidShape.cs | 17 +- UnityPackage/Runtime/Core/Ring.cs | 42 ++-- UnityPackage/Runtime/Core/Shape.cs | 119 +++++++--- UnityPackage/Runtime/Core/WireframesLog.cs | 23 ++ .../Runtime/Core/WireframesLog.cs.meta | 2 + .../Runtime/Rendering/HeadlessHost.cs | 62 +++++ .../Runtime/Rendering/HeadlessHost.cs.meta | 2 + UnityPackage/Runtime/Rendering/MeshChunk.cs | 110 +++++++-- UnityPackage/Runtime/Rendering/MeshProxy.cs | 120 ++++++++-- UnityPackage/Runtime/Shapes/Box/Box.cs | 4 +- UnityPackage/Runtime/Shapes/Box/BoxFactory.cs | 9 +- .../Runtime/Shapes/Capsule/Capsule.cs | 2 - .../Runtime/Shapes/Capsule/CapsuleFactory.cs | 19 +- UnityPackage/Runtime/Shapes/Circle/Circle.cs | 3 +- .../Runtime/Shapes/Circle/CircleFactory.cs | 21 +- UnityPackage/Runtime/Shapes/Cone/Cone.cs | 2 - .../Runtime/Shapes/Cone/ConeFactory.cs | 17 +- .../Runtime/Shapes/Cylinder/Cylinder.cs | 2 - .../Shapes/Cylinder/CylinderFactory.cs | 17 +- .../Runtime/Shapes/Ellipse/Ellipse.cs | 3 +- .../Runtime/Shapes/Ellipse/EllipseFactory.cs | 17 +- .../Runtime/Shapes/Ellipsoid/Ellipsoid.cs | 2 - .../Shapes/Ellipsoid/EllipsoidFactory.cs | 15 +- .../Runtime/Shapes/Frustum/Frustum.cs | 8 +- .../Runtime/Shapes/Frustum/FrustumFactory.cs | 18 +- UnityPackage/Runtime/Shapes/IRigidShape.cs | 1 + UnityPackage/Runtime/Shapes/Line/ILine.cs | 2 + UnityPackage/Runtime/Shapes/Line/Line.cs | 8 +- .../Runtime/Shapes/Line/LineFactory.cs | 9 +- .../Runtime/Shapes/Polyline/IPolyline.cs | 1 + .../Runtime/Shapes/Polyline/Polyline.cs | 10 +- .../Shapes/Polyline/PolylineFactory.cs | 24 +- .../Runtime/Shapes/Pyramid/Pyramid.cs | 3 +- .../Runtime/Shapes/Pyramid/PyramidFactory.cs | 9 +- .../Runtime/Shapes/Rectangle/Rectangle.cs | 4 +- .../Shapes/Rectangle/RectangleFactory.cs | 10 +- .../RoundedRectangle/RoundedRectangle.cs | 6 +- .../RoundedRectangleFactory.cs | 16 +- UnityPackage/Runtime/Shapes/Sphere/Sphere.cs | 2 - .../Runtime/Shapes/Sphere/SphereFactory.cs | 17 +- .../Shapes/SpikedSphere/SpikedSphere.cs | 5 +- .../SpikedSphere/SpikedSphereFactory.cs | 9 +- .../Runtime/Shapes/Stadium/Stadium.cs | 2 - .../Runtime/Shapes/Stadium/StadiumFactory.cs | 21 +- UnityPackage/Runtime/Shapes/Star/Star.cs | 16 +- .../Runtime/Shapes/Star/StarFactory.cs | 16 +- .../Tests/Editor/BoneValidationTests.cs | 62 +++++ .../Tests/Editor/BoneValidationTests.cs.meta | 2 + .../Tests/Editor/CirclePointsTests.cs | 34 +++ .../Tests/Editor/CirclePointsTests.cs.meta | 2 + UnityPackage/Tests/Editor/DirtyRangesTests.cs | 31 +++ .../Tests/Runtime/ReliabilityTests.cs | 217 ++++++++++++++++++ .../Tests/Runtime/ReliabilityTests.cs.meta | 2 + 67 files changed, 1165 insertions(+), 306 deletions(-) create mode 100644 UnityPackage/Runtime/Core/CirclePoints.cs create mode 100644 UnityPackage/Runtime/Core/CirclePoints.cs.meta create mode 100644 UnityPackage/Runtime/Core/EdgeSource.cs create mode 100644 UnityPackage/Runtime/Core/EdgeSource.cs.meta create mode 100644 UnityPackage/Runtime/Core/IShapeHost.cs create mode 100644 UnityPackage/Runtime/Core/IShapeHost.cs.meta create mode 100644 UnityPackage/Runtime/Core/MainThread.cs create mode 100644 UnityPackage/Runtime/Core/MainThread.cs.meta create mode 100644 UnityPackage/Runtime/Core/WireframesLog.cs create mode 100644 UnityPackage/Runtime/Core/WireframesLog.cs.meta create mode 100644 UnityPackage/Runtime/Rendering/HeadlessHost.cs create mode 100644 UnityPackage/Runtime/Rendering/HeadlessHost.cs.meta create mode 100644 UnityPackage/Tests/Editor/BoneValidationTests.cs create mode 100644 UnityPackage/Tests/Editor/BoneValidationTests.cs.meta create mode 100644 UnityPackage/Tests/Editor/CirclePointsTests.cs create mode 100644 UnityPackage/Tests/Editor/CirclePointsTests.cs.meta create mode 100644 UnityPackage/Tests/Runtime/ReliabilityTests.cs create mode 100644 UnityPackage/Tests/Runtime/ReliabilityTests.cs.meta diff --git a/UnityPackage/Runtime/Container/WireframeContainer.cs b/UnityPackage/Runtime/Container/WireframeContainer.cs index c8ccd09..c1629c0 100644 --- a/UnityPackage/Runtime/Container/WireframeContainer.cs +++ b/UnityPackage/Runtime/Container/WireframeContainer.cs @@ -11,7 +11,9 @@ namespace reromanlee.Wireframes /// /// Main thread only. Each shape type adds its Create methods as extension methods, declared in a factory class next /// to the shape, such as . The container creates a GameObject in the active scene; edits - /// made to its shapes are uploaded right before a camera renders them. + /// made to its shapes are uploaded right before a camera renders them. Without a graphics device, as in server + /// builds, or without a usable shader, shapes keep working but nothing is drawn or uploaded. Bones must be scene + /// objects: Create methods and bone setters throw for a prefab asset. /// public sealed class WireframeContainer : IDisposable { @@ -30,6 +32,7 @@ public WireframeContainer() : this(null) /// A setting is outside its valid range. public WireframeContainer(WireframeContainerSettings settings) { + MainThread.Check(); settings ??= new WireframeContainerSettings(); settings.Validate(nameof(settings)); @@ -72,17 +75,25 @@ internal MeshProxy Proxy /// Removes the container and disposes every shape it created. Calling it again does nothing. public void Dispose() { + MainThread.Check(); if (_isDisposed) { return; } - _proxy.Shutdown(); - UnityObjects.Destroy(_proxy.gameObject); + try + { + _proxy.Shutdown(); + } + finally + { + UnityObjects.Destroy(_proxy.gameObject); + } } /// The proxy that a factory adds a new shape to, after checking . internal static MeshProxy ProxyOf(WireframeContainer container) { + MainThread.Check(); if (container == null) { throw new ArgumentNullException(nameof(container)); diff --git a/UnityPackage/Runtime/Core/AxialShape.cs b/UnityPackage/Runtime/Core/AxialShape.cs index 842cdee..bdaff49 100644 --- a/UnityPackage/Runtime/Core/AxialShape.cs +++ b/UnityPackage/Runtime/Core/AxialShape.cs @@ -12,13 +12,12 @@ internal abstract class AxialShape : RigidShape, IAxialShape private float _length; protected AxialShape( - MeshProxy proxy, int vertexCount, - int[] edgePattern, + EdgeSource edgeSource, Transform bone, Vector3 localPosition, Quaternion localRotation, - float length) : base(proxy, vertexCount, edgePattern, bone, localPosition, localRotation) + float length) : base(vertexCount, edgeSource, bone, localPosition, localRotation) { _length = length; } diff --git a/UnityPackage/Runtime/Core/CirclePoints.cs b/UnityPackage/Runtime/Core/CirclePoints.cs new file mode 100644 index 0000000..88a21bb --- /dev/null +++ b/UnityPackage/Runtime/Core/CirclePoints.cs @@ -0,0 +1,41 @@ +using System; +using System.Runtime.CompilerServices; +using UnityEngine; + +namespace reromanlee.Wireframes +{ + /// + /// Points of a unit circle at equal angles, starting at angle 0 and turning from +X toward +Y, worked out one after + /// another instead of kept in a table. Each step rotates the last point in double precision, so even a full turn of + /// thousands of steps stays far closer than float precision can tell. + /// + internal struct CirclePoints + { + private readonly double _stepCosine; + private readonly double _stepSine; + private double _x; + private double _y; + + /// Points in a full turn. + /// Index of the first point to return. + internal CirclePoints(int count, int first = 0) + { + double step = 2.0 * Math.PI / count; + _stepCosine = Math.Cos(step); + _stepSine = Math.Sin(step); + _x = Math.Cos(step * first); + _y = Math.Sin(step * first); + } + + /// Returns the current point and moves on to the next one: (cosine, sine) of its angle. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal Vector2 Next() + { + Vector2 point = new((float)_x, (float)_y); + double x = _x * _stepCosine - _y * _stepSine; + _y = _x * _stepSine + _y * _stepCosine; + _x = x; + return point; + } + } +} diff --git a/UnityPackage/Runtime/Core/CirclePoints.cs.meta b/UnityPackage/Runtime/Core/CirclePoints.cs.meta new file mode 100644 index 0000000..2eff73a --- /dev/null +++ b/UnityPackage/Runtime/Core/CirclePoints.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 54f40c841f0745ad305b403dd8d4841d \ No newline at end of file diff --git a/UnityPackage/Runtime/Core/DirtyRanges.cs b/UnityPackage/Runtime/Core/DirtyRanges.cs index 6455098..8e5a34f 100644 --- a/UnityPackage/Runtime/Core/DirtyRanges.cs +++ b/UnityPackage/Runtime/Core/DirtyRanges.cs @@ -13,11 +13,16 @@ internal sealed class DirtyRanges private const int MergeGap = 64; // Past this many separate ranges, a single covering upload is cheaper. private const int MaxRanges = 16; + // Past this many collected ranges, they are merged as they come, so the list stays small when no flush clears + // it, such as while nothing renders. + private const int MaxCollectedRanges = 256; private static readonly Comparer ByStart = Comparer.Create((a, b) => a.start.CompareTo(b.start)); private RangeInt[] _ranges = new RangeInt[16]; private int _count; + // Once so many ranges came in that they collapsed into one covering range, later ones only widen it. + private bool _isCovering; internal int Count { @@ -35,9 +40,28 @@ internal void Add(int start, int length) { return; } + if (_isCovering) + { + RangeInt covering = _ranges[0]; + int coveringStart = Math.Min(covering.start, start); + _ranges[0] = new RangeInt(coveringStart, Math.Max(covering.end, start + length) - coveringStart); + return; + } if (_count == _ranges.Length) { - Array.Resize(ref _ranges, _count * 2); + if (_count >= MaxCollectedRanges) + { + _isCovering = Merge() == 1; + if (_isCovering) + { + Add(start, length); + return; + } + } + if (_count == _ranges.Length) + { + Array.Resize(ref _ranges, _count * 2); + } } _ranges[_count++] = new RangeInt(start, length); } @@ -45,6 +69,7 @@ internal void Add(int start, int length) internal void Clear() { _count = 0; + _isCovering = false; } /// Sorts and merges the collected ranges in place and returns how many remain. diff --git a/UnityPackage/Runtime/Core/EdgeSource.cs b/UnityPackage/Runtime/Core/EdgeSource.cs new file mode 100644 index 0000000..c5cf830 --- /dev/null +++ b/UnityPackage/Runtime/Core/EdgeSource.cs @@ -0,0 +1,44 @@ +namespace reromanlee.Wireframes +{ + /// + /// Where a shape's edge pattern comes from: an array of its own, or a that shares one + /// between the live shapes of the same resolution. The shape takes the pattern when it is attached and gives it + /// back when it is disposed. + /// + internal readonly struct EdgeSource + { + private readonly int[] _pattern; + private readonly PatternCache _cache; + private readonly int _resolution; + + internal EdgeSource(PatternCache cache, int resolution) + { + _pattern = null; + _cache = cache; + _resolution = resolution; + } + + private EdgeSource(int[] pattern) + { + _pattern = pattern; + _cache = null; + _resolution = 0; + } + + /// A pattern owned by one shape: pairs of local vertex indices, one pair per edge. + public static implicit operator EdgeSource(int[] pattern) + { + return new EdgeSource(pattern); + } + + internal int[] Acquire() + { + return _cache != null ? _cache.Acquire(_resolution) : _pattern; + } + + internal void Release() + { + _cache?.Release(_resolution); + } + } +} diff --git a/UnityPackage/Runtime/Core/EdgeSource.cs.meta b/UnityPackage/Runtime/Core/EdgeSource.cs.meta new file mode 100644 index 0000000..c0dee0e --- /dev/null +++ b/UnityPackage/Runtime/Core/EdgeSource.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f46e60cc29a7836d428ad0d8ba856509 \ No newline at end of file diff --git a/UnityPackage/Runtime/Core/IShapeHost.cs b/UnityPackage/Runtime/Core/IShapeHost.cs new file mode 100644 index 0000000..0a96bad --- /dev/null +++ b/UnityPackage/Runtime/Core/IShapeHost.cs @@ -0,0 +1,14 @@ +namespace reromanlee.Wireframes +{ + /// What holds a shape once it is attached: a chunk that draws it, or a host that only keeps it. + internal interface IShapeHost + { + /// Registry of the bones that the host's shapes follow. + BoneRegistry Bones { get; } + + /// Queues to be written on the next flush. + void Enqueue(Shape shape); + + void Remove(Shape shape); + } +} diff --git a/UnityPackage/Runtime/Core/IShapeHost.cs.meta b/UnityPackage/Runtime/Core/IShapeHost.cs.meta new file mode 100644 index 0000000..0077169 --- /dev/null +++ b/UnityPackage/Runtime/Core/IShapeHost.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: d18d7f2b3e89a9662224139ca00a29b7 \ No newline at end of file diff --git a/UnityPackage/Runtime/Core/MainThread.cs b/UnityPackage/Runtime/Core/MainThread.cs new file mode 100644 index 0000000..cbc681c --- /dev/null +++ b/UnityPackage/Runtime/Core/MainThread.cs @@ -0,0 +1,37 @@ +using System; +using System.Diagnostics; +using System.Threading; +using UnityEngine; + +namespace reromanlee.Wireframes +{ + /// + /// Catches calls from other threads in the Editor and development builds, where they would race the render-time + /// flush and corrupt shapes without a word. Release builds leave the check out. + /// + internal static class MainThread + { + private const int Unknown = -1; + + private static int _id = Unknown; + + /// The caller isn't on Unity's main thread. + [Conditional("UNITY_EDITOR"), Conditional("DEVELOPMENT_BUILD")] + internal static void Check() + { + if (_id != Unknown && Thread.CurrentThread.ManagedThreadId != _id) + { + throw new InvalidOperationException("Wireframes can only be used from Unity's main thread."); + } + } + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] +#if UNITY_EDITOR + [UnityEditor.InitializeOnLoadMethod] +#endif + private static void Capture() + { + _id = Thread.CurrentThread.ManagedThreadId; + } + } +} diff --git a/UnityPackage/Runtime/Core/MainThread.cs.meta b/UnityPackage/Runtime/Core/MainThread.cs.meta new file mode 100644 index 0000000..d795024 --- /dev/null +++ b/UnityPackage/Runtime/Core/MainThread.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e1cc87f791cbea88a792c9dc5befd072 \ No newline at end of file diff --git a/UnityPackage/Runtime/Core/PatternCache.cs b/UnityPackage/Runtime/Core/PatternCache.cs index 9275960..44a6004 100644 --- a/UnityPackage/Runtime/Core/PatternCache.cs +++ b/UnityPackage/Runtime/Core/PatternCache.cs @@ -4,12 +4,13 @@ namespace reromanlee.Wireframes { /// - /// Edge patterns that depend only on a shape's resolution. Shapes only read their pattern, so each one is built - /// on first use and then shared by every shape with the same resolution. + /// Edge patterns of one shape type by resolution, built on first use and shared by every live shape with that + /// resolution. A pattern is dropped when the last shape using it is disposed, so the cache only ever holds what + /// live shapes need. /// internal sealed class PatternCache { - private readonly Dictionary _patterns = new(); + private readonly Dictionary _entries = new(); private readonly Func _build; internal PatternCache(Func build) @@ -17,14 +18,48 @@ internal PatternCache(Func build) _build = build; } - internal int[] Get(int resolution) + /// The pattern for , taken from the cache once the shape is attached. + internal EdgeSource Get(int resolution) { - if (!_patterns.TryGetValue(resolution, out int[] pattern)) + return new EdgeSource(this, resolution); + } + + internal bool Contains(int resolution) + { + return _entries.ContainsKey(resolution); + } + + internal int[] Acquire(int resolution) + { + if (!_entries.TryGetValue(resolution, out Entry entry)) + { + entry.Pattern = _build(resolution); + } + entry.UserCount++; + _entries[resolution] = entry; + return entry.Pattern; + } + + internal void Release(int resolution) + { + if (!_entries.TryGetValue(resolution, out Entry entry)) { - pattern = _build(resolution); - _patterns.Add(resolution, pattern); + return; } - return pattern; + if (--entry.UserCount == 0) + { + _entries.Remove(resolution); + } + else + { + _entries[resolution] = entry; + } + } + + private struct Entry + { + public int[] Pattern; + public int UserCount; } } } diff --git a/UnityPackage/Runtime/Core/PointShape.cs b/UnityPackage/Runtime/Core/PointShape.cs index d29141c..faeefaf 100644 --- a/UnityPackage/Runtime/Core/PointShape.cs +++ b/UnityPackage/Runtime/Core/PointShape.cs @@ -9,7 +9,7 @@ namespace reromanlee.Wireframes /// internal abstract class PointShape : Shape { - protected PointShape(MeshProxy proxy, int pointCount, int[] edgePattern) : base(proxy, pointCount, edgePattern) + protected PointShape(int pointCount, EdgeSource edgeSource) : base(pointCount, edgeSource) { } @@ -63,7 +63,12 @@ public Transform GetBone(int index) public void SetBone(int index, Transform bone) { EnsureUsable(); + bone = CheckBone(bone, nameof(bone)); ref ShapePoint point = ref LivePoint(index); + if (ReferenceEquals(bone, point.Bone)) + { + return; + } Vector3 worldPosition = ToWorld(point.Bone, point.LocalPosition); ReplaceBone(ref point.Bone, ref point.BoneSlot, bone); point.LocalPosition = ToLocal(point.Bone, worldPosition); @@ -104,6 +109,15 @@ internal override void WriteBoneIndices(Span boneIndices) } } + protected override void AcquireBones(BoneRegistry bones) + { + for (int i = 0; i < VertexCount; i++) + { + ref ShapePoint point = ref Point(i); + point.BoneSlot = bones.Acquire(point.Bone); + } + } + protected override void ReleaseBones(BoneRegistry bones) { for (int i = 0; i < VertexCount; i++) @@ -112,11 +126,12 @@ protected override void ReleaseBones(BoneRegistry bones) } } - /// Attaches point to , keeping its local position. - protected void AttachPoint(int index, Transform bone) + /// + /// Sets the bone of point while the shape is being built, keeping its local position. + /// + protected void InitializeBone(int index, Transform bone, string parameterName) { - ref ShapePoint point = ref Point(index); - ReplaceBone(ref point.Bone, ref point.BoneSlot, bone); + Point(index).Bone = CheckBone(bone, parameterName); } /// Storage of point . diff --git a/UnityPackage/Runtime/Core/RectangleShape.cs b/UnityPackage/Runtime/Core/RectangleShape.cs index 18b0134..a100d1b 100644 --- a/UnityPackage/Runtime/Core/RectangleShape.cs +++ b/UnityPackage/Runtime/Core/RectangleShape.cs @@ -11,13 +11,12 @@ internal abstract class RectangleShape : RigidShape private Vector2 _size; protected RectangleShape( - MeshProxy proxy, int vertexCount, - int[] edgePattern, + EdgeSource edgeSource, Transform bone, Vector3 localCenter, Quaternion localRotation, - Vector2 size) : base(proxy, vertexCount, edgePattern, bone, localCenter, localRotation) + Vector2 size) : base(vertexCount, edgeSource, bone, localCenter, localRotation) { _size = size; } diff --git a/UnityPackage/Runtime/Core/RigidShape.cs b/UnityPackage/Runtime/Core/RigidShape.cs index 6d81106..7a70914 100644 --- a/UnityPackage/Runtime/Core/RigidShape.cs +++ b/UnityPackage/Runtime/Core/RigidShape.cs @@ -16,14 +16,13 @@ internal abstract class RigidShape : Shape, IRigidShape private int _boneSlot; protected RigidShape( - MeshProxy proxy, int vertexCount, - int[] edgePattern, + EdgeSource edgeSource, Transform bone, Vector3 localPosition, - Quaternion localRotation) : base(proxy, vertexCount, edgePattern) + Quaternion localRotation) : base(vertexCount, edgeSource) { - ReplaceBone(ref _bone, ref _boneSlot, bone); + _bone = CheckBone(bone, nameof(bone)); _localPosition = localPosition; _localRotation = Quaternion.Normalize(localRotation); } @@ -114,6 +113,11 @@ public Transform Bone set { EnsureUsable(); + value = CheckBone(value, nameof(value)); + if (ReferenceEquals(value, _bone)) + { + return; + } Vector3 worldPosition = ToWorld(_bone, _localPosition); Quaternion worldRotation = ToWorld(_bone, _localRotation); float oldScale = UniformScale(_bone); @@ -154,6 +158,11 @@ internal sealed override void WriteBoneIndices(Span boneIndices) boneIndices.Fill(_boneSlot); } + protected sealed override void AcquireBones(BoneRegistry bones) + { + _boneSlot = bones.Acquire(_bone); + } + protected sealed override void ReleaseBones(BoneRegistry bones) { bones.Release(_boneSlot); diff --git a/UnityPackage/Runtime/Core/Ring.cs b/UnityPackage/Runtime/Core/Ring.cs index 94bea21..8e03535 100644 --- a/UnityPackage/Runtime/Core/Ring.cs +++ b/UnityPackage/Runtime/Core/Ring.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using UnityEngine; namespace reromanlee.Wireframes @@ -9,18 +8,19 @@ internal static class Ring { internal const int DefaultSegmentCount = 32; + /// Most segments a ring can have, which keeps a shape and its shared edge pattern to a sane size. + internal const int MaxSegmentCount = 1024; + /// Edges of shapes that are one closed ring, by vertex count. internal static readonly PatternCache Patterns = new(BuildEdges); - // Cosine and sine around a unit circle for each segment count in use, shared by every ring with that count. - private static readonly Dictionary UnitCircles = new(); - /// Returns once it is checked, so it can be used in a base constructor call. internal static int CheckSegmentCount(int segmentCount) { - if (segmentCount < 3) + if (segmentCount < 3 || segmentCount > MaxSegmentCount) { - throw new ArgumentOutOfRangeException(nameof(segmentCount), segmentCount, "A ring needs at least 3 segments."); + throw new ArgumentOutOfRangeException( + nameof(segmentCount), segmentCount, $"A ring needs 3 to {MaxSegmentCount} segments."); } return segmentCount; } @@ -31,10 +31,12 @@ internal static int CheckSegmentCount(int segmentCount) /// internal static int CheckQuarterSegmentCount(int segmentCount) { - if (segmentCount < 4 || segmentCount % 4 != 0) + if (segmentCount < 4 || segmentCount % 4 != 0 || segmentCount > MaxSegmentCount) { throw new ArgumentOutOfRangeException( - nameof(segmentCount), segmentCount, "This shape needs a positive multiple of 4 segments."); + nameof(segmentCount), + segmentCount, + $"This shape needs a multiple of 4 segments, from 4 to {MaxSegmentCount}."); } return segmentCount; } @@ -46,10 +48,11 @@ internal static int CheckQuarterSegmentCount(int segmentCount) /// internal static void Write(Span positions, Vector3 center, Vector3 axisU, Vector3 axisV) { - Vector2[] circle = UnitCircle(positions.Length); + CirclePoints circle = new(positions.Length); for (int i = 0; i < positions.Length; i++) { - positions[i] = center + axisU * circle[i].x + axisV * circle[i].y; + Vector2 point = circle.Next(); + positions[i] = center + axisU * point.x + axisV * point.y; } } @@ -65,25 +68,6 @@ internal static void AddEdges(int[] pattern, ref int cursor, int first, int coun } } - /// - /// Cosine and sine of angles evenly spread over a full turn, starting at 0. The table - /// is shared, so callers must not change it. - /// - internal static Vector2[] UnitCircle(int count) - { - if (!UnitCircles.TryGetValue(count, out Vector2[] circle)) - { - circle = new Vector2[count]; - for (int i = 0; i < count; i++) - { - double angle = 2.0 * Math.PI * i / count; - circle[i] = new Vector2((float)Math.Cos(angle), (float)Math.Sin(angle)); - } - UnitCircles.Add(count, circle); - } - return circle; - } - private static int[] BuildEdges(int count) { int[] pattern = new int[count * 2]; diff --git a/UnityPackage/Runtime/Core/Shape.cs b/UnityPackage/Runtime/Core/Shape.cs index 04d1faf..c0b6e4b 100644 --- a/UnityPackage/Runtime/Core/Shape.cs +++ b/UnityPackage/Runtime/Core/Shape.cs @@ -4,42 +4,40 @@ namespace reromanlee.Wireframes { /// - /// Base of every shape. It owns the shape's vertex block and edges inside a chunk, queues edits for the next - /// flush and handles disposal, so derived shapes only describe their geometry. + /// Base of every shape. Derived constructors only check and store the shape's state; then takes + /// its bones and edge pattern and hands it to a host, so a shape that fails to build leaves nothing behind. It + /// queues edits for the next flush and handles disposal, so derived shapes only describe their geometry. /// internal abstract class Shape : IShape, IEdgeOwner { - private readonly int[] _edgePattern; - private readonly int[] _edgeSlots; - private MeshChunk _chunk; + private readonly EdgeSource _edgeSource; + private int[] _edgePattern; + private int[] _edgeSlots; + private IShapeHost _host; private DirtyFlags _dirty; - /// Pairs of local vertex indices, one pair per edge. - protected Shape(MeshProxy proxy, int vertexCount, int[] edgePattern) + protected Shape(int vertexCount, EdgeSource edgeSource) { VertexCount = vertexCount; - _edgePattern = edgePattern; - _edgeSlots = new int[edgePattern.Length / 2]; - _chunk = proxy.Attach(this); - MarkDirty(DirtyFlags.All); + _edgeSource = edgeSource; } public bool IsDisposed { - get => _chunk == null; + get => _host == null; } - /// The chunk holding the shape, or null once it is disposed. + /// The chunk that draws the shape, or null when it is disposed or nothing can draw it. internal MeshChunk Chunk { - get => _chunk; + get => _host as MeshChunk; } internal int VertexCount { get; } internal int VertexStart { get; set; } - /// Position in the chunk's shape list. + /// Position in its host's shape list. internal int ShapeIndex { get; set; } internal int EdgeCount @@ -47,26 +45,54 @@ internal int EdgeCount get => _edgeSlots.Length; } + /// True once a problem with the shape was logged, so each shape logs at most one. + internal bool HasReportedProblem { get; set; } + /// The registry of the bones this shape follows. protected BoneRegistry Bones { - get => _chunk.Bones; + get => _host.Bones; } public void Dispose() { - if (_chunk == null) + MainThread.Check(); + if (_host == null) { return; } - MeshChunk chunk = _chunk; - chunk.Remove(this); - ReleaseBones(chunk.Bones); - _chunk = null; + IShapeHost host = _host; + host.Remove(this); + ReleaseBones(host.Bones); + _edgeSource.Release(); + _host = null; } public abstract void SetColor(Color color); + /// + /// Takes the shape's edge pattern and bones and adds it to . If that fails, everything + /// taken is given back. + /// + internal void Attach(MeshProxy proxy) + { + _edgePattern = _edgeSource.Acquire(); + _edgeSlots = new int[_edgePattern.Length / 2]; + BoneRegistry bones = proxy.Bones; + AcquireBones(bones); + try + { + _host = proxy.Attach(this); + } + catch + { + ReleaseBones(bones); + _edgeSource.Release(); + throw; + } + MarkDirty(DirtyFlags.All); + } + internal int GetEdgeSlot(int edge) { return _edgeSlots[edge]; @@ -87,7 +113,7 @@ internal void MarkDirty(DirtyFlags flags) { if (_dirty == DirtyFlags.None) { - _chunk.Enqueue(this); + _host.Enqueue(this); } _dirty |= flags; } @@ -99,10 +125,11 @@ internal DirtyFlags TakeDirty() return dirty; } - /// Marks the shape disposed when its whole chunk goes away. + /// Marks the shape disposed when its whole host goes away, bone registry included. internal void Detach() { - _chunk = null; + _edgeSource.Release(); + _host = null; } internal void WriteEdges(EdgeList edges, DirtyRanges ranges) @@ -121,15 +148,19 @@ internal void WriteEdges(EdgeList edges, DirtyRanges ranges) internal abstract void WriteBoneIndices(Span boneIndices); + /// Registers the bones the shape was built with, when it is attached. + protected abstract void AcquireBones(BoneRegistry bones); + protected abstract void ReleaseBones(BoneRegistry bones); /// - /// Throws once the shape is disposed, and otherwise lets it notice a bone that was destroyed, so every member - /// sees the shape in world space from then on. + /// Throws off the main thread (in the Editor and development builds) or once the shape is disposed, and + /// otherwise lets it notice a bone that was destroyed, so every member sees the shape in world space from then on. /// protected void EnsureUsable() { - if (_chunk == null) + MainThread.Check(); + if (_host == null) { throw new ObjectDisposedException(GetType().Name); } @@ -144,21 +175,39 @@ protected virtual void DetachFromDestroyedBones() { } - /// Points a bone field at and moves its registry slot along. + /// + /// Points a bone field at , already checked with , and moves its + /// registry slot along. + /// protected void ReplaceBone(ref Transform bone, ref int slot, Transform value) { - // Destroyed transforms compare equal to null; they are stored as null so they are never read again. - if (value == null) - { - value = null; - } // Acquiring first keeps a bone that is re-assigned from being released and re-registered in between. - int newSlot = _chunk.Bones.Acquire(value); - _chunk.Bones.Release(slot); + int newSlot = _host.Bones.Acquire(value); + _host.Bones.Release(slot); bone = value; slot = newSlot; } + /// + /// Returns once it is checked to be a Transform in a scene, or null for no bone and for + /// a destroyed one, which is how bone fields store them so they are never read again. + /// + /// isn't in a scene, such as a prefab asset. + protected static Transform CheckBone(Transform bone, string parameterName) + { + if (bone == null) + { + return null; + } + if (!bone.gameObject.scene.IsValid()) + { + throw new ArgumentException( + $"'{bone.name}' isn't in a scene. Shapes can follow scene objects only, not prefab assets.", + parameterName); + } + return bone; + } + /// True when was a transform that has since been destroyed. protected static bool IsDestroyed(Transform bone) { diff --git a/UnityPackage/Runtime/Core/WireframesLog.cs b/UnityPackage/Runtime/Core/WireframesLog.cs new file mode 100644 index 0000000..23808cf --- /dev/null +++ b/UnityPackage/Runtime/Core/WireframesLog.cs @@ -0,0 +1,23 @@ +using System; +using UnityEngine; +using Object = UnityEngine.Object; + +namespace reromanlee.Wireframes +{ + /// The package's console messages, all starting with [Wireframes] so they are easy to find and filter. + internal static class WireframesLog + { + private const string Prefix = "[Wireframes] "; + + internal static void Warning(string message, Object context = null) + { + Debug.LogWarning(Prefix + message, context); + } + + /// Logs as an error, followed by and its stack trace. + internal static void Error(string message, Exception exception, Object context = null) + { + Debug.LogError($"{Prefix}{message}\n{exception}", context); + } + } +} diff --git a/UnityPackage/Runtime/Core/WireframesLog.cs.meta b/UnityPackage/Runtime/Core/WireframesLog.cs.meta new file mode 100644 index 0000000..42e8c53 --- /dev/null +++ b/UnityPackage/Runtime/Core/WireframesLog.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 19d59a5e3750660e4cb59d48ef973b7d \ No newline at end of file diff --git a/UnityPackage/Runtime/Rendering/HeadlessHost.cs b/UnityPackage/Runtime/Rendering/HeadlessHost.cs new file mode 100644 index 0000000..67a2039 --- /dev/null +++ b/UnityPackage/Runtime/Rendering/HeadlessHost.cs @@ -0,0 +1,62 @@ +using System; + +namespace reromanlee.Wireframes +{ + /// + /// Holds the shapes of a container that can't draw: without a graphics device, as in server builds, or without a + /// usable shader. Shapes keep working as usual, but nothing is stored for the GPU and nothing is uploaded. + /// + internal sealed class HeadlessHost : IShapeHost, IDisposable + { + private const int InitialCapacity = 64; + + private Shape[] _shapes = new Shape[InitialCapacity]; + private int _shapeCount; + + internal HeadlessHost(BoneRegistry bones) + { + Bones = bones; + } + + public BoneRegistry Bones { get; } + + internal int ShapeCount + { + get => _shapeCount; + } + + internal void Add(Shape shape) + { + if (_shapeCount == _shapes.Length) + { + Array.Resize(ref _shapes, _shapeCount * 2); + } + shape.ShapeIndex = _shapeCount; + _shapes[_shapeCount++] = shape; + } + + public void Enqueue(Shape shape) + { + // Nothing is drawn, so there is nothing to write; the shape stays marked and never queues again. + } + + public void Remove(Shape shape) + { + int last = --_shapeCount; + Shape moved = _shapes[last]; + _shapes[shape.ShapeIndex] = moved; + moved.ShapeIndex = shape.ShapeIndex; + _shapes[last] = null; + } + + public void Dispose() + { + for (int i = 0; i < _shapeCount; i++) + { + _shapes[i].Detach(); + _shapes[i] = null; + } + _shapeCount = 0; + } + } +} diff --git a/UnityPackage/Runtime/Rendering/HeadlessHost.cs.meta b/UnityPackage/Runtime/Rendering/HeadlessHost.cs.meta new file mode 100644 index 0000000..406f607 --- /dev/null +++ b/UnityPackage/Runtime/Rendering/HeadlessHost.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f14780e79cd26cfc670dd59c804c6a34 \ No newline at end of file diff --git a/UnityPackage/Runtime/Rendering/MeshChunk.cs b/UnityPackage/Runtime/Rendering/MeshChunk.cs index c5ac679..a57c5b2 100644 --- a/UnityPackage/Runtime/Rendering/MeshChunk.cs +++ b/UnityPackage/Runtime/Rendering/MeshChunk.cs @@ -14,7 +14,7 @@ namespace reromanlee.Wireframes /// A chunk grows up to vertices, so its indices stay 16-bit. A chunk created with more /// is a large chunk: it keeps 32-bit indices for a shape bigger than that and never grows. /// - internal sealed class MeshChunk : IDisposable + internal sealed class MeshChunk : IShapeHost, IDisposable { /// /// Most vertices a chunk with 16-bit indices holds. Index 0xFFFF stays unused because some graphics APIs reserve @@ -126,7 +126,7 @@ internal MeshChunk( Flush(); } - internal BoneRegistry Bones + public BoneRegistry Bones { get => _bones; } @@ -157,6 +157,12 @@ internal int ShapeCount get => _shapeCount; } + /// Shapes queued for the next flush. + internal int PendingCount + { + get => _pendingCount; + } + internal int EdgeCount { get => _edges.Count; @@ -232,7 +238,7 @@ internal void Add(Shape shape) EmptySince = float.NaN; } - internal void Remove(Shape shape) + public void Remove(Shape shape) { // The last edge moves into each freed slot; later edges of this same shape may be among them, and // their owner callback keeps the shape's slots current while the loop runs. @@ -253,11 +259,17 @@ internal void Remove(Shape shape) _shapes[last] = null; } - internal void Enqueue(Shape shape) + public void Enqueue(Shape shape) { if (_pendingCount == _pending.Length) { - Array.Resize(ref _pending, _pendingCount * 2); + // Without renders to flush the queue, disposed shapes would pile up in it; each live shape is queued + // at most once, so dropping them keeps the queue within the number of live shapes. + RemoveDisposedPending(); + if (_pendingCount == _pending.Length) + { + Array.Resize(ref _pending, _pendingCount * 2); + } } _pending[_pendingCount++] = shape; } @@ -321,31 +333,87 @@ private void WritePendingShapes() { continue; } - - DirtyFlags dirty = shape.TakeDirty(); - int start = shape.VertexStart; - int count = shape.VertexCount; - if ((dirty & DirtyFlags.Positions) != 0) + try { - shape.WritePositions(new Span(_positions, start, count)); - _positionRanges.Add(start, count); + WriteShape(shape, shape.TakeDirty()); } - if ((dirty & DirtyFlags.Colors) != 0) + catch (Exception exception) { - shape.WriteColors(new Span(_colors, start, count)); - _colorRanges.Add(start, count); + // One broken shape must not stop the others from drawing; it keeps what it last wrote. + if (!shape.HasReportedProblem) + { + shape.HasReportedProblem = true; + WireframesLog.Error( + $"A {shape.GetType().Name} failed to update and was skipped.", exception, _renderer); + } } - if ((dirty & DirtyFlags.Bones) != 0) + } + _pendingCount = 0; + } + + private void WriteShape(Shape shape, DirtyFlags dirty) + { + int start = shape.VertexStart; + int count = shape.VertexCount; + if ((dirty & DirtyFlags.Positions) != 0) + { + shape.WritePositions(new Span(_positions, start, count)); + _positionRanges.Add(start, count); + CheckPositions(shape); + } + if ((dirty & DirtyFlags.Colors) != 0) + { + shape.WriteColors(new Span(_colors, start, count)); + _colorRanges.Add(start, count); + } + if ((dirty & DirtyFlags.Bones) != 0) + { + shape.WriteBoneIndices(new Span(_boneIndices, start, count)); + _boneRanges.Add(start, count); + } + if ((dirty & DirtyFlags.Edges) != 0) + { + shape.WriteEdges(_edges, _edgeRanges); + } + } + + /// Warns once per shape about positions that aren't finite, in the Editor and development builds. + [System.Diagnostics.Conditional("UNITY_EDITOR"), System.Diagnostics.Conditional("DEVELOPMENT_BUILD")] + private void CheckPositions(Shape shape) + { + if (shape.HasReportedProblem) + { + return; + } + int end = shape.VertexStart + shape.VertexCount; + for (int i = shape.VertexStart; i < end; i++) + { + Vector3 position = _positions[i]; + if (!float.IsFinite(position.x) || !float.IsFinite(position.y) || !float.IsFinite(position.z)) { - shape.WriteBoneIndices(new Span(_boneIndices, start, count)); - _boneRanges.Add(start, count); + shape.HasReportedProblem = true; + WireframesLog.Warning( + $"A {shape.GetType().Name} has a position that isn't a finite number, so it can't be drawn " + + "correctly. Check the positions, sizes and rotations set on it.", + _renderer); + return; } - if ((dirty & DirtyFlags.Edges) != 0) + } + } + + private void RemoveDisposedPending() + { + int kept = 0; + for (int i = 0; i < _pendingCount; i++) + { + Shape shape = _pending[i]; + if (!shape.IsDisposed) { - shape.WriteEdges(_edges, _edgeRanges); + _pending[kept++] = shape; } } - _pendingCount = 0; + Array.Clear(_pending, kept, _pendingCount - kept); + _pendingCount = kept; } private void UploadVertices() diff --git a/UnityPackage/Runtime/Rendering/MeshProxy.cs b/UnityPackage/Runtime/Rendering/MeshProxy.cs index 6e8c490..a1c036f 100644 --- a/UnityPackage/Runtime/Rendering/MeshProxy.cs +++ b/UnityPackage/Runtime/Rendering/MeshProxy.cs @@ -8,23 +8,37 @@ namespace reromanlee.Wireframes /// /// Component on the container's GameObject. Right before each render, it applies queued shape edits and reads the /// bones' matrices, so everything that moved or changed earlier in the frame shows up in that frame. It releases every - /// resource when its GameObject is destroyed, including when the scene unloads. + /// resource when its GameObject is destroyed, including when the scene unloads. Where nothing can draw, it keeps the + /// shapes in a and does no rendering work at all. /// [AddComponentMenu("")] [DisallowMultipleComponent] internal sealed class MeshProxy : MonoBehaviour { + private static bool _hasWarnedAboutShader; + private readonly BoneRegistry _bones = new(); private readonly BoneTexture _boneTexture = new(); private ChunkAllocator _chunks; + private HeadlessHost _headless; private WireframeContainer _container; private Material[] _materials; private bool _ownsMaterials; private bool _isShutDown; + private bool _hasLoggedFlushFailure; + + /// Makes new containers act as they do without a graphics device, so tests can cover server builds. + internal static bool SimulateNoGraphics { get; set; } + + /// True when the container draws nothing, because there is no graphics device or no usable shader. + internal bool IsHeadless + { + get => _headless != null; + } internal IReadOnlyList Chunks { - get => _chunks.Chunks; + get => _chunks != null ? _chunks.Chunks : Array.Empty(); } internal ChunkAllocator ChunkAllocator @@ -32,7 +46,12 @@ internal ChunkAllocator ChunkAllocator get => _chunks; } - /// The materials every chunk draws with. + internal HeadlessHost HeadlessHost + { + get => _headless; + } + + /// The materials every chunk draws with; null when headless. internal Material[] Materials { get => _materials; @@ -51,20 +70,10 @@ internal BoneTexture BoneTexture internal void Initialize(WireframeContainer container, WireframeContainerSettings settings) { _container = container; - if (settings.Material != null) - { - _materials = new[] { settings.Material }; - } - else + if (!TryCreateMaterials(settings)) { - // The shader sits in a Resources folder, which keeps it in player builds. - Shader shader = Shader.Find(WireframeMaterials.ShaderName); - if (shader == null) - { - throw new InvalidOperationException($"Shader '{WireframeMaterials.ShaderName}' was not found."); - } - _materials = WireframeMaterials.Create(shader, settings.Occlusion, settings.UseAlpha); - _ownsMaterials = true; + _headless = new HeadlessHost(_bones); + return; } _chunks = new ChunkAllocator(transform, _materials, settings.Layer, _bones); _chunks.Reserve(settings.VertexCapacity, settings.EdgeCapacity); @@ -72,15 +81,31 @@ internal void Initialize(WireframeContainer container, WireframeContainerSetting Flush(); } - /// Adds to a chunk and returns that chunk. - internal MeshChunk Attach(Shape shape) + /// Attaches a newly built and returns it. If that fails, nothing of it remains. + internal T Add(T shape) where T : Shape { + shape.Attach(this); + return shape; + } + + /// Hands to a chunk, or to the headless host, and returns it. + internal IShapeHost Attach(Shape shape) + { + if (_headless != null) + { + _headless.Add(shape); + return _headless; + } return _chunks.Attach(shape); } /// Reads the bones, writes queued shapes and uploads what changed. internal void Flush() { + if (_chunks == null) + { + return; + } _bones.ReadMatrices(); if (_boneTexture.Upload(_bones)) { @@ -96,7 +121,10 @@ internal void Shutdown() return; } _isShutDown = true; + // The container learns first, so it reads as disposed even if releasing something below throws. + _container?.OnProxyShutdown(); _chunks?.Dispose(); + _headless?.Dispose(); _boneTexture.Dispose(); if (_ownsMaterials) { @@ -106,10 +134,52 @@ internal void Shutdown() } } _materials = null; - if (_container != null) + } + + /// + /// Picks the materials to draw with, or returns false when nothing can draw: without a graphics device, silently, + /// as that is expected in server builds, and without a usable shader, with one warning per session. + /// + private bool TryCreateMaterials(WireframeContainerSettings settings) + { + if (SystemInfo.graphicsDeviceType == GraphicsDeviceType.Null || SimulateNoGraphics) + { + return false; + } + if (settings.Material != null) + { + _materials = new[] { settings.Material }; + return true; + } + // The shader sits in a Resources folder, which keeps it in player builds. + Shader shader = Shader.Find(WireframeMaterials.ShaderName); + if (shader == null || !shader.isSupported) + { + WarnAboutShader(shader == null + ? $"The shader '{WireframeMaterials.ShaderName}' isn't in this build" + : $"The shader '{WireframeMaterials.ShaderName}' isn't supported on {SystemInfo.graphicsDeviceType}"); + return false; + } + _materials = WireframeMaterials.Create(shader, settings.Occlusion, settings.UseAlpha); + _ownsMaterials = true; + return true; + } + + private void WarnAboutShader(string problem) + { + if (_hasWarnedAboutShader) { - _container.OnProxyShutdown(); + return; } + _hasWarnedAboutShader = true; + WireframesLog.Warning($"{problem}, so wireframes won't be drawn. Their shapes still work.", this); + } + + [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)] + private static void ResetWarnings() + { + // Needed when domain reload is disabled, so each Play Mode session warns again. + _hasWarnedAboutShader = false; } private void OnEnable() @@ -142,7 +212,7 @@ private void OnPreCullCamera(Camera camera) private void FlushBeforeRendering() { - if (_isShutDown) + if (_isShutDown || _chunks == null) { return; } @@ -157,8 +227,12 @@ private void FlushBeforeRendering() } catch (Exception exception) { - // An exception must never escape into Unity's rendering. - Debug.LogException(exception, this); + // An exception must never escape into Unity's rendering, nor flood the console once every frame. + if (!_hasLoggedFlushFailure) + { + _hasLoggedFlushFailure = true; + WireframesLog.Error("Updating the wireframes before a render failed.", exception, this); + } } } } diff --git a/UnityPackage/Runtime/Shapes/Box/Box.cs b/UnityPackage/Runtime/Shapes/Box/Box.cs index 581972f..fca9fd1 100644 --- a/UnityPackage/Runtime/Shapes/Box/Box.cs +++ b/UnityPackage/Runtime/Shapes/Box/Box.cs @@ -18,8 +18,8 @@ internal sealed class Box : RigidShape, IBox private Vector3 _size; - internal Box(MeshProxy proxy, Transform bone, Vector3 localCenter, Quaternion localRotation, Vector3 size) - : base(proxy, CornerCount, EdgePattern, bone, localCenter, localRotation) + internal Box(Transform bone, Vector3 localCenter, Quaternion localRotation, Vector3 size) + : base(CornerCount, EdgePattern, bone, localCenter, localRotation) { _size = size; } diff --git a/UnityPackage/Runtime/Shapes/Box/BoxFactory.cs b/UnityPackage/Runtime/Shapes/Box/BoxFactory.cs index bfd8d5f..693bbcd 100644 --- a/UnityPackage/Runtime/Shapes/Box/BoxFactory.cs +++ b/UnityPackage/Runtime/Shapes/Box/BoxFactory.cs @@ -9,14 +9,14 @@ public static class BoxFactory public static IBox CreateBox(this WireframeContainer container) { MeshProxy proxy = WireframeContainer.ProxyOf(container); - return new Box(proxy, null, Vector3.zero, Quaternion.identity, Vector3.one * ShapeDefaults.Size); + return proxy.Add(new Box(null, Vector3.zero, Quaternion.identity, Vector3.one * ShapeDefaults.Size)); } /// Creates a white, world-aligned box spanned by two opposite world-space corners. public static IBox CreateBox(this WireframeContainer container, Vector3 cornerA, Vector3 cornerB) { MeshProxy proxy = WireframeContainer.ProxyOf(container); - return new Box(proxy, null, (cornerA + cornerB) * 0.5f, Quaternion.identity, cornerB - cornerA); + return proxy.Add(new Box(null, (cornerA + cornerB) * 0.5f, Quaternion.identity, cornerB - cornerA)); } /// @@ -28,13 +28,14 @@ public static IBox CreateBox( { MeshProxy proxy = WireframeContainer.ProxyOf(container); Vector3 center = (localCornerA + localCornerB) * 0.5f; - return new Box(proxy, bone, center, Quaternion.identity, localCornerB - localCornerA); + return proxy.Add(new Box(bone, center, Quaternion.identity, localCornerB - localCornerA)); } /// Creates a white box in world space from its center, rotation and size. public static IBox CreateBox(this WireframeContainer container, Vector3 center, Quaternion rotation, Vector3 size) { - return new Box(WireframeContainer.ProxyOf(container), null, center, rotation, size); + MeshProxy proxy = WireframeContainer.ProxyOf(container); + return proxy.Add(new Box(null, center, rotation, size)); } } } diff --git a/UnityPackage/Runtime/Shapes/Capsule/Capsule.cs b/UnityPackage/Runtime/Shapes/Capsule/Capsule.cs index 5be38fc..6c9dd5a 100644 --- a/UnityPackage/Runtime/Shapes/Capsule/Capsule.cs +++ b/UnityPackage/Runtime/Shapes/Capsule/Capsule.cs @@ -12,7 +12,6 @@ internal sealed class Capsule : AxialShape, ICapsule private float _radiusB; internal Capsule( - MeshProxy proxy, Transform bone, Vector3 localPosition, Quaternion localRotation, @@ -21,7 +20,6 @@ internal Capsule( float radiusB, int segmentCount) : base( - proxy, CountVertices(Ring.CheckQuarterSegmentCount(segmentCount)), Patterns.Get(segmentCount), bone, diff --git a/UnityPackage/Runtime/Shapes/Capsule/CapsuleFactory.cs b/UnityPackage/Runtime/Shapes/Capsule/CapsuleFactory.cs index 3c50592..f6116f2 100644 --- a/UnityPackage/Runtime/Shapes/Capsule/CapsuleFactory.cs +++ b/UnityPackage/Runtime/Shapes/Capsule/CapsuleFactory.cs @@ -11,15 +11,14 @@ public static class CapsuleFactory public static ICapsule CreateCapsule(this WireframeContainer container) { MeshProxy proxy = WireframeContainer.ProxyOf(container); - return new Capsule( - proxy, + return proxy.Add(new Capsule( null, Vector3.zero, Quaternion.identity, ShapeDefaults.Size, ShapeDefaults.Radius, ShapeDefaults.Radius, - Ring.DefaultSegmentCount); + Ring.DefaultSegmentCount)); } /// @@ -27,7 +26,7 @@ public static ICapsule CreateCapsule(this WireframeContainer container) /// capsule of Physics.CapsuleCast. Its spin around the axis keeps the capsule's +Y as close to world up /// as it can. /// - /// Number of straight pieces each ring is drawn with, a positive multiple of 4. + /// Number of straight pieces each ring is drawn with, a multiple of 4 from 4 to 1024. public static ICapsule CreateCapsule( this WireframeContainer container, Vector3 centerA, @@ -42,7 +41,7 @@ public static ICapsule CreateCapsule( /// Creates a white capsule around two spheres centered on two world positions, each with its own radius. Its /// spin around the axis keeps the capsule's +Y as close to world up as it can. /// - /// Number of straight pieces each ring is drawn with, a positive multiple of 4. + /// Number of straight pieces each ring is drawn with, a multiple of 4 from 4 to 1024. public static ICapsule CreateCapsule( this WireframeContainer container, Vector3 centerA, @@ -54,14 +53,14 @@ public static ICapsule CreateCapsule( MeshProxy proxy = WireframeContainer.ProxyOf(container); Quaternion rotation = ShapeRotations.FromAxis(centerB - centerA, Vector3.up); float length = Vector3.Distance(centerA, centerB); - return new Capsule(proxy, null, centerA, rotation, length, radiusA, radiusB, segmentCount); + return proxy.Add(new Capsule(null, centerA, rotation, length, radiusA, radiusB, segmentCount)); } /// /// Creates a white capsule that follows , around two spheres centered on positions in the /// bone's local space. Its spin around the axis keeps the capsule's +Y as close to the bone's up as it can. /// - /// Number of straight pieces each ring is drawn with, a positive multiple of 4. + /// Number of straight pieces each ring is drawn with, a multiple of 4 from 4 to 1024. public static ICapsule CreateCapsule( this WireframeContainer container, Transform bone, @@ -74,14 +73,14 @@ public static ICapsule CreateCapsule( MeshProxy proxy = WireframeContainer.ProxyOf(container); Quaternion rotation = ShapeRotations.FromAxis(localCenterB - localCenterA, Vector3.up); float length = Vector3.Distance(localCenterA, localCenterB); - return new Capsule(proxy, bone, localCenterA, rotation, length, radiusA, radiusB, segmentCount); + return proxy.Add(new Capsule(bone, localCenterA, rotation, length, radiusA, radiusB, segmentCount)); } /// /// Creates a white capsule in world space whose first sphere is centered on and /// whose second sphere is centered along the +Z axis of . /// - /// Number of straight pieces each ring is drawn with, a positive multiple of 4. + /// Number of straight pieces each ring is drawn with, a multiple of 4 from 4 to 1024. public static ICapsule CreateCapsule( this WireframeContainer container, Vector3 position, @@ -92,7 +91,7 @@ public static ICapsule CreateCapsule( int segmentCount = Ring.DefaultSegmentCount) { MeshProxy proxy = WireframeContainer.ProxyOf(container); - return new Capsule(proxy, null, position, rotation, length, radiusA, radiusB, segmentCount); + return proxy.Add(new Capsule(null, position, rotation, length, radiusA, radiusB, segmentCount)); } } } diff --git a/UnityPackage/Runtime/Shapes/Circle/Circle.cs b/UnityPackage/Runtime/Shapes/Circle/Circle.cs index 97d5f55..96fe276 100644 --- a/UnityPackage/Runtime/Shapes/Circle/Circle.cs +++ b/UnityPackage/Runtime/Shapes/Circle/Circle.cs @@ -8,13 +8,12 @@ internal sealed class Circle : RigidShape, ICircle private float _radius; internal Circle( - MeshProxy proxy, Transform bone, Vector3 localCenter, Quaternion localRotation, float radius, int segmentCount) - : base(proxy, Ring.CheckSegmentCount(segmentCount), Ring.Patterns.Get(segmentCount), bone, localCenter, localRotation) + : base(Ring.CheckSegmentCount(segmentCount), Ring.Patterns.Get(segmentCount), bone, localCenter, localRotation) { _radius = radius; } diff --git a/UnityPackage/Runtime/Shapes/Circle/CircleFactory.cs b/UnityPackage/Runtime/Shapes/Circle/CircleFactory.cs index 5ac2a57..05d40db 100644 --- a/UnityPackage/Runtime/Shapes/Circle/CircleFactory.cs +++ b/UnityPackage/Runtime/Shapes/Circle/CircleFactory.cs @@ -9,22 +9,24 @@ public static class CircleFactory public static ICircle CreateCircle(this WireframeContainer container) { MeshProxy proxy = WireframeContainer.ProxyOf(container); - return new Circle(proxy, null, Vector3.zero, Quaternion.identity, ShapeDefaults.Radius, Ring.DefaultSegmentCount); + return proxy.Add(new Circle( + null, Vector3.zero, Quaternion.identity, ShapeDefaults.Radius, Ring.DefaultSegmentCount)); } /// Creates a white circle around a world position, lying flat in the world's XZ plane. - /// Number of straight pieces the circle is drawn with, at least 3. + /// Number of straight pieces the circle is drawn with, from 3 to 1024. public static ICircle CreateCircle( this WireframeContainer container, Vector3 center, float radius, int segmentCount = Ring.DefaultSegmentCount) { - return new Circle(WireframeContainer.ProxyOf(container), null, center, Quaternion.identity, radius, segmentCount); + MeshProxy proxy = WireframeContainer.ProxyOf(container); + return proxy.Add(new Circle(null, center, Quaternion.identity, radius, segmentCount)); } /// /// Creates a white circle that follows , around a position in the bone's local space /// and lying flat in the bone's XZ plane. /// - /// Number of straight pieces the circle is drawn with, at least 3. + /// Number of straight pieces the circle is drawn with, from 3 to 1024. public static ICircle CreateCircle( this WireframeContainer container, Transform bone, @@ -33,11 +35,11 @@ public static ICircle CreateCircle( int segmentCount = Ring.DefaultSegmentCount) { MeshProxy proxy = WireframeContainer.ProxyOf(container); - return new Circle(proxy, bone, localCenter, Quaternion.identity, radius, segmentCount); + return proxy.Add(new Circle(bone, localCenter, Quaternion.identity, radius, segmentCount)); } /// Creates a white circle in world space, lying in the XZ plane of . - /// Number of straight pieces the circle is drawn with, at least 3. + /// Number of straight pieces the circle is drawn with, from 3 to 1024. public static ICircle CreateCircle( this WireframeContainer container, Vector3 center, @@ -45,11 +47,12 @@ public static ICircle CreateCircle( float radius, int segmentCount = Ring.DefaultSegmentCount) { - return new Circle(WireframeContainer.ProxyOf(container), null, center, rotation, radius, segmentCount); + MeshProxy proxy = WireframeContainer.ProxyOf(container); + return proxy.Add(new Circle(null, center, rotation, radius, segmentCount)); } /// Creates a white circle around a world position, facing . - /// Number of straight pieces the circle is drawn with, at least 3. + /// Number of straight pieces the circle is drawn with, from 3 to 1024. public static ICircle CreateCircle( this WireframeContainer container, Vector3 center, @@ -58,7 +61,7 @@ public static ICircle CreateCircle( int segmentCount = Ring.DefaultSegmentCount) { MeshProxy proxy = WireframeContainer.ProxyOf(container); - return new Circle(proxy, null, center, ShapeRotations.FromNormal(normal), radius, segmentCount); + return proxy.Add(new Circle(null, center, ShapeRotations.FromNormal(normal), radius, segmentCount)); } } } diff --git a/UnityPackage/Runtime/Shapes/Cone/Cone.cs b/UnityPackage/Runtime/Shapes/Cone/Cone.cs index ca90173..a2b4320 100644 --- a/UnityPackage/Runtime/Shapes/Cone/Cone.cs +++ b/UnityPackage/Runtime/Shapes/Cone/Cone.cs @@ -12,7 +12,6 @@ internal sealed class Cone : AxialShape, ICone private float _radius; internal Cone( - MeshProxy proxy, Transform bone, Vector3 localPosition, Quaternion localRotation, @@ -20,7 +19,6 @@ internal Cone( float radius, int segmentCount) : base( - proxy, Ring.CheckQuarterSegmentCount(segmentCount) + TipCount, Patterns.Get(segmentCount), bone, diff --git a/UnityPackage/Runtime/Shapes/Cone/ConeFactory.cs b/UnityPackage/Runtime/Shapes/Cone/ConeFactory.cs index 30fc756..9fdfbac 100644 --- a/UnityPackage/Runtime/Shapes/Cone/ConeFactory.cs +++ b/UnityPackage/Runtime/Shapes/Cone/ConeFactory.cs @@ -9,21 +9,20 @@ public static class ConeFactory public static ICone CreateCone(this WireframeContainer container) { MeshProxy proxy = WireframeContainer.ProxyOf(container); - return new Cone( - proxy, + return proxy.Add(new Cone( null, Vector3.zero, Quaternion.identity, ShapeDefaults.Size, ShapeDefaults.Radius, - Ring.DefaultSegmentCount); + Ring.DefaultSegmentCount)); } /// /// Creates a white cone from a tip to the center of its base, both world positions. Its spin around the axis /// keeps the cone's +Y as close to world up as it can. /// - /// Number of straight pieces the base ring is drawn with, a positive multiple of 4. + /// Number of straight pieces the base ring is drawn with, a multiple of 4 from 4 to 1024. public static ICone CreateCone( this WireframeContainer container, Vector3 tip, @@ -33,14 +32,14 @@ public static ICone CreateCone( { MeshProxy proxy = WireframeContainer.ProxyOf(container); Quaternion rotation = ShapeRotations.FromAxis(baseCenter - tip, Vector3.up); - return new Cone(proxy, null, tip, rotation, Vector3.Distance(tip, baseCenter), radius, segmentCount); + return proxy.Add(new Cone(null, tip, rotation, Vector3.Distance(tip, baseCenter), radius, segmentCount)); } /// /// Creates a white cone that follows , from a tip to the center of its base, both in the /// bone's local space. Its spin around the axis keeps the cone's +Y as close to the bone's up as it can. /// - /// Number of straight pieces the base ring is drawn with, a positive multiple of 4. + /// Number of straight pieces the base ring is drawn with, a multiple of 4 from 4 to 1024. public static ICone CreateCone( this WireframeContainer container, Transform bone, @@ -52,14 +51,14 @@ public static ICone CreateCone( MeshProxy proxy = WireframeContainer.ProxyOf(container); Quaternion rotation = ShapeRotations.FromAxis(localBaseCenter - localTip, Vector3.up); float length = Vector3.Distance(localTip, localBaseCenter); - return new Cone(proxy, bone, localTip, rotation, length, radius, segmentCount); + return proxy.Add(new Cone(bone, localTip, rotation, length, radius, segmentCount)); } /// /// Creates a white cone in world space with its tip at and its base /// along the +Z axis of . /// - /// Number of straight pieces the base ring is drawn with, a positive multiple of 4. + /// Number of straight pieces the base ring is drawn with, a multiple of 4 from 4 to 1024. public static ICone CreateCone( this WireframeContainer container, Vector3 position, @@ -69,7 +68,7 @@ public static ICone CreateCone( int segmentCount = Ring.DefaultSegmentCount) { MeshProxy proxy = WireframeContainer.ProxyOf(container); - return new Cone(proxy, null, position, rotation, length, radius, segmentCount); + return proxy.Add(new Cone(null, position, rotation, length, radius, segmentCount)); } } } diff --git a/UnityPackage/Runtime/Shapes/Cylinder/Cylinder.cs b/UnityPackage/Runtime/Shapes/Cylinder/Cylinder.cs index d98e326..9fd969b 100644 --- a/UnityPackage/Runtime/Shapes/Cylinder/Cylinder.cs +++ b/UnityPackage/Runtime/Shapes/Cylinder/Cylinder.cs @@ -12,7 +12,6 @@ internal sealed class Cylinder : AxialShape, ICylinder private float _radius; internal Cylinder( - MeshProxy proxy, Transform bone, Vector3 localPosition, Quaternion localRotation, @@ -20,7 +19,6 @@ internal Cylinder( float radius, int segmentCount) : base( - proxy, Ring.CheckQuarterSegmentCount(segmentCount) * RingCount, Patterns.Get(segmentCount), bone, diff --git a/UnityPackage/Runtime/Shapes/Cylinder/CylinderFactory.cs b/UnityPackage/Runtime/Shapes/Cylinder/CylinderFactory.cs index f7bdb00..b3fac88 100644 --- a/UnityPackage/Runtime/Shapes/Cylinder/CylinderFactory.cs +++ b/UnityPackage/Runtime/Shapes/Cylinder/CylinderFactory.cs @@ -9,21 +9,20 @@ public static class CylinderFactory public static ICylinder CreateCylinder(this WireframeContainer container) { MeshProxy proxy = WireframeContainer.ProxyOf(container); - return new Cylinder( - proxy, + return proxy.Add(new Cylinder( null, Vector3.zero, Quaternion.identity, ShapeDefaults.Size, ShapeDefaults.Radius, - Ring.DefaultSegmentCount); + Ring.DefaultSegmentCount)); } /// /// Creates a white cylinder between two world positions. Its spin around the axis keeps the cylinder's +Y as /// close to world up as it can. /// - /// Number of straight pieces each ring is drawn with, a positive multiple of 4. + /// Number of straight pieces each ring is drawn with, a multiple of 4 from 4 to 1024. public static ICylinder CreateCylinder( this WireframeContainer container, Vector3 endA, @@ -33,14 +32,14 @@ public static ICylinder CreateCylinder( { MeshProxy proxy = WireframeContainer.ProxyOf(container); Quaternion rotation = ShapeRotations.FromAxis(endB - endA, Vector3.up); - return new Cylinder(proxy, null, endA, rotation, Vector3.Distance(endA, endB), radius, segmentCount); + return proxy.Add(new Cylinder(null, endA, rotation, Vector3.Distance(endA, endB), radius, segmentCount)); } /// /// Creates a white cylinder that follows , between two positions in the bone's local /// space. Its spin around the axis keeps the cylinder's +Y as close to the bone's up as it can. /// - /// Number of straight pieces each ring is drawn with, a positive multiple of 4. + /// Number of straight pieces each ring is drawn with, a multiple of 4 from 4 to 1024. public static ICylinder CreateCylinder( this WireframeContainer container, Transform bone, @@ -52,14 +51,14 @@ public static ICylinder CreateCylinder( MeshProxy proxy = WireframeContainer.ProxyOf(container); Quaternion rotation = ShapeRotations.FromAxis(localEndB - localEndA, Vector3.up); float length = Vector3.Distance(localEndA, localEndB); - return new Cylinder(proxy, bone, localEndA, rotation, length, radius, segmentCount); + return proxy.Add(new Cylinder(bone, localEndA, rotation, length, radius, segmentCount)); } /// /// Creates a white cylinder in world space that starts at and runs /// along the +Z axis of . /// - /// Number of straight pieces each ring is drawn with, a positive multiple of 4. + /// Number of straight pieces each ring is drawn with, a multiple of 4 from 4 to 1024. public static ICylinder CreateCylinder( this WireframeContainer container, Vector3 position, @@ -69,7 +68,7 @@ public static ICylinder CreateCylinder( int segmentCount = Ring.DefaultSegmentCount) { MeshProxy proxy = WireframeContainer.ProxyOf(container); - return new Cylinder(proxy, null, position, rotation, length, radius, segmentCount); + return proxy.Add(new Cylinder(null, position, rotation, length, radius, segmentCount)); } } } diff --git a/UnityPackage/Runtime/Shapes/Ellipse/Ellipse.cs b/UnityPackage/Runtime/Shapes/Ellipse/Ellipse.cs index 1267a93..0df1070 100644 --- a/UnityPackage/Runtime/Shapes/Ellipse/Ellipse.cs +++ b/UnityPackage/Runtime/Shapes/Ellipse/Ellipse.cs @@ -8,13 +8,12 @@ internal sealed class Ellipse : RigidShape, IEllipse private Vector2 _radii; internal Ellipse( - MeshProxy proxy, Transform bone, Vector3 localCenter, Quaternion localRotation, Vector2 radii, int segmentCount) - : base(proxy, Ring.CheckSegmentCount(segmentCount), Ring.Patterns.Get(segmentCount), bone, localCenter, localRotation) + : base(Ring.CheckSegmentCount(segmentCount), Ring.Patterns.Get(segmentCount), bone, localCenter, localRotation) { _radii = radii; } diff --git a/UnityPackage/Runtime/Shapes/Ellipse/EllipseFactory.cs b/UnityPackage/Runtime/Shapes/Ellipse/EllipseFactory.cs index c6653b2..9835980 100644 --- a/UnityPackage/Runtime/Shapes/Ellipse/EllipseFactory.cs +++ b/UnityPackage/Runtime/Shapes/Ellipse/EllipseFactory.cs @@ -13,14 +13,14 @@ public static IEllipse CreateEllipse(this WireframeContainer container) { MeshProxy proxy = WireframeContainer.ProxyOf(container); Vector2 radii = new(ShapeDefaults.Radius * 0.5f, ShapeDefaults.Radius); - return new Ellipse(proxy, null, Vector3.zero, Quaternion.identity, radii, Ring.DefaultSegmentCount); + return proxy.Add(new Ellipse(null, Vector3.zero, Quaternion.identity, radii, Ring.DefaultSegmentCount)); } /// /// Creates a white ellipse whose long axis runs between two world positions, with as /// its other semi-axis. It lies as flat as it can: its normal stays as close to world up as the tips allow. /// - /// Number of straight pieces the ellipse is drawn with, at least 3. + /// Number of straight pieces the ellipse is drawn with, from 3 to 1024. public static IEllipse CreateEllipse( this WireframeContainer container, Vector3 tipA, @@ -35,7 +35,7 @@ public static IEllipse CreateEllipse( /// Creates a white ellipse whose long axis runs between two world positions, with as /// its other semi-axis, facing as close to as the tips allow. /// - /// Number of straight pieces the ellipse is drawn with, at least 3. + /// Number of straight pieces the ellipse is drawn with, from 3 to 1024. public static IEllipse CreateEllipse( this WireframeContainer container, Vector3 tipA, @@ -47,14 +47,14 @@ public static IEllipse CreateEllipse( MeshProxy proxy = WireframeContainer.ProxyOf(container); Quaternion rotation = ShapeRotations.FromAxis(tipB - tipA, normal); Vector2 radii = new(radius, Vector3.Distance(tipA, tipB) * 0.5f); - return new Ellipse(proxy, null, (tipA + tipB) * 0.5f, rotation, radii, segmentCount); + return proxy.Add(new Ellipse(null, (tipA + tipB) * 0.5f, rotation, radii, segmentCount)); } /// /// Creates a white ellipse that follows , whose long axis runs between two positions in /// the bone's local space. It lies as flat in the bone's XZ plane as the tips allow. /// - /// Number of straight pieces the ellipse is drawn with, at least 3. + /// Number of straight pieces the ellipse is drawn with, from 3 to 1024. public static IEllipse CreateEllipse( this WireframeContainer container, Transform bone, @@ -66,12 +66,12 @@ public static IEllipse CreateEllipse( MeshProxy proxy = WireframeContainer.ProxyOf(container); Quaternion rotation = ShapeRotations.FromAxis(localTipB - localTipA, Vector3.up); Vector2 radii = new(radius, Vector3.Distance(localTipA, localTipB) * 0.5f); - return new Ellipse(proxy, bone, (localTipA + localTipB) * 0.5f, rotation, radii, segmentCount); + return proxy.Add(new Ellipse(bone, (localTipA + localTipB) * 0.5f, rotation, radii, segmentCount)); } /// Creates a white ellipse in world space, lying in the XZ plane of . /// Semi-axes: x along the rotation's X axis and y along its Z axis. - /// Number of straight pieces the ellipse is drawn with, at least 3. + /// Number of straight pieces the ellipse is drawn with, from 3 to 1024. public static IEllipse CreateEllipse( this WireframeContainer container, Vector3 center, @@ -79,7 +79,8 @@ public static IEllipse CreateEllipse( Vector2 radii, int segmentCount = Ring.DefaultSegmentCount) { - return new Ellipse(WireframeContainer.ProxyOf(container), null, center, rotation, radii, segmentCount); + MeshProxy proxy = WireframeContainer.ProxyOf(container); + return proxy.Add(new Ellipse(null, center, rotation, radii, segmentCount)); } } } diff --git a/UnityPackage/Runtime/Shapes/Ellipsoid/Ellipsoid.cs b/UnityPackage/Runtime/Shapes/Ellipsoid/Ellipsoid.cs index 2e426d6..443cb73 100644 --- a/UnityPackage/Runtime/Shapes/Ellipsoid/Ellipsoid.cs +++ b/UnityPackage/Runtime/Shapes/Ellipsoid/Ellipsoid.cs @@ -8,14 +8,12 @@ internal sealed class Ellipsoid : RigidShape, IEllipsoid private Vector3 _radii; internal Ellipsoid( - MeshProxy proxy, Transform bone, Vector3 localCenter, Quaternion localRotation, Vector3 radii, int segmentCount) : base( - proxy, Ring.CheckSegmentCount(segmentCount) * AxisRings.RingCount, AxisRings.Patterns.Get(segmentCount), bone, diff --git a/UnityPackage/Runtime/Shapes/Ellipsoid/EllipsoidFactory.cs b/UnityPackage/Runtime/Shapes/Ellipsoid/EllipsoidFactory.cs index 4e59634..1a94d89 100644 --- a/UnityPackage/Runtime/Shapes/Ellipsoid/EllipsoidFactory.cs +++ b/UnityPackage/Runtime/Shapes/Ellipsoid/EllipsoidFactory.cs @@ -12,14 +12,14 @@ public static IEllipsoid CreateEllipsoid(this WireframeContainer container) { MeshProxy proxy = WireframeContainer.ProxyOf(container); Vector3 radii = new(ShapeDefaults.Radius * 0.5f, ShapeDefaults.Radius * 0.5f, ShapeDefaults.Radius); - return new Ellipsoid(proxy, null, Vector3.zero, Quaternion.identity, radii, Ring.DefaultSegmentCount); + return proxy.Add(new Ellipsoid(null, Vector3.zero, Quaternion.identity, radii, Ring.DefaultSegmentCount)); } /// /// Creates a white ellipsoid whose long axis runs between two world positions, with /// as its other two semi-axes. Its +Y stays as close to world up as the tips allow. /// - /// Number of straight pieces each ellipse is drawn with, at least 3. + /// Number of straight pieces each ellipse is drawn with, from 3 to 1024. public static IEllipsoid CreateEllipsoid( this WireframeContainer container, Vector3 tipA, @@ -30,14 +30,14 @@ public static IEllipsoid CreateEllipsoid( MeshProxy proxy = WireframeContainer.ProxyOf(container); Quaternion rotation = ShapeRotations.FromAxis(tipB - tipA, Vector3.up); Vector3 radii = new(radius, radius, Vector3.Distance(tipA, tipB) * 0.5f); - return new Ellipsoid(proxy, null, (tipA + tipB) * 0.5f, rotation, radii, segmentCount); + return proxy.Add(new Ellipsoid(null, (tipA + tipB) * 0.5f, rotation, radii, segmentCount)); } /// /// Creates a white ellipsoid that follows , whose long axis runs between two positions in /// the bone's local space, with as its other two semi-axes. /// - /// Number of straight pieces each ellipse is drawn with, at least 3. + /// Number of straight pieces each ellipse is drawn with, from 3 to 1024. public static IEllipsoid CreateEllipsoid( this WireframeContainer container, Transform bone, @@ -49,12 +49,12 @@ public static IEllipsoid CreateEllipsoid( MeshProxy proxy = WireframeContainer.ProxyOf(container); Quaternion rotation = ShapeRotations.FromAxis(localTipB - localTipA, Vector3.up); Vector3 radii = new(radius, radius, Vector3.Distance(localTipA, localTipB) * 0.5f); - return new Ellipsoid(proxy, bone, (localTipA + localTipB) * 0.5f, rotation, radii, segmentCount); + return proxy.Add(new Ellipsoid(bone, (localTipA + localTipB) * 0.5f, rotation, radii, segmentCount)); } /// Creates a white ellipsoid in world space, turned by . /// Semi-axes along the rotation's X, Y and Z axes. - /// Number of straight pieces each ellipse is drawn with, at least 3. + /// Number of straight pieces each ellipse is drawn with, from 3 to 1024. public static IEllipsoid CreateEllipsoid( this WireframeContainer container, Vector3 center, @@ -62,7 +62,8 @@ public static IEllipsoid CreateEllipsoid( Vector3 radii, int segmentCount = Ring.DefaultSegmentCount) { - return new Ellipsoid(WireframeContainer.ProxyOf(container), null, center, rotation, radii, segmentCount); + MeshProxy proxy = WireframeContainer.ProxyOf(container); + return proxy.Add(new Ellipsoid(null, center, rotation, radii, segmentCount)); } } } diff --git a/UnityPackage/Runtime/Shapes/Frustum/Frustum.cs b/UnityPackage/Runtime/Shapes/Frustum/Frustum.cs index 996d9af..03110bc 100644 --- a/UnityPackage/Runtime/Shapes/Frustum/Frustum.cs +++ b/UnityPackage/Runtime/Shapes/Frustum/Frustum.cs @@ -12,7 +12,6 @@ internal sealed class Frustum : AxialShape, IFrustum private float _radiusB; internal Frustum( - MeshProxy proxy, Transform bone, Vector3 localPosition, Quaternion localRotation, @@ -20,7 +19,7 @@ internal Frustum( float radiusA, float radiusB, int sideCount) - : base(proxy, CountVertices(sideCount), Patterns.Get(sideCount), bone, localPosition, localRotation, length) + : base(CountVertices(sideCount), Patterns.Get(sideCount), bone, localPosition, localRotation, length) { _radiusA = radiusA; _radiusB = radiusB; @@ -87,9 +86,10 @@ protected override void ScaleCrossSection(float factor) private static int CountVertices(int sideCount) { - if (sideCount < 3) + if (sideCount < 3 || sideCount > Ring.MaxSegmentCount) { - throw new ArgumentOutOfRangeException(nameof(sideCount), sideCount, "A frustum needs at least 3 sides."); + throw new ArgumentOutOfRangeException( + nameof(sideCount), sideCount, $"A frustum needs 3 to {Ring.MaxSegmentCount} sides."); } return sideCount * PolygonCount; } diff --git a/UnityPackage/Runtime/Shapes/Frustum/FrustumFactory.cs b/UnityPackage/Runtime/Shapes/Frustum/FrustumFactory.cs index 7b48827..5146621 100644 --- a/UnityPackage/Runtime/Shapes/Frustum/FrustumFactory.cs +++ b/UnityPackage/Runtime/Shapes/Frustum/FrustumFactory.cs @@ -14,35 +14,35 @@ public static class FrustumFactory public static IFrustum CreateFrustum(this WireframeContainer container) { MeshProxy proxy = WireframeContainer.ProxyOf(container); - return new Frustum( - proxy, + return proxy.Add(new Frustum( null, Vector3.zero, Quaternion.identity, ShapeDefaults.Size, ShapeDefaults.Radius * 0.5f, ShapeDefaults.Radius, - DefaultSideCount); + DefaultSideCount)); } /// /// Creates a white frustum between the centers of its two polygons, both world positions. Its spin around the /// axis keeps the frustum's +Y as close to world up as it can. /// - /// Number of sides of each polygon, at least 3. + /// Number of sides of each polygon, from 3 to 1024. public static IFrustum CreateFrustum( this WireframeContainer container, Vector3 endA, Vector3 endB, float radiusA, float radiusB, int sideCount) { MeshProxy proxy = WireframeContainer.ProxyOf(container); Quaternion rotation = ShapeRotations.FromAxis(endB - endA, Vector3.up); - return new Frustum(proxy, null, endA, rotation, Vector3.Distance(endA, endB), radiusA, radiusB, sideCount); + return proxy.Add(new Frustum( + null, endA, rotation, Vector3.Distance(endA, endB), radiusA, radiusB, sideCount)); } /// /// Creates a white frustum that follows , between the centers of its two polygons in the /// bone's local space. Its spin around the axis keeps the frustum's +Y as close to the bone's up as it can. /// - /// Number of sides of each polygon, at least 3. + /// Number of sides of each polygon, from 3 to 1024. public static IFrustum CreateFrustum( this WireframeContainer container, Transform bone, @@ -55,14 +55,14 @@ public static IFrustum CreateFrustum( MeshProxy proxy = WireframeContainer.ProxyOf(container); Quaternion rotation = ShapeRotations.FromAxis(localEndB - localEndA, Vector3.up); float length = Vector3.Distance(localEndA, localEndB); - return new Frustum(proxy, bone, localEndA, rotation, length, radiusA, radiusB, sideCount); + return proxy.Add(new Frustum(bone, localEndA, rotation, length, radiusA, radiusB, sideCount)); } /// /// Creates a white frustum in world space that starts at and runs /// along the +Z axis of . /// - /// Number of sides of each polygon, at least 3. + /// Number of sides of each polygon, from 3 to 1024. public static IFrustum CreateFrustum( this WireframeContainer container, Vector3 position, @@ -73,7 +73,7 @@ public static IFrustum CreateFrustum( int sideCount) { MeshProxy proxy = WireframeContainer.ProxyOf(container); - return new Frustum(proxy, null, position, rotation, length, radiusA, radiusB, sideCount); + return proxy.Add(new Frustum(null, position, rotation, length, radiusA, radiusB, sideCount)); } } } diff --git a/UnityPackage/Runtime/Shapes/IRigidShape.cs b/UnityPackage/Runtime/Shapes/IRigidShape.cs index 4d86381..93ad5bb 100644 --- a/UnityPackage/Runtime/Shapes/IRigidShape.cs +++ b/UnityPackage/Runtime/Shapes/IRigidShape.cs @@ -33,6 +33,7 @@ public interface IRigidShape : IShape /// exact for uniformly scaled bones. If the bone is destroyed, the shape stays where it was and becomes world /// space. /// + /// The Transform isn't in a scene, such as a prefab asset. Transform Bone { get; set; } } } diff --git a/UnityPackage/Runtime/Shapes/Line/ILine.cs b/UnityPackage/Runtime/Shapes/Line/ILine.cs index bb5205b..aff68e9 100644 --- a/UnityPackage/Runtime/Shapes/Line/ILine.cs +++ b/UnityPackage/Runtime/Shapes/Line/ILine.cs @@ -29,12 +29,14 @@ public interface ILine : IShape /// Transform that endpoint A follows, or null for world space. Changing it keeps the endpoint's world position, /// like reparenting a Transform. If the bone is destroyed, the endpoint stays where it was and becomes world space. /// + /// The Transform isn't in a scene, such as a prefab asset. Transform BoneA { get; set; } /// /// Transform that endpoint B follows, or null for world space. Changing it keeps the endpoint's world position, /// like reparenting a Transform. If the bone is destroyed, the endpoint stays where it was and becomes world space. /// + /// The Transform isn't in a scene, such as a prefab asset. Transform BoneB { get; set; } } } diff --git a/UnityPackage/Runtime/Shapes/Line/Line.cs b/UnityPackage/Runtime/Shapes/Line/Line.cs index d1d58f3..04bd614 100644 --- a/UnityPackage/Runtime/Shapes/Line/Line.cs +++ b/UnityPackage/Runtime/Shapes/Line/Line.cs @@ -10,17 +10,17 @@ internal sealed class Line : PointShape, ILine private ShapePoint _pointA; private ShapePoint _pointB; - internal Line(MeshProxy proxy, Vector3 positionA, Vector3 positionB) : base(proxy, LineVertexCount, EdgePattern) + internal Line(Vector3 positionA, Vector3 positionB) : base(LineVertexCount, EdgePattern) { _pointA = new ShapePoint(positionA); _pointB = new ShapePoint(positionB); } /// Creates a line whose endpoints sit at the origins of their bones. - internal Line(MeshProxy proxy, Transform boneA, Transform boneB) : this(proxy, Vector3.zero, Vector3.zero) + internal Line(Transform boneA, Transform boneB) : this(Vector3.zero, Vector3.zero) { - AttachPoint(0, boneA); - AttachPoint(1, boneB); + InitializeBone(0, boneA, nameof(boneA)); + InitializeBone(1, boneB, nameof(boneB)); } public Vector3 LocalPositionA diff --git a/UnityPackage/Runtime/Shapes/Line/LineFactory.cs b/UnityPackage/Runtime/Shapes/Line/LineFactory.cs index c5f6ddb..32e53d8 100644 --- a/UnityPackage/Runtime/Shapes/Line/LineFactory.cs +++ b/UnityPackage/Runtime/Shapes/Line/LineFactory.cs @@ -8,13 +8,15 @@ public static class LineFactory /// Creates a white line with both endpoints at the world origin. public static ILine CreateLine(this WireframeContainer container) { - return new Line(WireframeContainer.ProxyOf(container), Vector3.zero, Vector3.zero); + MeshProxy proxy = WireframeContainer.ProxyOf(container); + return proxy.Add(new Line(Vector3.zero, Vector3.zero)); } /// Creates a white line between two world positions. public static ILine CreateLine(this WireframeContainer container, Vector3 positionA, Vector3 positionB) { - return new Line(WireframeContainer.ProxyOf(container), positionA, positionB); + MeshProxy proxy = WireframeContainer.ProxyOf(container); + return proxy.Add(new Line(positionA, positionB)); } /// @@ -23,7 +25,8 @@ public static ILine CreateLine(this WireframeContainer container, Vector3 positi /// public static ILine CreateLine(this WireframeContainer container, Transform boneA, Transform boneB) { - return new Line(WireframeContainer.ProxyOf(container), boneA, boneB); + MeshProxy proxy = WireframeContainer.ProxyOf(container); + return proxy.Add(new Line(boneA, boneB)); } } } diff --git a/UnityPackage/Runtime/Shapes/Polyline/IPolyline.cs b/UnityPackage/Runtime/Shapes/Polyline/IPolyline.cs index 1dbf142..7ca03ff 100644 --- a/UnityPackage/Runtime/Shapes/Polyline/IPolyline.cs +++ b/UnityPackage/Runtime/Shapes/Polyline/IPolyline.cs @@ -42,6 +42,7 @@ public interface IPolyline : IShape /// Sets the Transform a point follows, or null for world space. Changing it keeps the point's world position, /// like reparenting a Transform. If the bone is destroyed, the point stays where it was and becomes world space. /// + /// The Transform isn't in a scene, such as a prefab asset. void SetBone(int index, Transform bone); } } diff --git a/UnityPackage/Runtime/Shapes/Polyline/Polyline.cs b/UnityPackage/Runtime/Shapes/Polyline/Polyline.cs index a9542ab..94fbeb5 100644 --- a/UnityPackage/Runtime/Shapes/Polyline/Polyline.cs +++ b/UnityPackage/Runtime/Shapes/Polyline/Polyline.cs @@ -9,8 +9,8 @@ internal sealed class Polyline : PointShape, IPolyline private readonly ShapePoint[] _points; private readonly bool _isClosed; - internal Polyline(MeshProxy proxy, IReadOnlyList points, bool isClosed) - : base(proxy, CheckCount(points, isClosed, nameof(points)), BuildEdges(points.Count, isClosed)) + internal Polyline(IReadOnlyList points, bool isClosed) + : base(CheckCount(points, isClosed, nameof(points)), BuildEdges(points.Count, isClosed)) { _isClosed = isClosed; _points = new ShapePoint[points.Count]; @@ -21,15 +21,15 @@ internal Polyline(MeshProxy proxy, IReadOnlyList points, bool isClosed) } /// Creates a polyline whose points sit at the origins of their bones. - internal Polyline(MeshProxy proxy, IReadOnlyList bones, bool isClosed) - : base(proxy, CheckCount(bones, isClosed, nameof(bones)), BuildEdges(bones.Count, isClosed)) + internal Polyline(IReadOnlyList bones, bool isClosed) + : base(CheckCount(bones, isClosed, nameof(bones)), BuildEdges(bones.Count, isClosed)) { _isClosed = isClosed; _points = new ShapePoint[bones.Count]; for (int i = 0; i < _points.Length; i++) { _points[i] = new ShapePoint(Vector3.zero); - AttachPoint(i, bones[i]); + InitializeBone(i, bones[i], nameof(bones)); } } diff --git a/UnityPackage/Runtime/Shapes/Polyline/PolylineFactory.cs b/UnityPackage/Runtime/Shapes/Polyline/PolylineFactory.cs index e9e0950..e3f7db9 100644 --- a/UnityPackage/Runtime/Shapes/Polyline/PolylineFactory.cs +++ b/UnityPackage/Runtime/Shapes/Polyline/PolylineFactory.cs @@ -9,13 +9,15 @@ public static class PolylineFactory /// Creates a white, open polyline through at least 2 world positions. public static IPolyline CreatePolyline(this WireframeContainer container, params Vector3[] points) { - return new Polyline(WireframeContainer.ProxyOf(container), points, false); + MeshProxy proxy = WireframeContainer.ProxyOf(container); + return proxy.Add(new Polyline(points, false)); } /// Creates a white, open polyline through at least 2 world positions. public static IPolyline CreatePolyline(this WireframeContainer container, IReadOnlyList points) { - return new Polyline(WireframeContainer.ProxyOf(container), points, false); + MeshProxy proxy = WireframeContainer.ProxyOf(container); + return proxy.Add(new Polyline(points, false)); } /// @@ -24,19 +26,22 @@ public static IPolyline CreatePolyline(this WireframeContainer container, IReadO /// public static IPolyline CreatePolyline(this WireframeContainer container, params Transform[] bones) { - return new Polyline(WireframeContainer.ProxyOf(container), bones, false); + MeshProxy proxy = WireframeContainer.ProxyOf(container); + return proxy.Add(new Polyline(bones, false)); } /// Creates a white, closed polyline through at least 3 world positions. public static IPolyline CreatePolygon(this WireframeContainer container, params Vector3[] points) { - return new Polyline(WireframeContainer.ProxyOf(container), points, true); + MeshProxy proxy = WireframeContainer.ProxyOf(container); + return proxy.Add(new Polyline(points, true)); } /// Creates a white, closed polyline through at least 3 world positions. public static IPolyline CreatePolygon(this WireframeContainer container, IReadOnlyList points) { - return new Polyline(WireframeContainer.ProxyOf(container), points, true); + MeshProxy proxy = WireframeContainer.ProxyOf(container); + return proxy.Add(new Polyline(points, true)); } /// @@ -45,14 +50,16 @@ public static IPolyline CreatePolygon(this WireframeContainer container, IReadOn /// public static IPolyline CreatePolygon(this WireframeContainer container, params Transform[] bones) { - return new Polyline(WireframeContainer.ProxyOf(container), bones, true); + MeshProxy proxy = WireframeContainer.ProxyOf(container); + return proxy.Add(new Polyline(bones, true)); } /// Creates a white triangle, a closed polyline through 3 world positions. public static IPolyline CreateTriangle( this WireframeContainer container, Vector3 pointA, Vector3 pointB, Vector3 pointC) { - return new Polyline(WireframeContainer.ProxyOf(container), new[] { pointA, pointB, pointC }, true); + MeshProxy proxy = WireframeContainer.ProxyOf(container); + return proxy.Add(new Polyline(new[] { pointA, pointB, pointC }, true)); } /// @@ -62,7 +69,8 @@ public static IPolyline CreateTriangle( public static IPolyline CreateTriangle( this WireframeContainer container, Transform boneA, Transform boneB, Transform boneC) { - return new Polyline(WireframeContainer.ProxyOf(container), new[] { boneA, boneB, boneC }, true); + MeshProxy proxy = WireframeContainer.ProxyOf(container); + return proxy.Add(new Polyline(new[] { boneA, boneB, boneC }, true)); } } } diff --git a/UnityPackage/Runtime/Shapes/Pyramid/Pyramid.cs b/UnityPackage/Runtime/Shapes/Pyramid/Pyramid.cs index 77952ec..9a3719b 100644 --- a/UnityPackage/Runtime/Shapes/Pyramid/Pyramid.cs +++ b/UnityPackage/Runtime/Shapes/Pyramid/Pyramid.cs @@ -17,12 +17,11 @@ internal sealed class Pyramid : AxialShape, IPyramid private Vector2 _baseSize; internal Pyramid( - MeshProxy proxy, Transform bone, Vector3 localPosition, Quaternion localRotation, float length, - Vector2 baseSize) : base(proxy, PyramidVertexCount, EdgePattern, bone, localPosition, localRotation, length) + Vector2 baseSize) : base(PyramidVertexCount, EdgePattern, bone, localPosition, localRotation, length) { _baseSize = baseSize; } diff --git a/UnityPackage/Runtime/Shapes/Pyramid/PyramidFactory.cs b/UnityPackage/Runtime/Shapes/Pyramid/PyramidFactory.cs index f27c0e9..df89e6e 100644 --- a/UnityPackage/Runtime/Shapes/Pyramid/PyramidFactory.cs +++ b/UnityPackage/Runtime/Shapes/Pyramid/PyramidFactory.cs @@ -10,7 +10,7 @@ public static IPyramid CreatePyramid(this WireframeContainer container) { MeshProxy proxy = WireframeContainer.ProxyOf(container); Vector2 baseSize = Vector2.one * ShapeDefaults.Size; - return new Pyramid(proxy, null, Vector3.zero, Quaternion.identity, ShapeDefaults.Size, baseSize); + return proxy.Add(new Pyramid(null, Vector3.zero, Quaternion.identity, ShapeDefaults.Size, baseSize)); } /// @@ -24,7 +24,7 @@ public static IPyramid CreatePyramid( { MeshProxy proxy = WireframeContainer.ProxyOf(container); Quaternion rotation = ShapeRotations.FromAxis(baseCenter - tip, Vector3.up); - return new Pyramid(proxy, null, tip, rotation, Vector3.Distance(tip, baseCenter), baseSize); + return proxy.Add(new Pyramid(null, tip, rotation, Vector3.Distance(tip, baseCenter), baseSize)); } /// @@ -42,7 +42,7 @@ public static IPyramid CreatePyramid( MeshProxy proxy = WireframeContainer.ProxyOf(container); Quaternion rotation = ShapeRotations.FromAxis(localBaseCenter - localTip, Vector3.up); float length = Vector3.Distance(localTip, localBaseCenter); - return new Pyramid(proxy, bone, localTip, rotation, length, baseSize); + return proxy.Add(new Pyramid(bone, localTip, rotation, length, baseSize)); } /// @@ -57,7 +57,8 @@ public static IPyramid CreatePyramid( float length, Vector2 baseSize) { - return new Pyramid(WireframeContainer.ProxyOf(container), null, position, rotation, length, baseSize); + MeshProxy proxy = WireframeContainer.ProxyOf(container); + return proxy.Add(new Pyramid(null, position, rotation, length, baseSize)); } } } diff --git a/UnityPackage/Runtime/Shapes/Rectangle/Rectangle.cs b/UnityPackage/Runtime/Shapes/Rectangle/Rectangle.cs index 4252c27..18404d9 100644 --- a/UnityPackage/Runtime/Shapes/Rectangle/Rectangle.cs +++ b/UnityPackage/Runtime/Shapes/Rectangle/Rectangle.cs @@ -10,8 +10,8 @@ internal sealed class Rectangle : RectangleShape, IRectangle // Corner 0 is A and corner 2 is B. private static readonly int[] EdgePattern = { 0, 1, 1, 2, 2, 3, 3, 0 }; - internal Rectangle(MeshProxy proxy, Transform bone, Vector3 localCenter, Quaternion localRotation, Vector2 size) - : base(proxy, CornerCount, EdgePattern, bone, localCenter, localRotation, size) + internal Rectangle(Transform bone, Vector3 localCenter, Quaternion localRotation, Vector2 size) + : base(CornerCount, EdgePattern, bone, localCenter, localRotation, size) { } diff --git a/UnityPackage/Runtime/Shapes/Rectangle/RectangleFactory.cs b/UnityPackage/Runtime/Shapes/Rectangle/RectangleFactory.cs index f59ea13..15ff2ce 100644 --- a/UnityPackage/Runtime/Shapes/Rectangle/RectangleFactory.cs +++ b/UnityPackage/Runtime/Shapes/Rectangle/RectangleFactory.cs @@ -9,7 +9,7 @@ public static class RectangleFactory public static IRectangle CreateRectangle(this WireframeContainer container) { MeshProxy proxy = WireframeContainer.ProxyOf(container); - return new Rectangle(proxy, null, Vector3.zero, Quaternion.identity, Vector2.one * ShapeDefaults.Size); + return proxy.Add(new Rectangle(null, Vector3.zero, Quaternion.identity, Vector2.one * ShapeDefaults.Size)); } /// @@ -20,7 +20,8 @@ public static IRectangle CreateRectangle(this WireframeContainer container, Vect { MeshProxy proxy = WireframeContainer.ProxyOf(container); Vector3 center = (cornerA + cornerB) * 0.5f; - return new Rectangle(proxy, null, center, Quaternion.identity, RectangleShape.FlatSpan(cornerA, cornerB)); + return proxy.Add(new Rectangle( + null, center, Quaternion.identity, RectangleShape.FlatSpan(cornerA, cornerB))); } /// @@ -33,14 +34,15 @@ public static IRectangle CreateRectangle( MeshProxy proxy = WireframeContainer.ProxyOf(container); Vector3 center = (localCornerA + localCornerB) * 0.5f; Vector2 size = RectangleShape.FlatSpan(localCornerA, localCornerB); - return new Rectangle(proxy, bone, center, Quaternion.identity, size); + return proxy.Add(new Rectangle(bone, center, Quaternion.identity, size)); } /// Creates a white rectangle in world space, lying in the XZ plane of . public static IRectangle CreateRectangle( this WireframeContainer container, Vector3 center, Quaternion rotation, Vector2 size) { - return new Rectangle(WireframeContainer.ProxyOf(container), null, center, rotation, size); + MeshProxy proxy = WireframeContainer.ProxyOf(container); + return proxy.Add(new Rectangle(null, center, rotation, size)); } } } diff --git a/UnityPackage/Runtime/Shapes/RoundedRectangle/RoundedRectangle.cs b/UnityPackage/Runtime/Shapes/RoundedRectangle/RoundedRectangle.cs index a8ffe3d..77de07a 100644 --- a/UnityPackage/Runtime/Shapes/RoundedRectangle/RoundedRectangle.cs +++ b/UnityPackage/Runtime/Shapes/RoundedRectangle/RoundedRectangle.cs @@ -11,7 +11,6 @@ internal sealed class RoundedRectangle : RectangleShape, IRoundedRectangle private float _cornerRadius; internal RoundedRectangle( - MeshProxy proxy, Transform bone, Vector3 localCenter, Quaternion localRotation, @@ -19,7 +18,6 @@ internal RoundedRectangle( float cornerRadius, int segmentCount) : base( - proxy, Ring.CheckQuarterSegmentCount(segmentCount) + CornerCount, Patterns.Get(segmentCount), bone, @@ -65,7 +63,6 @@ protected override void WriteShape(Span positions) float mirrorX = size.x < 0f ? -1f : 1f; float mirrorZ = size.y < 0f ? -1f : 1f; float radius = Mathf.Clamp(_cornerRadius, 0f, Mathf.Min(halfX, halfZ)); - Vector2[] circle = Ring.UnitCircle(segmentCount); // Going around from +Z toward +X, the corners are (+X, +Z), (+X, -Z), (-X, -Z) and (-X, +Z); each is a // quarter of a circle around a point inset by the radius. @@ -74,9 +71,10 @@ protected override void WriteShape(Span positions) { float centerX = corner < 2 ? halfX - radius : radius - halfX; float centerZ = corner == 0 || corner == 3 ? halfZ - radius : radius - halfZ; + CirclePoints arc = new(segmentCount, corner * quarter); for (int step = 0; step <= quarter; step++) { - Vector2 direction = circle[(corner * quarter + step) % segmentCount]; + Vector2 direction = arc.Next(); positions[vertex++] = new Vector3( (centerX + direction.y * radius) * mirrorX, 0f, diff --git a/UnityPackage/Runtime/Shapes/RoundedRectangle/RoundedRectangleFactory.cs b/UnityPackage/Runtime/Shapes/RoundedRectangle/RoundedRectangleFactory.cs index e0ed1dc..8dc2def 100644 --- a/UnityPackage/Runtime/Shapes/RoundedRectangle/RoundedRectangleFactory.cs +++ b/UnityPackage/Runtime/Shapes/RoundedRectangle/RoundedRectangleFactory.cs @@ -15,8 +15,8 @@ public static IRoundedRectangle CreateRoundedRectangle(this WireframeContainer c { MeshProxy proxy = WireframeContainer.ProxyOf(container); Vector2 size = Vector2.one * ShapeDefaults.Size; - return new RoundedRectangle( - proxy, null, Vector3.zero, Quaternion.identity, size, DefaultCornerRadius, Ring.DefaultSegmentCount); + return proxy.Add(new RoundedRectangle( + null, Vector3.zero, Quaternion.identity, size, DefaultCornerRadius, Ring.DefaultSegmentCount)); } /// @@ -24,7 +24,7 @@ public static IRoundedRectangle CreateRoundedRectangle(this WireframeContainer c /// world-space corners. If the corners differ in height, the rectangle lies halfway between them. /// /// - /// Number of straight pieces a full circle of the corners is drawn with, a positive multiple of 4. + /// Number of straight pieces a full circle of the corners is drawn with, a multiple of 4 from 4 to 1024. /// public static IRoundedRectangle CreateRoundedRectangle( this WireframeContainer container, @@ -36,7 +36,7 @@ public static IRoundedRectangle CreateRoundedRectangle( MeshProxy proxy = WireframeContainer.ProxyOf(container); Vector3 center = (cornerA + cornerB) * 0.5f; Vector2 size = RectangleShape.FlatSpan(cornerA, cornerB); - return new RoundedRectangle(proxy, null, center, Quaternion.identity, size, cornerRadius, segmentCount); + return proxy.Add(new RoundedRectangle(null, center, Quaternion.identity, size, cornerRadius, segmentCount)); } /// @@ -44,7 +44,7 @@ public static IRoundedRectangle CreateRoundedRectangle( /// and spanned by two opposite corners in the bone's local space. /// /// - /// Number of straight pieces a full circle of the corners is drawn with, a positive multiple of 4. + /// Number of straight pieces a full circle of the corners is drawn with, a multiple of 4 from 4 to 1024. /// public static IRoundedRectangle CreateRoundedRectangle( this WireframeContainer container, @@ -57,14 +57,14 @@ public static IRoundedRectangle CreateRoundedRectangle( MeshProxy proxy = WireframeContainer.ProxyOf(container); Vector3 center = (localCornerA + localCornerB) * 0.5f; Vector2 size = RectangleShape.FlatSpan(localCornerA, localCornerB); - return new RoundedRectangle(proxy, bone, center, Quaternion.identity, size, cornerRadius, segmentCount); + return proxy.Add(new RoundedRectangle(bone, center, Quaternion.identity, size, cornerRadius, segmentCount)); } /// /// Creates a white rounded rectangle in world space, lying in the XZ plane of . /// /// - /// Number of straight pieces a full circle of the corners is drawn with, a positive multiple of 4. + /// Number of straight pieces a full circle of the corners is drawn with, a multiple of 4 from 4 to 1024. /// public static IRoundedRectangle CreateRoundedRectangle( this WireframeContainer container, @@ -75,7 +75,7 @@ public static IRoundedRectangle CreateRoundedRectangle( int segmentCount = Ring.DefaultSegmentCount) { MeshProxy proxy = WireframeContainer.ProxyOf(container); - return new RoundedRectangle(proxy, null, center, rotation, size, cornerRadius, segmentCount); + return proxy.Add(new RoundedRectangle(null, center, rotation, size, cornerRadius, segmentCount)); } } } diff --git a/UnityPackage/Runtime/Shapes/Sphere/Sphere.cs b/UnityPackage/Runtime/Shapes/Sphere/Sphere.cs index 27f6726..835be28 100644 --- a/UnityPackage/Runtime/Shapes/Sphere/Sphere.cs +++ b/UnityPackage/Runtime/Shapes/Sphere/Sphere.cs @@ -8,14 +8,12 @@ internal sealed class Sphere : RigidShape, ISphere private float _radius; internal Sphere( - MeshProxy proxy, Transform bone, Vector3 localCenter, Quaternion localRotation, float radius, int segmentCount) : base( - proxy, Ring.CheckSegmentCount(segmentCount) * AxisRings.RingCount, AxisRings.Patterns.Get(segmentCount), bone, diff --git a/UnityPackage/Runtime/Shapes/Sphere/SphereFactory.cs b/UnityPackage/Runtime/Shapes/Sphere/SphereFactory.cs index c924b61..da7dde8 100644 --- a/UnityPackage/Runtime/Shapes/Sphere/SphereFactory.cs +++ b/UnityPackage/Runtime/Shapes/Sphere/SphereFactory.cs @@ -9,22 +9,24 @@ public static class SphereFactory public static ISphere CreateSphere(this WireframeContainer container) { MeshProxy proxy = WireframeContainer.ProxyOf(container); - return new Sphere(proxy, null, Vector3.zero, Quaternion.identity, ShapeDefaults.Radius, Ring.DefaultSegmentCount); + return proxy.Add(new Sphere( + null, Vector3.zero, Quaternion.identity, ShapeDefaults.Radius, Ring.DefaultSegmentCount)); } /// Creates a white, world-aligned sphere around a world position. - /// Number of straight pieces each great circle is drawn with, at least 3. + /// Number of straight pieces each great circle is drawn with, from 3 to 1024. public static ISphere CreateSphere( this WireframeContainer container, Vector3 center, float radius, int segmentCount = Ring.DefaultSegmentCount) { - return new Sphere(WireframeContainer.ProxyOf(container), null, center, Quaternion.identity, radius, segmentCount); + MeshProxy proxy = WireframeContainer.ProxyOf(container); + return proxy.Add(new Sphere(null, center, Quaternion.identity, radius, segmentCount)); } /// /// Creates a white sphere that follows , around a position in the bone's local space and /// aligned to the bone's axes. /// - /// Number of straight pieces each great circle is drawn with, at least 3. + /// Number of straight pieces each great circle is drawn with, from 3 to 1024. public static ISphere CreateSphere( this WireframeContainer container, Transform bone, @@ -33,11 +35,11 @@ public static ISphere CreateSphere( int segmentCount = Ring.DefaultSegmentCount) { MeshProxy proxy = WireframeContainer.ProxyOf(container); - return new Sphere(proxy, bone, localCenter, Quaternion.identity, radius, segmentCount); + return proxy.Add(new Sphere(bone, localCenter, Quaternion.identity, radius, segmentCount)); } /// Creates a white sphere in world space, with its great circles turned by . - /// Number of straight pieces each great circle is drawn with, at least 3. + /// Number of straight pieces each great circle is drawn with, from 3 to 1024. public static ISphere CreateSphere( this WireframeContainer container, Vector3 center, @@ -45,7 +47,8 @@ public static ISphere CreateSphere( float radius, int segmentCount = Ring.DefaultSegmentCount) { - return new Sphere(WireframeContainer.ProxyOf(container), null, center, rotation, radius, segmentCount); + MeshProxy proxy = WireframeContainer.ProxyOf(container); + return proxy.Add(new Sphere(null, center, rotation, radius, segmentCount)); } } } diff --git a/UnityPackage/Runtime/Shapes/SpikedSphere/SpikedSphere.cs b/UnityPackage/Runtime/Shapes/SpikedSphere/SpikedSphere.cs index 168248b..4b5dc93 100644 --- a/UnityPackage/Runtime/Shapes/SpikedSphere/SpikedSphere.cs +++ b/UnityPackage/Runtime/Shapes/SpikedSphere/SpikedSphere.cs @@ -10,19 +10,17 @@ internal sealed class SpikedSphere : RigidShape, ISpikedSphere private float _spikeLength; internal SpikedSphere( - MeshProxy proxy, Transform bone, Vector3 localCenter, Quaternion localRotation, float baseRadius, float spikeLength, int spikeCount) - : this(proxy, bone, localCenter, localRotation, baseRadius, spikeLength, SolidFor(spikeCount)) + : this(bone, localCenter, localRotation, baseRadius, spikeLength, SolidFor(spikeCount)) { } private SpikedSphere( - MeshProxy proxy, Transform bone, Vector3 localCenter, Quaternion localRotation, @@ -30,7 +28,6 @@ private SpikedSphere( float spikeLength, PlatonicSolid solid) : base( - proxy, solid.Corners.Length + solid.Faces.Length, solid.EdgePattern, bone, diff --git a/UnityPackage/Runtime/Shapes/SpikedSphere/SpikedSphereFactory.cs b/UnityPackage/Runtime/Shapes/SpikedSphere/SpikedSphereFactory.cs index 39aa629..37c0f18 100644 --- a/UnityPackage/Runtime/Shapes/SpikedSphere/SpikedSphereFactory.cs +++ b/UnityPackage/Runtime/Shapes/SpikedSphere/SpikedSphereFactory.cs @@ -15,7 +15,7 @@ public static ISpikedSphere CreateSpikedSphere(this WireframeContainer container { MeshProxy proxy = WireframeContainer.ProxyOf(container); float half = ShapeDefaults.Radius * 0.5f; - return new SpikedSphere(proxy, null, Vector3.zero, Quaternion.identity, half, half, DefaultSpikeCount); + return proxy.Add(new SpikedSphere(null, Vector3.zero, Quaternion.identity, half, half, DefaultSpikeCount)); } /// Creates a white, world-aligned spiked sphere around a world position. @@ -24,7 +24,7 @@ public static ISpikedSphere CreateSpikedSphere( this WireframeContainer container, Vector3 center, float baseRadius, float spikeLength, int spikeCount) { MeshProxy proxy = WireframeContainer.ProxyOf(container); - return new SpikedSphere(proxy, null, center, Quaternion.identity, baseRadius, spikeLength, spikeCount); + return proxy.Add(new SpikedSphere(null, center, Quaternion.identity, baseRadius, spikeLength, spikeCount)); } /// @@ -41,7 +41,8 @@ public static ISpikedSphere CreateSpikedSphere( int spikeCount) { MeshProxy proxy = WireframeContainer.ProxyOf(container); - return new SpikedSphere(proxy, bone, localCenter, Quaternion.identity, baseRadius, spikeLength, spikeCount); + return proxy.Add(new SpikedSphere( + bone, localCenter, Quaternion.identity, baseRadius, spikeLength, spikeCount)); } /// Creates a white spiked sphere in world space, turned by . @@ -55,7 +56,7 @@ public static ISpikedSphere CreateSpikedSphere( int spikeCount) { MeshProxy proxy = WireframeContainer.ProxyOf(container); - return new SpikedSphere(proxy, null, center, rotation, baseRadius, spikeLength, spikeCount); + return proxy.Add(new SpikedSphere(null, center, rotation, baseRadius, spikeLength, spikeCount)); } } } diff --git a/UnityPackage/Runtime/Shapes/Stadium/Stadium.cs b/UnityPackage/Runtime/Shapes/Stadium/Stadium.cs index 97da750..709c3b9 100644 --- a/UnityPackage/Runtime/Shapes/Stadium/Stadium.cs +++ b/UnityPackage/Runtime/Shapes/Stadium/Stadium.cs @@ -11,7 +11,6 @@ internal sealed class Stadium : AxialShape, IStadium private float _radiusB; internal Stadium( - MeshProxy proxy, Transform bone, Vector3 localPosition, Quaternion localRotation, @@ -20,7 +19,6 @@ internal Stadium( float radiusB, int segmentCount) : base( - proxy, Ring.CheckQuarterSegmentCount(segmentCount) + ArcCount, // One closed loop: arc A, the -X side, arc B and the +X side back to the start. Ring.Patterns.Get(segmentCount + ArcCount), diff --git a/UnityPackage/Runtime/Shapes/Stadium/StadiumFactory.cs b/UnityPackage/Runtime/Shapes/Stadium/StadiumFactory.cs index fc2fdbd..6eb13a4 100644 --- a/UnityPackage/Runtime/Shapes/Stadium/StadiumFactory.cs +++ b/UnityPackage/Runtime/Shapes/Stadium/StadiumFactory.cs @@ -12,22 +12,21 @@ public static class StadiumFactory public static IStadium CreateStadium(this WireframeContainer container) { MeshProxy proxy = WireframeContainer.ProxyOf(container); - return new Stadium( - proxy, + return proxy.Add(new Stadium( null, Vector3.zero, Quaternion.identity, ShapeDefaults.Size, ShapeDefaults.Radius, ShapeDefaults.Radius, - Ring.DefaultSegmentCount); + Ring.DefaultSegmentCount)); } /// /// Creates a white stadium around two circles of the same radius, centered on two world positions. It lies as /// flat as it can: its normal stays as close to world up as the centers allow. /// - /// Number of straight pieces a full circle would be drawn with, a positive multiple of 4. + /// Number of straight pieces a full circle would be drawn with, a multiple of 4 from 4 to 1024. public static IStadium CreateStadium( this WireframeContainer container, Vector3 centerA, @@ -42,7 +41,7 @@ public static IStadium CreateStadium( /// Creates a white stadium around two circles centered on two world positions, each with its own radius. It lies /// as flat as it can: its normal stays as close to world up as the centers allow. /// - /// Number of straight pieces a full circle would be drawn with, a positive multiple of 4. + /// Number of straight pieces a full circle would be drawn with, a multiple of 4 from 4 to 1024. public static IStadium CreateStadium( this WireframeContainer container, Vector3 centerA, @@ -58,7 +57,7 @@ public static IStadium CreateStadium( /// Creates a white stadium around two circles centered on two world positions, facing as close to /// as the centers allow. /// - /// Number of straight pieces a full circle would be drawn with, a positive multiple of 4. + /// Number of straight pieces a full circle would be drawn with, a multiple of 4 from 4 to 1024. public static IStadium CreateStadium( this WireframeContainer container, Vector3 centerA, @@ -71,14 +70,14 @@ public static IStadium CreateStadium( MeshProxy proxy = WireframeContainer.ProxyOf(container); Quaternion rotation = ShapeRotations.FromAxis(centerB - centerA, normal); float length = Vector3.Distance(centerA, centerB); - return new Stadium(proxy, null, centerA, rotation, length, radiusA, radiusB, segmentCount); + return proxy.Add(new Stadium(null, centerA, rotation, length, radiusA, radiusB, segmentCount)); } /// /// Creates a white stadium that follows , around two circles centered on positions in the /// bone's local space. It lies as flat in the bone's XZ plane as the centers allow. /// - /// Number of straight pieces a full circle would be drawn with, a positive multiple of 4. + /// Number of straight pieces a full circle would be drawn with, a multiple of 4 from 4 to 1024. public static IStadium CreateStadium( this WireframeContainer container, Transform bone, @@ -91,7 +90,7 @@ public static IStadium CreateStadium( MeshProxy proxy = WireframeContainer.ProxyOf(container); Quaternion rotation = ShapeRotations.FromAxis(localCenterB - localCenterA, Vector3.up); float length = Vector3.Distance(localCenterA, localCenterB); - return new Stadium(proxy, bone, localCenterA, rotation, length, radiusA, radiusB, segmentCount); + return proxy.Add(new Stadium(bone, localCenterA, rotation, length, radiusA, radiusB, segmentCount)); } /// @@ -99,7 +98,7 @@ public static IStadium CreateStadium( /// circle is centered on and whose second is centered /// along the rotation's +Z axis. /// - /// Number of straight pieces a full circle would be drawn with, a positive multiple of 4. + /// Number of straight pieces a full circle would be drawn with, a multiple of 4 from 4 to 1024. public static IStadium CreateStadium( this WireframeContainer container, Vector3 position, @@ -110,7 +109,7 @@ public static IStadium CreateStadium( int segmentCount = Ring.DefaultSegmentCount) { MeshProxy proxy = WireframeContainer.ProxyOf(container); - return new Stadium(proxy, null, position, rotation, length, radiusA, radiusB, segmentCount); + return proxy.Add(new Stadium(null, position, rotation, length, radiusA, radiusB, segmentCount)); } } } diff --git a/UnityPackage/Runtime/Shapes/Star/Star.cs b/UnityPackage/Runtime/Shapes/Star/Star.cs index 6464737..f6b2a67 100644 --- a/UnityPackage/Runtime/Shapes/Star/Star.cs +++ b/UnityPackage/Runtime/Shapes/Star/Star.cs @@ -5,18 +5,19 @@ namespace reromanlee.Wireframes { internal sealed class Star : RigidShape, IStar { + internal const int MaxPointCount = Ring.MaxSegmentCount / 2; + private float _innerRadius; private float _outerRadius; internal Star( - MeshProxy proxy, Transform bone, Vector3 localCenter, Quaternion localRotation, float innerRadius, float outerRadius, int pointCount) - : base(proxy, CountVertices(pointCount), Ring.Patterns.Get(pointCount * 2), bone, localCenter, localRotation) + : base(CountVertices(pointCount), Ring.Patterns.Get(pointCount * 2), bone, localCenter, localRotation) { _innerRadius = innerRadius; _outerRadius = outerRadius; @@ -64,11 +65,12 @@ public int PointCount protected override void WriteShape(Span positions) { // A ring that alternates between the tips and the corners between them, starting with a tip on +Z. - Vector2[] circle = Ring.UnitCircle(positions.Length); + CirclePoints circle = new(positions.Length); for (int i = 0; i < positions.Length; i++) { + Vector2 point = circle.Next(); float radius = i % 2 == 0 ? _outerRadius : _innerRadius; - positions[i] = new Vector3(circle[i].y * radius, 0f, circle[i].x * radius); + positions[i] = new Vector3(point.y * radius, 0f, point.x * radius); } } @@ -80,9 +82,11 @@ protected override void ScaleSizes(float factor) private static int CountVertices(int pointCount) { - if (pointCount < 3) + // Its tips and corners make one ring. + if (pointCount < 3 || pointCount > MaxPointCount) { - throw new ArgumentOutOfRangeException(nameof(pointCount), pointCount, "A star needs at least 3 points."); + throw new ArgumentOutOfRangeException( + nameof(pointCount), pointCount, $"A star needs 3 to {MaxPointCount} points."); } return pointCount * 2; } diff --git a/UnityPackage/Runtime/Shapes/Star/StarFactory.cs b/UnityPackage/Runtime/Shapes/Star/StarFactory.cs index e6b29c9..cf33301 100644 --- a/UnityPackage/Runtime/Shapes/Star/StarFactory.cs +++ b/UnityPackage/Runtime/Shapes/Star/StarFactory.cs @@ -15,27 +15,27 @@ public static class StarFactory public static IStar CreateStar(this WireframeContainer container) { MeshProxy proxy = WireframeContainer.ProxyOf(container); - return new Star( - proxy, null, Vector3.zero, Quaternion.identity, DefaultInnerRadius, ShapeDefaults.Radius, DefaultPointCount); + return proxy.Add(new Star( + null, Vector3.zero, Quaternion.identity, DefaultInnerRadius, ShapeDefaults.Radius, DefaultPointCount)); } /// /// Creates a white star around a world position, lying flat in the world's XZ plane with its first point along /// +Z. /// - /// Number of points, at least 3. + /// Number of points, from 3 to 512. public static IStar CreateStar( this WireframeContainer container, Vector3 center, float innerRadius, float outerRadius, int pointCount) { MeshProxy proxy = WireframeContainer.ProxyOf(container); - return new Star(proxy, null, center, Quaternion.identity, innerRadius, outerRadius, pointCount); + return proxy.Add(new Star(null, center, Quaternion.identity, innerRadius, outerRadius, pointCount)); } /// /// Creates a white star that follows , around a position in the bone's local space and /// lying flat in the bone's XZ plane with its first point along the bone's +Z. /// - /// Number of points, at least 3. + /// Number of points, from 3 to 512. public static IStar CreateStar( this WireframeContainer container, Transform bone, @@ -45,14 +45,14 @@ public static IStar CreateStar( int pointCount) { MeshProxy proxy = WireframeContainer.ProxyOf(container); - return new Star(proxy, bone, localCenter, Quaternion.identity, innerRadius, outerRadius, pointCount); + return proxy.Add(new Star(bone, localCenter, Quaternion.identity, innerRadius, outerRadius, pointCount)); } /// /// Creates a white star in world space, lying in the XZ plane of with its first point /// along the rotation's +Z. /// - /// Number of points, at least 3. + /// Number of points, from 3 to 512. public static IStar CreateStar( this WireframeContainer container, Vector3 center, @@ -62,7 +62,7 @@ public static IStar CreateStar( int pointCount) { MeshProxy proxy = WireframeContainer.ProxyOf(container); - return new Star(proxy, null, center, rotation, innerRadius, outerRadius, pointCount); + return proxy.Add(new Star(null, center, rotation, innerRadius, outerRadius, pointCount)); } } } diff --git a/UnityPackage/Tests/Editor/BoneValidationTests.cs b/UnityPackage/Tests/Editor/BoneValidationTests.cs new file mode 100644 index 0000000..ef7c551 --- /dev/null +++ b/UnityPackage/Tests/Editor/BoneValidationTests.cs @@ -0,0 +1,62 @@ +using System; +using NUnit.Framework; +using UnityEditor; +using UnityEngine; +using Object = UnityEngine.Object; + +namespace reromanlee.Wireframes.Tests +{ + /// Bones must be scene objects: a prefab asset never moves, so following one is always a mistake. + public class BoneValidationTests + { + private const string PrefabPath = "Assets/WireframesBoneValidationTest.prefab"; + + private WireframeContainer _container; + private Transform _prefabBone; + private GameObject _sceneBone; + + [SetUp] + public void CreateBones() + { + GameObject source = new("Prefab Bone"); + _prefabBone = PrefabUtility.SaveAsPrefabAsset(source, PrefabPath).transform; + Object.DestroyImmediate(source); + _sceneBone = new GameObject("Scene Bone"); + _container = new WireframeContainer(); + } + + [TearDown] + public void DestroyBones() + { + _container.Dispose(); + Object.DestroyImmediate(_sceneBone); + AssetDatabase.DeleteAsset(PrefabPath); + } + + [Test] + public void CreatingAShapeOnAPrefabAsset_ThrowsAndLeavesNothingBehind() + { + Assert.Throws(() => _container.CreateCircle(_prefabBone, Vector3.zero, 1f)); + ArgumentException exception = Assert.Throws( + () => _container.CreateLine(_sceneBone.transform, _prefabBone)); + Assert.Throws(() => _container.CreatePolyline(_sceneBone.transform, _prefabBone)); + + Assert.That(exception.ParamName, Is.EqualTo("boneB")); + Assert.That(_container.Proxy.Chunks, Is.Empty); + Assert.That(_container.Proxy.Bones.Count, Is.Zero); + } + + [Test] + public void SettingAPrefabAssetAsBone_ThrowsAndKeepsTheBone() + { + ICircle circle = _container.CreateCircle(_sceneBone.transform, Vector3.zero, 1f); + ILine line = _container.CreateLine(); + + Assert.Throws(() => circle.Bone = _prefabBone); + Assert.Throws(() => line.BoneA = _prefabBone); + + Assert.That(circle.Bone, Is.SameAs(_sceneBone.transform)); + Assert.That(line.BoneA, Is.Null); + } + } +} diff --git a/UnityPackage/Tests/Editor/BoneValidationTests.cs.meta b/UnityPackage/Tests/Editor/BoneValidationTests.cs.meta new file mode 100644 index 0000000..55588f7 --- /dev/null +++ b/UnityPackage/Tests/Editor/BoneValidationTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1314ed380c2017ba6c34aa602c822edb \ No newline at end of file diff --git a/UnityPackage/Tests/Editor/CirclePointsTests.cs b/UnityPackage/Tests/Editor/CirclePointsTests.cs new file mode 100644 index 0000000..d89c351 --- /dev/null +++ b/UnityPackage/Tests/Editor/CirclePointsTests.cs @@ -0,0 +1,34 @@ +using System; +using NUnit.Framework; +using UnityEngine; + +namespace reromanlee.Wireframes.Tests +{ + public class CirclePointsTests + { + [Test] + public void EveryPoint_MatchesItsCosineAndSine([Values(3, 4, 32, 1000, 1024)] int count) + { + CirclePoints circle = new(count); + + for (int i = 0; i < count; i++) + { + double angle = 2.0 * Math.PI * i / count; + Vector2 point = circle.Next(); + Assert.That(point.x, Is.EqualTo((float)Math.Cos(angle)).Within(1e-6f), $"Point {i} of {count}"); + Assert.That(point.y, Is.EqualTo((float)Math.Sin(angle)).Within(1e-6f), $"Point {i} of {count}"); + } + } + + [Test] + public void First_StartsPartWayAround() + { + CirclePoints circle = new(8, 2); + + Vector2 point = circle.Next(); + + Assert.That(point.x, Is.EqualTo(0f).Within(1e-6f)); + Assert.That(point.y, Is.EqualTo(1f).Within(1e-6f)); + } + } +} diff --git a/UnityPackage/Tests/Editor/CirclePointsTests.cs.meta b/UnityPackage/Tests/Editor/CirclePointsTests.cs.meta new file mode 100644 index 0000000..bc544e9 --- /dev/null +++ b/UnityPackage/Tests/Editor/CirclePointsTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c19f70acab4bf95674777385dc8286c6 \ No newline at end of file diff --git a/UnityPackage/Tests/Editor/DirtyRangesTests.cs b/UnityPackage/Tests/Editor/DirtyRangesTests.cs index f69da2c..c1649a8 100644 --- a/UnityPackage/Tests/Editor/DirtyRangesTests.cs +++ b/UnityPackage/Tests/Editor/DirtyRangesTests.cs @@ -34,6 +34,37 @@ public void Merge_CollapsesManyScatteredRangesIntoOne() Assert.That(ranges[0].length, Is.EqualTo(39002)); } + [Test] + public void Add_WithoutFlushes_KeepsTheListSmallAndCoversEveryRange() + { + DirtyRanges ranges = new(); + for (int i = 0; i < 100000; i++) + { + ranges.Add(i * 1000, 2); + } + + Assert.That(ranges.Count, Is.LessThanOrEqualTo(256)); + Assert.That(ranges.Merge(), Is.EqualTo(1)); + Assert.That(ranges[0].start, Is.Zero); + Assert.That(ranges[0].end, Is.EqualTo(99999 * 1000 + 2)); + } + + [Test] + public void Clear_EndsTheCoveringRange() + { + DirtyRanges ranges = new(); + for (int i = 0; i < 1000; i++) + { + ranges.Add(i * 1000, 2); + } + + ranges.Clear(); + ranges.Add(0, 2); + ranges.Add(50000, 2); + + Assert.That(ranges.Merge(), Is.EqualTo(2)); + } + [Test] public void Add_IgnoresEmptyRanges() { diff --git a/UnityPackage/Tests/Runtime/ReliabilityTests.cs b/UnityPackage/Tests/Runtime/ReliabilityTests.cs new file mode 100644 index 0000000..f74a8fd --- /dev/null +++ b/UnityPackage/Tests/Runtime/ReliabilityTests.cs @@ -0,0 +1,217 @@ +using System; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.TestTools; + +namespace reromanlee.Wireframes.Tests +{ + /// What keeps the package safe to misuse, usable without a GPU and steady over long runs. + public class ReliabilityTests : WireframesTestBase + { + [Test] + public void WithoutGraphics_ShapesWorkButNothingIsCreatedForTheGpu() + { + WireframeContainer container; + MeshProxy.SimulateNoGraphics = true; + try + { + container = CreateContainer(new WireframeContainerSettings { VertexCapacity = 1000 }); + } + finally + { + MeshProxy.SimulateNoGraphics = false; + } + Transform bone = CreateBone(new Vector3(1f, 2f, 3f), Quaternion.identity); + + ICircle circle = container.CreateCircle(bone, Vector3.zero, 1f); + circle.Radius = 2f; + container.CreateLine(Vector3.zero, Vector3.one).Dispose(); + container.Proxy.Flush(); + + Assert.That(container.Proxy.IsHeadless, Is.True); + Assert.That(container.Proxy.Chunks, Is.Empty); + Assert.That(container.Proxy.GetComponentsInChildren(), Is.Empty); + Assert.That(container.Proxy.HeadlessHost.ShapeCount, Is.EqualTo(1)); + AssertApproximately(new Vector3(1f, 2f, 3f), circle.WorldPosition); + Assert.That(circle.Radius, Is.EqualTo(2f)); + + container.Dispose(); + Assert.That(circle.IsDisposed, Is.True); + } + + [Test] + public void EdgePatterns_AreSharedAndDroppedWithTheLastShapeUsingThem() + { + // A segment count no other test uses. + const int segmentCount = 997; + WireframeContainer container = CreateContainer(); + ICircle first = container.CreateCircle(Vector3.zero, 1f, segmentCount); + ICircle second = container.CreateCircle(Vector3.one, 1f, segmentCount); + Assert.That(Ring.Patterns.Contains(segmentCount), Is.True); + + first.Dispose(); + Assert.That(Ring.Patterns.Contains(segmentCount), Is.True); + second.Dispose(); + Assert.That(Ring.Patterns.Contains(segmentCount), Is.False); + + container.CreateSphere(Vector3.zero, 1f, segmentCount); + Assert.That(AxisRings.Patterns.Contains(segmentCount), Is.True); + container.Dispose(); + Assert.That(AxisRings.Patterns.Contains(segmentCount), Is.False); + } + + [Test] + public void SettingTheSameBoneAgain_ChangesNothing() + { + WireframeContainer container = CreateContainer(); + Transform bone = CreateBone(new Vector3(1f, 2f, 3f), Quaternion.Euler(10f, 20f, 30f), 1.7f); + ICircle circle = container.CreateCircle(bone, new Vector3(0.1f, 0.2f, 0.3f), 1.3f); + ILine line = container.CreateLine(bone, bone); + line.LocalPositionA = new Vector3(0.4f, 0.5f, 0.6f); + container.Proxy.Flush(); + + for (int i = 0; i < 100; i++) + { + circle.Bone = bone; + line.BoneA = bone; + } + + // Exactly: the round trip through world space that a different bone takes would round them. + Assert.That(circle.LocalPosition, Is.EqualTo(new Vector3(0.1f, 0.2f, 0.3f))); + Assert.That(circle.Radius, Is.EqualTo(1.3f)); + Assert.That(line.LocalPositionA, Is.EqualTo(new Vector3(0.4f, 0.5f, 0.6f))); + Assert.That(ChunkOf(circle).PendingCount, Is.Zero, "A same-bone set queued an upload."); + } + + [Test] + public void ResolutionsPastTheLimit_Throw() + { + WireframeContainer container = CreateContainer(); + int tooMany = Ring.MaxSegmentCount + 4; + + Assert.Throws(() => container.CreateCircle(Vector3.zero, 1f, tooMany)); + Assert.Throws(() => container.CreateSphere(Vector3.zero, 1f, tooMany)); + Assert.Throws(() => container.CreateCapsule(Vector3.zero, Vector3.up, 1f, tooMany)); + Assert.Throws(() => container.CreateFrustum(Vector3.zero, Vector3.up, 1f, 1f, tooMany)); + Assert.Throws( + () => container.CreateStar(Vector3.zero, 0.5f, 1f, Star.MaxPointCount + 1)); + Assert.That(ShapeCountOf(container), Is.Zero); + + Assert.That(container.CreateCircle(Vector3.zero, 1f, Ring.MaxSegmentCount).SegmentCount, Is.EqualTo(1024)); + Assert.That(container.CreateStar(Vector3.zero, 0.5f, 1f, Star.MaxPointCount).PointCount, Is.EqualTo(512)); + } + + [Test] + public void AShapeThatFailsToUpdate_IsReportedOnceAndTheOthersStillUpdate() + { + WireframeContainer container = CreateContainer(); + ILine before = container.CreateLine(Vector3.zero, Vector3.one); + FaultyShape faulty = container.Proxy.Add(new FaultyShape()); + ILine after = container.CreateLine(Vector3.one, Vector3.one * 2f); + + LogAssert.Expect(LogType.Error, new Regex(@"^\[Wireframes\] A FaultyShape failed to update")); + AssertApproximately(Vector3.one * 2f, BakeShape(container, after)[1]); + AssertApproximately(Vector3.one, BakeShape(container, before)[1]); + + // A second failure isn't logged; an unexpected error log would fail the test. + faulty.MarkDirty(DirtyFlags.Positions); + container.Proxy.Flush(); + } + + [Test] + public void PositionsThatArentFinite_AreReportedOncePerShape() + { + WireframeContainer container = CreateContainer(); + ILine line = container.CreateLine(Vector3.zero, new Vector3(float.NaN, 0f, 0f)); + int warnings = 0; + void CountWarnings(string message, string stackTrace, LogType type) + { + if (type == LogType.Warning && message.Contains("A Line has a position that isn't a finite number")) + { + warnings++; + } + } + + Application.logMessageReceived += CountWarnings; + try + { + container.Proxy.Flush(); + line.LocalPositionA = new Vector3(float.PositiveInfinity, 0f, 0f); + container.Proxy.Flush(); + } + finally + { + Application.logMessageReceived -= CountWarnings; + } + + Assert.That(warnings, Is.EqualTo(1)); + } + + [Test] + public void DisposedShapes_DontPileUpWhileNothingFlushes() + { + WireframeContainer container = CreateContainer(); + ILine kept = container.CreateLine(); + + for (int i = 0; i < 10000; i++) + { + container.CreateLine().Dispose(); + } + + MeshChunk chunk = ChunkOf(kept); + Assert.That(chunk.PendingCount, Is.LessThan(100)); + Assert.That(chunk.Allocator.End, Is.EqualTo(4), "Freed vertex blocks weren't reused."); + } + + [Test] + public void CallsFromAnotherThread_Throw() + { + WireframeContainer container = CreateContainer(); + ILine line = container.CreateLine(); + + AggregateException fromShape = Assert.Throws( + () => Task.Run(() => line.ColorA = Color.red).Wait()); + AggregateException fromFactory = Assert.Throws( + () => Task.Run(() => container.CreateLine()).Wait()); + + Assert.That(fromShape.InnerException, Is.TypeOf()); + Assert.That(fromFactory.InnerException, Is.TypeOf()); + Assert.That(ShapeCountOf(container), Is.EqualTo(1)); + } + + /// A shape whose positions always fail to write. + private sealed class FaultyShape : Shape + { + internal FaultyShape() : base(2, new[] { 0, 1 }) + { + } + + public override void SetColor(Color color) + { + } + + internal override void WritePositions(Span positions) + { + throw new InvalidOperationException("Broken on purpose."); + } + + internal override void WriteColors(Span colors) + { + } + + internal override void WriteBoneIndices(Span boneIndices) + { + } + + protected override void AcquireBones(BoneRegistry bones) + { + } + + protected override void ReleaseBones(BoneRegistry bones) + { + } + } + } +} diff --git a/UnityPackage/Tests/Runtime/ReliabilityTests.cs.meta b/UnityPackage/Tests/Runtime/ReliabilityTests.cs.meta new file mode 100644 index 0000000..63c07cc --- /dev/null +++ b/UnityPackage/Tests/Runtime/ReliabilityTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: ab5e693fb154c79b93fa8231fad639ff \ No newline at end of file From 304cf135471b2fa82c267f7cf045d5ad688175c4 Mon Sep 17 00:00:00 2001 From: Roman Likhadievski Date: Tue, 29 Sep 2026 16:14:06 +0300 Subject: [PATCH 07/13] feature: edit mode --- UnityPackage/Editor.meta | 8 + UnityPackage/Editor/AssemblyInfo.cs | 3 + UnityPackage/Editor/AssemblyInfo.cs.meta | 2 + UnityPackage/Editor/EditModeLifecycle.cs | 107 ++++++++ UnityPackage/Editor/EditModeLifecycle.cs.meta | 2 + .../reromanlee.Wireframes.Editor.asmdef | 18 ++ .../reromanlee.Wireframes.Editor.asmdef.meta | 7 + UnityPackage/Runtime/AssemblyInfo.cs | 1 + .../Runtime/Container/WireframeContainer.cs | 10 +- .../Container/WireframeContainerSettings.cs | 5 +- UnityPackage/Runtime/Core/UnityObjects.cs | 8 +- UnityPackage/Runtime/Rendering/MeshChunk.cs | 6 +- UnityPackage/Runtime/Rendering/MeshProxy.cs | 53 +++- UnityPackage/Tests/Editor/EditModeTests.cs | 244 ++++++++++++++++++ .../Tests/Editor/EditModeTests.cs.meta | 2 + .../reromanlee.Wireframes.Tests.Editor.asmdef | 1 + 16 files changed, 466 insertions(+), 11 deletions(-) create mode 100644 UnityPackage/Editor.meta create mode 100644 UnityPackage/Editor/AssemblyInfo.cs create mode 100644 UnityPackage/Editor/AssemblyInfo.cs.meta create mode 100644 UnityPackage/Editor/EditModeLifecycle.cs create mode 100644 UnityPackage/Editor/EditModeLifecycle.cs.meta create mode 100644 UnityPackage/Editor/reromanlee.Wireframes.Editor.asmdef create mode 100644 UnityPackage/Editor/reromanlee.Wireframes.Editor.asmdef.meta create mode 100644 UnityPackage/Tests/Editor/EditModeTests.cs create mode 100644 UnityPackage/Tests/Editor/EditModeTests.cs.meta diff --git a/UnityPackage/Editor.meta b/UnityPackage/Editor.meta new file mode 100644 index 0000000..f4bd918 --- /dev/null +++ b/UnityPackage/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 56ceb271f26f6631eff17196aa8d32fd +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityPackage/Editor/AssemblyInfo.cs b/UnityPackage/Editor/AssemblyInfo.cs new file mode 100644 index 0000000..6acaf20 --- /dev/null +++ b/UnityPackage/Editor/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("reromanlee.Wireframes.Tests.Editor")] diff --git a/UnityPackage/Editor/AssemblyInfo.cs.meta b/UnityPackage/Editor/AssemblyInfo.cs.meta new file mode 100644 index 0000000..02c5424 --- /dev/null +++ b/UnityPackage/Editor/AssemblyInfo.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: cb32bf5d1512b7a80e7d47d92d0f380c \ No newline at end of file diff --git a/UnityPackage/Editor/EditModeLifecycle.cs b/UnityPackage/Editor/EditModeLifecycle.cs new file mode 100644 index 0000000..b49a2e7 --- /dev/null +++ b/UnityPackage/Editor/EditModeLifecycle.cs @@ -0,0 +1,107 @@ +using System.Collections.Generic; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; +using UnityEngine.SceneManagement; + +namespace reromanlee.Wireframes.Editor +{ + /// + /// Gives containers created in Edit Mode the lifetime a scene object would have. They are disposed before scripts + /// reload, which would otherwise leave their GameObjects behind without their state, and when the scene they were + /// created in closes, unless they persist across scenes. Their idle chunks are released from the editor loop, where + /// objects may be destroyed right away. Entering or leaving Play Mode alone disposes nothing. + /// + [InitializeOnLoad] + internal static class EditModeLifecycle + { + static EditModeLifecycle() + { + AssemblyReloadEvents.beforeAssemblyReload += DisposeEveryContainer; + EditorSceneManager.sceneClosing += OnSceneClosing; + EditorApplication.update += ReleaseIdleChunks; + } + + /// Disposes every container, Play Mode ones included, destroying their objects right away. + internal static void DisposeEveryContainer() + { + IReadOnlyList proxies = MeshProxy.LiveProxies; + // No frame comes after a reload starts, so nothing may be left for the end of one. + UnityObjects.DestroysImmediately = true; + try + { + for (int i = proxies.Count - 1; i >= 0; i--) + { + proxies[i].Container.Dispose(); + } + } + finally + { + UnityObjects.DestroysImmediately = false; + } + } + + /// + /// Disposes the Edit Mode containers of a scene that closes, and moves the persistent ones into another open + /// scene, since closing a scene destroys even the objects it never saves. + /// + internal static void OnSceneClosing(Scene scene, bool removingScene) + { + // Entering Play Mode closes the edited scene, and leaving it closes the played ones; neither disposes anything. + if (Application.isPlaying || EditorApplication.isPlayingOrWillChangePlaymode) + { + return; + } + IReadOnlyList proxies = MeshProxy.LiveProxies; + for (int i = proxies.Count - 1; i >= 0; i--) + { + MeshProxy proxy = proxies[i]; + if (!proxy.IsEditMode || proxy.HomeScene != scene) + { + continue; + } + if (proxy.PersistsAcrossScenes) + { + MoveToAnotherScene(proxy, scene); + } + else + { + proxy.Container.Dispose(); + } + } + } + + /// Releases the idle chunks of every container while not in Play Mode, which releases its own. + internal static void ReleaseIdleChunks() + { + if (Application.isPlaying) + { + return; + } + IReadOnlyList proxies = MeshProxy.LiveProxies; + float time = Time.realtimeSinceStartup; + for (int i = proxies.Count - 1; i >= 0; i--) + { + proxies[i].ReleaseIdleChunks(time); + } + } + + /// + /// Moves into another loaded scene. When none is left, as when a scene replaces all + /// open ones, the proxy stays out of any scene, where it keeps drawing. + /// + private static void MoveToAnotherScene(MeshProxy proxy, Scene closing) + { + for (int i = 0; i < SceneManager.sceneCount; i++) + { + Scene scene = SceneManager.GetSceneAt(i); + if (scene.isLoaded && scene != closing) + { + SceneManager.MoveGameObjectToScene(proxy.gameObject, scene); + proxy.HomeScene = scene; + return; + } + } + } + } +} diff --git a/UnityPackage/Editor/EditModeLifecycle.cs.meta b/UnityPackage/Editor/EditModeLifecycle.cs.meta new file mode 100644 index 0000000..8d1f77a --- /dev/null +++ b/UnityPackage/Editor/EditModeLifecycle.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 29ee05747cac57fd2263b490e307a3ed \ No newline at end of file diff --git a/UnityPackage/Editor/reromanlee.Wireframes.Editor.asmdef b/UnityPackage/Editor/reromanlee.Wireframes.Editor.asmdef new file mode 100644 index 0000000..5a97294 --- /dev/null +++ b/UnityPackage/Editor/reromanlee.Wireframes.Editor.asmdef @@ -0,0 +1,18 @@ +{ + "name": "reromanlee.Wireframes.Editor", + "rootNamespace": "reromanlee.Wireframes.Editor", + "references": [ + "reromanlee.Wireframes" + ], + "includePlatforms": [ + "Editor" + ], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": false, + "precompiledReferences": [], + "autoReferenced": true, + "defineConstraints": [], + "versionDefines": [], + "noEngineReferences": false +} diff --git a/UnityPackage/Editor/reromanlee.Wireframes.Editor.asmdef.meta b/UnityPackage/Editor/reromanlee.Wireframes.Editor.asmdef.meta new file mode 100644 index 0000000..6b11845 --- /dev/null +++ b/UnityPackage/Editor/reromanlee.Wireframes.Editor.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 14222136f3057885a71657de5caf1352 +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityPackage/Runtime/AssemblyInfo.cs b/UnityPackage/Runtime/AssemblyInfo.cs index 333da64..dc1ec6d 100644 --- a/UnityPackage/Runtime/AssemblyInfo.cs +++ b/UnityPackage/Runtime/AssemblyInfo.cs @@ -1,4 +1,5 @@ using System.Runtime.CompilerServices; +[assembly: InternalsVisibleTo("reromanlee.Wireframes.Editor")] [assembly: InternalsVisibleTo("reromanlee.Wireframes.Tests.Editor")] [assembly: InternalsVisibleTo("reromanlee.Wireframes.Tests.Runtime")] diff --git a/UnityPackage/Runtime/Container/WireframeContainer.cs b/UnityPackage/Runtime/Container/WireframeContainer.cs index c1629c0..e967029 100644 --- a/UnityPackage/Runtime/Container/WireframeContainer.cs +++ b/UnityPackage/Runtime/Container/WireframeContainer.cs @@ -14,6 +14,12 @@ namespace reromanlee.Wireframes /// made to its shapes are uploaded right before a camera renders them. Without a graphics device, as in server /// builds, or without a usable shader, shapes keep working but nothing is drawn or uploaded. Bones must be scene /// objects: Create methods and bone setters throw for a prefab asset. + /// + /// Containers work the same in Edit Mode, drawing in the Scene and Game views. There, a container is never saved + /// into its scene and never marks it as changed; it is disposed before scripts reload and when its scene closes, + /// unless it persists across scenes. Switching between Edit and Play Mode alone never disposes a container, though + /// the script reload that Play Mode starts with by default does. + /// /// public sealed class WireframeContainer : IDisposable { @@ -36,7 +42,9 @@ public WireframeContainer(WireframeContainerSettings settings) settings ??= new WireframeContainerSettings(); settings.Validate(nameof(settings)); - GameObject proxyObject = new(settings.ResolvedName) { hideFlags = HideFlags.NotEditable, layer = settings.Layer }; + // In Edit Mode it is kept out of the saved scene, which also keeps the scene from being marked as changed. + HideFlags hideFlags = HideFlags.NotEditable | (Application.isPlaying ? HideFlags.None : HideFlags.DontSave); + GameObject proxyObject = new(settings.ResolvedName) { hideFlags = hideFlags, layer = settings.Layer }; try { // Otherwise the proxy lives in the active scene, and unloading that scene disposes the container. diff --git a/UnityPackage/Runtime/Container/WireframeContainerSettings.cs b/UnityPackage/Runtime/Container/WireframeContainerSettings.cs index 832d25f..8dca4af 100644 --- a/UnityPackage/Runtime/Container/WireframeContainerSettings.cs +++ b/UnityPackage/Runtime/Container/WireframeContainerSettings.cs @@ -64,8 +64,9 @@ public int Layer } /// - /// True keeps the container when other scenes load in Play mode, like Object.DontDestroyOnLoad. False, the - /// default, disposes it with the scene that was active when it was created. + /// True keeps the container when other scenes load, like Object.DontDestroyOnLoad in Play Mode and when + /// scenes are opened or closed in Edit Mode. False, the default, disposes it with the scene that was active when + /// it was created. /// public bool PersistAcrossScenes { diff --git a/UnityPackage/Runtime/Core/UnityObjects.cs b/UnityPackage/Runtime/Core/UnityObjects.cs index 395a1f1..626e738 100644 --- a/UnityPackage/Runtime/Core/UnityObjects.cs +++ b/UnityPackage/Runtime/Core/UnityObjects.cs @@ -4,6 +4,12 @@ namespace reromanlee.Wireframes { internal static class UnityObjects { + /// + /// True makes destroy right away in Play Mode too, for work that runs where no later frame + /// would do it, such as right before scripts reload. + /// + internal static bool DestroysImmediately { get; set; } + /// Destroys with the call Unity allows in the current mode. public static void Destroy(Object target) { @@ -11,7 +17,7 @@ public static void Destroy(Object target) { return; } - if (Application.isPlaying) + if (Application.isPlaying && !DestroysImmediately) { Object.Destroy(target); } diff --git a/UnityPackage/Runtime/Rendering/MeshChunk.cs b/UnityPackage/Runtime/Rendering/MeshChunk.cs index a57c5b2..ed16538 100644 --- a/UnityPackage/Runtime/Rendering/MeshChunk.cs +++ b/UnityPackage/Runtime/Rendering/MeshChunk.cs @@ -103,10 +103,12 @@ internal MeshChunk( _edges = new EdgeList(edgeCapacity); _bones = bones; - GameObject chunkObject = new(ObjectName) { hideFlags = HideFlags.NotEditable, layer = layer }; + // Same flags as the container's GameObject, so an Edit Mode chunk is never saved either. + GameObject chunkObject = new(ObjectName) { hideFlags = parent.gameObject.hideFlags, layer = layer }; chunkObject.transform.SetParent(parent, false); - _mesh = new Mesh { name = ObjectName }; + // Owned and destroyed by the chunk, so it is kept from saving and from unloading as an unused asset. + _mesh = new Mesh { name = ObjectName, hideFlags = HideFlags.DontSave }; _mesh.MarkDynamic(); _mesh.subMeshCount = 1; _mesh.bounds = FixedBounds; diff --git a/UnityPackage/Runtime/Rendering/MeshProxy.cs b/UnityPackage/Runtime/Rendering/MeshProxy.cs index a1c036f..47cab9a 100644 --- a/UnityPackage/Runtime/Rendering/MeshProxy.cs +++ b/UnityPackage/Runtime/Rendering/MeshProxy.cs @@ -2,19 +2,22 @@ using System.Collections.Generic; using UnityEngine; using UnityEngine.Rendering; +using UnityEngine.SceneManagement; namespace reromanlee.Wireframes { /// - /// Component on the container's GameObject. Right before each render, it applies queued shape edits and reads the - /// bones' matrices, so everything that moved or changed earlier in the frame shows up in that frame. It releases every - /// resource when its GameObject is destroyed, including when the scene unloads. Where nothing can draw, it keeps the - /// shapes in a and does no rendering work at all. + /// Component on the container's GameObject, in Play and Edit Mode alike. Right before each render, it applies queued + /// shape edits and reads the bones' matrices, so everything that moved or changed earlier in the frame shows up in + /// that frame. It releases every resource when its GameObject is destroyed, including when the scene unloads. Where + /// nothing can draw, it keeps the shapes in a and does no rendering work at all. /// [AddComponentMenu("")] [DisallowMultipleComponent] + [ExecuteAlways] internal sealed class MeshProxy : MonoBehaviour { + private static readonly List Live = new(); private static bool _hasWarnedAboutShader; private readonly BoneRegistry _bones = new(); @@ -30,6 +33,28 @@ internal sealed class MeshProxy : MonoBehaviour /// Makes new containers act as they do without a graphics device, so tests can cover server builds. internal static bool SimulateNoGraphics { get; set; } + /// The proxy of every container that isn't disposed yet, for editor code that manages their lifetime. + internal static IReadOnlyList LiveProxies + { + get => Live; + } + + internal WireframeContainer Container + { + get => _container; + } + + /// True for a container created in Edit Mode, which the editor disposes itself. + internal bool IsEditMode { get; private set; } + + internal bool PersistsAcrossScenes { get; private set; } + + /// + /// The scene the container belongs to. It still identifies that scene after the proxy leaves it, as it does when + /// Play Mode reloads the scene and the proxy, never saved, stays outside any scene. + /// + internal Scene HomeScene { get; set; } + /// True when the container draws nothing, because there is no graphics device or no usable shader. internal bool IsHeadless { @@ -70,6 +95,10 @@ internal BoneTexture BoneTexture internal void Initialize(WireframeContainer container, WireframeContainerSettings settings) { _container = container; + IsEditMode = !Application.isPlaying; + PersistsAcrossScenes = settings.PersistAcrossScenes; + HomeScene = gameObject.scene; + Live.Add(this); if (!TryCreateMaterials(settings)) { _headless = new HeadlessHost(_bones); @@ -99,6 +128,18 @@ internal IShapeHost Attach(Shape shape) return _chunks.Attach(shape); } + /// + /// Releases the chunks that stayed empty long enough; see for where + /// this may run. + /// + internal void ReleaseIdleChunks(float time) + { + if (!_isShutDown) + { + _chunks?.ReleaseIdleChunks(time); + } + } + /// Reads the bones, writes queued shapes and uploads what changed. internal void Flush() { @@ -121,6 +162,7 @@ internal void Shutdown() return; } _isShutDown = true; + Live.Remove(this); // The container learns first, so it reads as disposed even if releasing something below throws. _container?.OnProxyShutdown(); _chunks?.Dispose(); @@ -220,9 +262,10 @@ private void FlushBeforeRendering() { Flush(); // Play Mode only, where destroying waits for the end of the frame and is allowed in a render callback. + // In Edit Mode, the editor's update loop releases them instead. if (Application.isPlaying) { - _chunks.ReleaseIdleChunks(Time.realtimeSinceStartup); + ReleaseIdleChunks(Time.realtimeSinceStartup); } } catch (Exception exception) diff --git a/UnityPackage/Tests/Editor/EditModeTests.cs b/UnityPackage/Tests/Editor/EditModeTests.cs new file mode 100644 index 0000000..8b0d5f7 --- /dev/null +++ b/UnityPackage/Tests/Editor/EditModeTests.cs @@ -0,0 +1,244 @@ +using System.Collections; +using System.Collections.Generic; +using NUnit.Framework; +using reromanlee.Wireframes.Editor; +using UnityEditor; +using UnityEditor.SceneManagement; +using UnityEngine; +using UnityEngine.SceneManagement; +using UnityEngine.TestTools; +using Object = UnityEngine.Object; + +namespace reromanlee.Wireframes.Tests +{ + /// Containers in Edit Mode: drawn, never saved, and disposed with their scene or before scripts reload. + public class EditModeTests + { + private const string ScenePath = "Assets/WireframesEditModeTest.unity"; + private const int Size = 32; + private const float HalfExtent = 4f; + + private readonly List _containers = new(); + private readonly List _objects = new(); + + [SetUp] + public void OpenEmptyScene() + { + EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single); + } + + [TearDown] + public void DestroyEverything() + { + foreach (WireframeContainer container in _containers) + { + container.Dispose(); + } + for (int i = _objects.Count - 1; i >= 0; i--) + { + if (_objects[i] != null) + { + Object.DestroyImmediate(_objects[i]); + } + } + _containers.Clear(); + _objects.Clear(); + AssetDatabase.DeleteAsset(ScenePath); + } + + [Test] + public void Container_IsNeverSavedAndLeavesTheSceneUnchanged() + { + Scene scene = SceneManager.GetActiveScene(); + + WireframeContainer container = CreateContainer(); + container.CreateSphere(Vector3.zero, 1f); + container.Proxy.Flush(); + + Assert.That(scene.isDirty, Is.False); + Assert.That(container.Proxy.IsEditMode, Is.True); + Assert.That(container.Proxy.gameObject.hideFlags & HideFlags.DontSave, Is.EqualTo(HideFlags.DontSave)); + foreach (MeshChunk chunk in container.Proxy.Chunks) + { + Assert.That(chunk.Renderer.gameObject.hideFlags & HideFlags.DontSave, Is.EqualTo(HideFlags.DontSave)); + } + } + + [Test] + public void Container_DrawsWhenACameraRendersAndItsBoneTextureGrows() + { + WireframeContainer container = CreateContainer(); + container.CreateLine(new Vector3(-2f, 0f, 0f), new Vector3(2f, 0f, 0f)).SetColor(Color.red); + Assert.That(RedAtCenter(), Is.GreaterThan(0.5f)); + + // More bones than one texture row holds, so the texture is replaced during the next render. + for (int i = 0; i < BoneTexture.BonesPerRow + 1; i++) + { + Transform bone = Track(new GameObject("Bone")).transform; + bone.position = new Vector3(50f, i, 0f); + container.CreateLine(bone, bone); + } + + Assert.That(RedAtCenter(), Is.GreaterThan(0.5f)); + Assert.That(container.Proxy.BoneTexture.Texture.height, Is.EqualTo(2)); + } + + [Test] + public void Container_IsDisposedWhenItsSceneCloses() + { + WireframeContainer container = CreateContainer(); + ILine line = container.CreateLine(); + GameObject proxyObject = container.Proxy.gameObject; + + EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single); + + Assert.That(container.IsDisposed, Is.True); + Assert.That(line.IsDisposed, Is.True); + Assert.That(proxyObject == null, Is.True); + } + + [Test] + public void PersistentContainer_SurvivesItsSceneClosingAndKeepsDrawing() + { + WireframeContainer container = CreateContainer(new WireframeContainerSettings { PersistAcrossScenes = true }); + container.CreateLine(new Vector3(-2f, 0f, 0f), new Vector3(2f, 0f, 0f)).SetColor(Color.red); + + EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Single); + + Assert.That(container.IsDisposed, Is.False); + Assert.That(RedAtCenter(), Is.GreaterThan(0.5f)); + } + + [Test] + public void PersistentContainer_MovesOutOfAnAdditiveSceneThatCloses() + { + Scene first = SceneManager.GetActiveScene(); + EditorSceneManager.SaveScene(first, ScenePath); + Scene additive = EditorSceneManager.NewScene(NewSceneSetup.EmptyScene, NewSceneMode.Additive); + SceneManager.SetActiveScene(additive); + WireframeContainer persistent = CreateContainer(new WireframeContainerSettings { PersistAcrossScenes = true }); + WireframeContainer plain = CreateContainer(); + SceneManager.SetActiveScene(first); + + EditorSceneManager.CloseScene(additive, true); + + Assert.That(persistent.IsDisposed, Is.False); + Assert.That(persistent.Proxy.gameObject.scene, Is.EqualTo(first)); + Assert.That(plain.IsDisposed, Is.True); + } + + [Test] + public void ScriptReload_DisposesEveryContainerRightAway() + { + WireframeContainer plain = CreateContainer(); + WireframeContainer persistent = CreateContainer(new WireframeContainerSettings { PersistAcrossScenes = true }); + GameObject proxyObject = persistent.Proxy.gameObject; + + EditModeLifecycle.DisposeEveryContainer(); + + Assert.That(plain.IsDisposed, Is.True); + Assert.That(persistent.IsDisposed, Is.True); + Assert.That(proxyObject == null, Is.True); + Assert.That(MeshProxy.LiveProxies, Is.Empty); + } + + [Test] + public void IdleChunks_AreReleasedFromTheEditorLoop() + { + WireframeContainer container = CreateContainer(); + List lines = new(); + for (int i = 0; i < MeshChunk.MaxVertexCount / 2 + 10; i++) + { + lines.Add(container.CreateLine()); + } + MeshChunk second = container.Proxy.Chunks[1]; + foreach (ILine line in lines) + { + if (((Shape)line).Chunk == second) + { + line.Dispose(); + } + } + + container.Proxy.ReleaseIdleChunks(1000f); + container.Proxy.ReleaseIdleChunks(1000f + ChunkAllocator.ReleaseDelay); + EditModeLifecycle.ReleaseIdleChunks(); + + Assert.That(container.Proxy.Chunks, Has.Count.EqualTo(1)); + Assert.That(second.Renderer == null, Is.True, "Edit Mode destroys a released chunk right away."); + } + + [UnityTest] + public IEnumerator EnteringAndLeavingPlayMode_KeepsTheContainer() + { + // Without a domain reload; with one, the reload disposes every container before Play Mode starts. + bool optionsEnabled = EditorSettings.enterPlayModeOptionsEnabled; + EnterPlayModeOptions options = EditorSettings.enterPlayModeOptions; + EditorSettings.enterPlayModeOptionsEnabled = true; + EditorSettings.enterPlayModeOptions = EnterPlayModeOptions.DisableDomainReload; + WireframeContainer container = CreateContainer(); + ILine line = container.CreateLine(); + try + { + yield return new EnterPlayMode(); + Assert.That(container.IsDisposed, Is.False); + line.ColorA = Color.red; + + yield return new ExitPlayMode(); + Assert.That(container.IsDisposed, Is.False); + Assert.That(line.ColorA, Is.EqualTo(Color.red)); + } + finally + { + EditorSettings.enterPlayModeOptionsEnabled = optionsEnabled; + EditorSettings.enterPlayModeOptions = options; + } + } + + private WireframeContainer CreateContainer(WireframeContainerSettings settings = null) + { + WireframeContainer container = new(settings); + _containers.Add(container); + return container; + } + + private T Track(T target) where T : Object + { + _objects.Add(target); + return target; + } + + /// Renders the world around the origin with a camera and returns the red at the center. + private float RedAtCenter() + { + RenderTexture target = Track(new RenderTexture(Size, Size, 24)); + Texture2D readback = Track(new Texture2D(Size, Size, TextureFormat.RGBA32, false)); + Camera camera = Track(new GameObject("Camera")).AddComponent(); + camera.enabled = false; + camera.allowMSAA = false; + camera.orthographic = true; + camera.orthographicSize = HalfExtent; + camera.transform.position = new Vector3(0f, 0f, -10f); + camera.clearFlags = CameraClearFlags.SolidColor; + camera.backgroundColor = Color.black; + camera.targetTexture = target; + camera.Render(); + camera.targetTexture = null; + + RenderTexture previous = RenderTexture.active; + RenderTexture.active = target; + readback.ReadPixels(new Rect(0f, 0f, Size, Size), 0, 0); + readback.Apply(); + RenderTexture.active = previous; + float red = 0f; + for (int y = Size / 2 - 1; y <= Size / 2; y++) + { + for (int x = Size / 2 - 1; x <= Size / 2; x++) + { + red = Mathf.Max(red, readback.GetPixel(x, y).r); + } + } + return red; + } + } +} diff --git a/UnityPackage/Tests/Editor/EditModeTests.cs.meta b/UnityPackage/Tests/Editor/EditModeTests.cs.meta new file mode 100644 index 0000000..f8db353 --- /dev/null +++ b/UnityPackage/Tests/Editor/EditModeTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 46ab8f25de330ac29587208f5921a439 \ No newline at end of file diff --git a/UnityPackage/Tests/Editor/reromanlee.Wireframes.Tests.Editor.asmdef b/UnityPackage/Tests/Editor/reromanlee.Wireframes.Tests.Editor.asmdef index e9e4f90..726a062 100644 --- a/UnityPackage/Tests/Editor/reromanlee.Wireframes.Tests.Editor.asmdef +++ b/UnityPackage/Tests/Editor/reromanlee.Wireframes.Tests.Editor.asmdef @@ -3,6 +3,7 @@ "rootNamespace": "reromanlee.Wireframes.Tests", "references": [ "reromanlee.Wireframes", + "reromanlee.Wireframes.Editor", "UnityEngine.TestRunner", "UnityEditor.TestRunner" ], From 8ad5a8a9a54f2d110f58ebd7a6ef59340f389ec8 Mon Sep 17 00:00:00 2001 From: Roman Likhadievski Date: Tue, 29 Sep 2026 17:53:16 +0300 Subject: [PATCH 08/13] feature: visibility --- .../Runtime/Container/WireframeContainer.cs | 20 ++ UnityPackage/Runtime/Core/DirtyRanges.cs | 24 +- UnityPackage/Runtime/Core/IShapeHost.cs | 12 + UnityPackage/Runtime/Core/Shape.cs | 77 +++++- .../Runtime/Rendering/ChunkAllocator.cs | 24 +- .../Runtime/Rendering/HeadlessHost.cs | 14 + UnityPackage/Runtime/Rendering/MeshChunk.cs | 112 ++++++-- UnityPackage/Runtime/Rendering/MeshProxy.cs | 10 + UnityPackage/Runtime/Shapes/IShape.cs | 6 + .../Tests/Runtime/ReliabilityTests.cs | 43 +++ UnityPackage/Tests/Runtime/ShaderTests.cs | 41 +++ UnityPackage/Tests/Runtime/VisibilityTests.cs | 250 ++++++++++++++++++ .../Tests/Runtime/VisibilityTests.cs.meta | 2 + 13 files changed, 601 insertions(+), 34 deletions(-) create mode 100644 UnityPackage/Tests/Runtime/VisibilityTests.cs create mode 100644 UnityPackage/Tests/Runtime/VisibilityTests.cs.meta diff --git a/UnityPackage/Runtime/Container/WireframeContainer.cs b/UnityPackage/Runtime/Container/WireframeContainer.cs index e967029..36dc1bb 100644 --- a/UnityPackage/Runtime/Container/WireframeContainer.cs +++ b/UnityPackage/Runtime/Container/WireframeContainer.cs @@ -68,6 +68,26 @@ public bool IsDisposed get => _isDisposed; } + /// + /// False hides every shape of the container without disposing anything, and costs nothing while it lasts: edits + /// made meanwhile are applied when it is shown again. Each shape's own still + /// counts. True by default. + /// + /// The container is disposed. + public bool IsVisible + { + get + { + MainThread.Check(); + return Proxy.IsVisible; + } + set + { + MainThread.Check(); + Proxy.IsVisible = value; + } + } + internal MeshProxy Proxy { get diff --git a/UnityPackage/Runtime/Core/DirtyRanges.cs b/UnityPackage/Runtime/Core/DirtyRanges.cs index 8e5a34f..9a41114 100644 --- a/UnityPackage/Runtime/Core/DirtyRanges.cs +++ b/UnityPackage/Runtime/Core/DirtyRanges.cs @@ -1,5 +1,4 @@ using System; -using System.Collections.Generic; using UnityEngine; namespace reromanlee.Wireframes @@ -17,8 +16,6 @@ internal sealed class DirtyRanges // it, such as while nothing renders. private const int MaxCollectedRanges = 256; - private static readonly Comparer ByStart = Comparer.Create((a, b) => a.start.CompareTo(b.start)); - private RangeInt[] _ranges = new RangeInt[16]; private int _count; // Once so many ranges came in that they collapsed into one covering range, later ones only widen it. @@ -79,7 +76,7 @@ internal int Merge() { return _count; } - Array.Sort(_ranges, 0, _count, ByStart); + SortByStart(); int merged = 0; RangeInt current = _ranges[0]; for (int i = 1; i < _count; i++) @@ -104,5 +101,24 @@ internal int Merge() _count = merged; return merged; } + + /// + /// Insertion sort by start: there are at most a few hundred ranges, often already in order, and unlike + /// Array.Sort with a comparer, it allocates nothing. + /// + private void SortByStart() + { + for (int i = 1; i < _count; i++) + { + RangeInt range = _ranges[i]; + int j = i - 1; + while (j >= 0 && _ranges[j].start > range.start) + { + _ranges[j + 1] = _ranges[j]; + j--; + } + _ranges[j + 1] = range; + } + } } } diff --git a/UnityPackage/Runtime/Core/IShapeHost.cs b/UnityPackage/Runtime/Core/IShapeHost.cs index 0a96bad..a5c286d 100644 --- a/UnityPackage/Runtime/Core/IShapeHost.cs +++ b/UnityPackage/Runtime/Core/IShapeHost.cs @@ -10,5 +10,17 @@ internal interface IShapeHost void Enqueue(Shape shape); void Remove(Shape shape); + + /// Draws the edges of again after . + void Show(Shape shape); + + /// Stops drawing the edges of , keeping everything else it has. + void Hide(Shape shape); + + /// + /// Holds again after it was removed from this host to change its size, and returns the + /// host that holds it now: this one when it has room, another one otherwise. + /// + IShapeHost Reattach(Shape shape); } } diff --git a/UnityPackage/Runtime/Core/Shape.cs b/UnityPackage/Runtime/Core/Shape.cs index c0b6e4b..8103305 100644 --- a/UnityPackage/Runtime/Core/Shape.cs +++ b/UnityPackage/Runtime/Core/Shape.cs @@ -10,11 +10,12 @@ namespace reromanlee.Wireframes /// internal abstract class Shape : IShape, IEdgeOwner { - private readonly EdgeSource _edgeSource; + private EdgeSource _edgeSource; private int[] _edgePattern; private int[] _edgeSlots; private IShapeHost _host; private DirtyFlags _dirty; + private bool _isHidden; protected Shape(int vertexCount, EdgeSource edgeSource) { @@ -27,19 +28,57 @@ public bool IsDisposed get => _host == null; } + public bool IsVisible + { + get + { + EnsureUsable(); + return !_isHidden; + } + set + { + EnsureUsable(); + bool isVisible = !_isHidden; + if (value == isVisible) + { + return; + } + // Shown only once the host managed to add the edges, so a failure leaves the shape hidden. + if (value) + { + _host.Show(this); + _isHidden = false; + } + else + { + _isHidden = true; + _host.Hide(this); + } + } + } + /// The chunk that draws the shape, or null when it is disposed or nothing can draw it. internal MeshChunk Chunk { get => _host as MeshChunk; } - internal int VertexCount { get; } + /// True while the shape is hidden, when its edges aren't in its chunk's drawn edges. + internal bool IsHidden + { + get => _isHidden; + } + + internal int VertexCount { get; private set; } internal int VertexStart { get; set; } /// Position in its host's shape list. internal int ShapeIndex { get; set; } + /// Position in its chunk's queue of shapes to write, or -1 while not queued there. + internal int PendingIndex { get; set; } = -1; + internal int EdgeCount { get => _edgeSlots.Length; @@ -93,6 +132,40 @@ internal void Attach(MeshProxy proxy) MarkDirty(DirtyFlags.All); } + /// + /// Changes the shape's vertex count and edge pattern while it stays the same object, for shapes whose number of + /// points changes. Derived shapes update their own points and bones first. Its host moves it where the new size + /// fits, and everything is written again on the next flush. If no room can be found, the shape ends up disposed + /// and the exception is rethrown. + /// + internal void Resize(int vertexCount, EdgeSource edgeSource) + { + IShapeHost host = _host; + host.Remove(this); + _edgeSource.Release(); + VertexCount = vertexCount; + _edgeSource = edgeSource; + _edgePattern = edgeSource.Acquire(); + if (_edgeSlots.Length != _edgePattern.Length / 2) + { + _edgeSlots = new int[_edgePattern.Length / 2]; + } + // Removing it dropped its queue entry, so it has to be queued again wherever it lands. + _dirty = DirtyFlags.None; + try + { + _host = host.Reattach(this); + } + catch + { + ReleaseBones(host.Bones); + _edgeSource.Release(); + _host = null; + throw; + } + MarkDirty(DirtyFlags.All); + } + internal int GetEdgeSlot(int edge) { return _edgeSlots[edge]; diff --git a/UnityPackage/Runtime/Rendering/ChunkAllocator.cs b/UnityPackage/Runtime/Rendering/ChunkAllocator.cs index da9804d..55c85b6 100644 --- a/UnityPackage/Runtime/Rendering/ChunkAllocator.cs +++ b/UnityPackage/Runtime/Rendering/ChunkAllocator.cs @@ -40,6 +40,28 @@ internal IReadOnlyList Chunks get => _chunks; } + /// Transform the chunks' GameObjects are created under. + internal Transform Parent + { + get => _parent; + } + + /// Materials every chunk draws with. + internal Material[] Materials + { + get => _materials; + } + + internal int Layer + { + get => _layer; + } + + internal BoneRegistry Bones + { + get => _bones; + } + /// /// Creates chunks with room for vertices and edges, /// split evenly between as few chunks as that takes. They are never released. @@ -166,7 +188,7 @@ private MeshChunk FindChunk(int vertexCount) private MeshChunk CreateChunk(int vertexCapacity, int edgeCapacity) { - MeshChunk chunk = new(_parent, _materials, _layer, _bones, vertexCapacity, edgeCapacity); + MeshChunk chunk = new(this, vertexCapacity, edgeCapacity); chunk.Renderer.SetPropertyBlock(_propertyBlock); _chunks.Add(chunk); return chunk; diff --git a/UnityPackage/Runtime/Rendering/HeadlessHost.cs b/UnityPackage/Runtime/Rendering/HeadlessHost.cs index 67a2039..296be1b 100644 --- a/UnityPackage/Runtime/Rendering/HeadlessHost.cs +++ b/UnityPackage/Runtime/Rendering/HeadlessHost.cs @@ -49,6 +49,20 @@ public void Remove(Shape shape) _shapes[last] = null; } + public void Show(Shape shape) + { + } + + public void Hide(Shape shape) + { + } + + public IShapeHost Reattach(Shape shape) + { + Add(shape); + return this; + } + public void Dispose() { for (int i = 0; i < _shapeCount; i++) diff --git a/UnityPackage/Runtime/Rendering/MeshChunk.cs b/UnityPackage/Runtime/Rendering/MeshChunk.cs index ed16538..2d88353 100644 --- a/UnityPackage/Runtime/Rendering/MeshChunk.cs +++ b/UnityPackage/Runtime/Rendering/MeshChunk.cs @@ -47,6 +47,7 @@ internal sealed class MeshChunk : IShapeHost, IDisposable private const MeshUpdateFlags UploadFlags = MeshUpdateFlags.DontValidateIndices | MeshUpdateFlags.DontRecalculateBounds; + private readonly ChunkAllocator _owner; private readonly MeshRenderer _renderer; private readonly Mesh _mesh; private readonly BoneRegistry _bones; @@ -81,16 +82,17 @@ internal sealed class MeshChunk : IShapeHost, IDisposable private IndexFormat _meshIndexFormat; private int _meshEdgeCount = -1; - /// - /// Materials the mesh is drawn with, each drawing all of it; the chunk leaves them to their owner. + /// + /// The allocator the chunk belongs to, which gives it its parent, layer, bones and materials. Every material draws + /// all of the mesh; the chunk leaves them to their owner. /// /// /// Vertices the chunk starts with. Past , it is a large chunk. /// /// Edges the chunk starts with. - internal MeshChunk( - Transform parent, Material[] materials, int layer, BoneRegistry bones, int vertexCapacity, int edgeCapacity) + internal MeshChunk(ChunkAllocator owner, int vertexCapacity, int edgeCapacity) { + _owner = owner; _vertexLimit = Math.Max(vertexCapacity, MaxVertexCount); // Checked before anything is created, so a device limit leaves nothing behind. CheckBufferSize((long)vertexCapacity * PositionSize); @@ -101,10 +103,11 @@ internal MeshChunk( _colors = new Color32[vertexCapacity]; _boneIndices = new float[vertexCapacity]; _edges = new EdgeList(edgeCapacity); - _bones = bones; + _bones = owner.Bones; // Same flags as the container's GameObject, so an Edit Mode chunk is never saved either. - GameObject chunkObject = new(ObjectName) { hideFlags = parent.gameObject.hideFlags, layer = layer }; + Transform parent = owner.Parent; + GameObject chunkObject = new(ObjectName) { hideFlags = parent.gameObject.hideFlags, layer = owner.Layer }; chunkObject.transform.SetParent(parent, false); // Owned and destroyed by the chunk, so it is kept from saving and from unloading as an unused asset. @@ -116,7 +119,7 @@ internal MeshChunk( _renderer = chunkObject.AddComponent(); // A renderer with more materials than sub-meshes draws its last sub-mesh once per extra material. - _renderer.sharedMaterials = materials; + _renderer.sharedMaterials = owner.Materials; _renderer.motionVectorGenerationMode = MotionVectorGenerationMode.ForceNoMotion; _renderer.shadowCastingMode = ShadowCastingMode.Off; _renderer.receiveShadows = false; @@ -159,12 +162,18 @@ internal int ShapeCount get => _shapeCount; } - /// Shapes queued for the next flush. + /// Entries in the queue of shapes to write on the next flush, including those emptied by removals. internal int PendingCount { get => _pendingCount; } + /// Entries the queue has room for before it grows. + internal int PendingCapacity + { + get => _pending.Length; + } + internal int EdgeCount { get => _edges.Count; @@ -224,12 +233,15 @@ internal void Add(Shape shape) { // Capacity is checked before anything changes, so a device limit leaves the chunk untouched. EnsureVertexCapacity(_allocator.EndAfterAllocate(shape.VertexCount)); - EnsureEdgeCapacity(_edges.Count + shape.EdgeCount); + if (!shape.IsHidden) + { + EnsureEdgeCapacity(_edges.Count + shape.EdgeCount); + } shape.VertexStart = _allocator.Allocate(shape.VertexCount); - for (int edge = 0; edge < shape.EdgeCount; edge++) + if (!shape.IsHidden) { - shape.SetEdgeSlot(edge, _edges.Add(shape, edge)); + AddEdges(shape); } if (_shapeCount == _shapes.Length) { @@ -242,17 +254,18 @@ internal void Add(Shape shape) public void Remove(Shape shape) { - // The last edge moves into each freed slot; later edges of this same shape may be among them, and - // their owner callback keeps the shape's slots current while the loop runs. - for (int edge = 0; edge < shape.EdgeCount; edge++) + if (!shape.IsHidden) { - int slot = shape.GetEdgeSlot(edge); - if (_edges.RemoveAt(slot)) - { - _edgeRanges.Add(slot, 1); - } + RemoveEdges(shape); } _allocator.Free(shape.VertexStart, shape.VertexCount); + // Its queue entry is emptied, so a shape that comes back is never queued twice. + int pending = shape.PendingIndex; + if (pending >= 0 && pending < _pendingCount && _pending[pending] == shape) + { + _pending[pending] = null; + } + shape.PendingIndex = -1; int last = --_shapeCount; Shape moved = _shapes[last]; @@ -261,18 +274,36 @@ public void Remove(Shape shape) _shapes[last] = null; } + public void Show(Shape shape) + { + EnsureEdgeCapacity(_edges.Count + shape.EdgeCount); + AddEdges(shape); + shape.MarkDirty(DirtyFlags.Edges); + } + + public void Hide(Shape shape) + { + RemoveEdges(shape); + } + + public IShapeHost Reattach(Shape shape) + { + return _owner.Attach(shape); + } + public void Enqueue(Shape shape) { if (_pendingCount == _pending.Length) { - // Without renders to flush the queue, disposed shapes would pile up in it; each live shape is queued - // at most once, so dropping them keeps the queue within the number of live shapes. - RemoveDisposedPending(); + // Without renders to flush the queue, the entries of removed shapes would pile up in it; each shape of + // the chunk is queued at most once, so dropping them keeps the queue within the chunk's shape count. + CompactPending(); if (_pendingCount == _pending.Length) { Array.Resize(ref _pending, _pendingCount * 2); } } + shape.PendingIndex = _pendingCount; _pending[_pendingCount++] = shape; } @@ -330,11 +361,13 @@ private void WritePendingShapes() for (int i = 0; i < _pendingCount; i++) { Shape shape = _pending[i]; - _pending[i] = null; - if (shape.IsDisposed) + // Removed shapes leave an empty entry behind. + if (shape == null) { continue; } + _pending[i] = null; + shape.PendingIndex = -1; try { WriteShape(shape, shape.TakeDirty()); @@ -373,12 +406,35 @@ private void WriteShape(Shape shape, DirtyFlags dirty) shape.WriteBoneIndices(new Span(_boneIndices, start, count)); _boneRanges.Add(start, count); } - if ((dirty & DirtyFlags.Edges) != 0) + // A hidden shape has no edge slots; showing it marks its edges again. + if ((dirty & DirtyFlags.Edges) != 0 && !shape.IsHidden) { shape.WriteEdges(_edges, _edgeRanges); } } + private void AddEdges(Shape shape) + { + for (int edge = 0; edge < shape.EdgeCount; edge++) + { + shape.SetEdgeSlot(edge, _edges.Add(shape, edge)); + } + } + + private void RemoveEdges(Shape shape) + { + // The last edge moves into each freed slot; later edges of this same shape may be among them, and + // their owner callback keeps the shape's slots current while the loop runs. + for (int edge = 0; edge < shape.EdgeCount; edge++) + { + int slot = shape.GetEdgeSlot(edge); + if (_edges.RemoveAt(slot)) + { + _edgeRanges.Add(slot, 1); + } + } + } + /// Warns once per shape about positions that aren't finite, in the Editor and development builds. [System.Diagnostics.Conditional("UNITY_EDITOR"), System.Diagnostics.Conditional("DEVELOPMENT_BUILD")] private void CheckPositions(Shape shape) @@ -403,14 +459,16 @@ private void CheckPositions(Shape shape) } } - private void RemoveDisposedPending() + /// Drops the empty entries that removed shapes left in the queue. + private void CompactPending() { int kept = 0; for (int i = 0; i < _pendingCount; i++) { Shape shape = _pending[i]; - if (!shape.IsDisposed) + if (shape != null) { + shape.PendingIndex = kept; _pending[kept++] = shape; } } diff --git a/UnityPackage/Runtime/Rendering/MeshProxy.cs b/UnityPackage/Runtime/Rendering/MeshProxy.cs index 47cab9a..3fa62a5 100644 --- a/UnityPackage/Runtime/Rendering/MeshProxy.cs +++ b/UnityPackage/Runtime/Rendering/MeshProxy.cs @@ -61,6 +61,16 @@ internal bool IsHeadless get => _headless != null; } + /// + /// False deactivates the container's GameObject: its chunks aren't drawn and, with the render callbacks it drops, + /// nothing is flushed until it is shown again, when every edit made meanwhile is applied. + /// + internal bool IsVisible + { + get => gameObject.activeSelf; + set => gameObject.SetActive(value); + } + internal IReadOnlyList Chunks { get => _chunks != null ? _chunks.Chunks : Array.Empty(); diff --git a/UnityPackage/Runtime/Shapes/IShape.cs b/UnityPackage/Runtime/Shapes/IShape.cs index ccae745..88c116a 100644 --- a/UnityPackage/Runtime/Shapes/IShape.cs +++ b/UnityPackage/Runtime/Shapes/IShape.cs @@ -14,6 +14,12 @@ public interface IShape : IDisposable /// True once the shape or its container was disposed. bool IsDisposed { get; } + /// + /// False hides the shape without disposing it: it keeps its place, bones and every setting, and draws again as it + /// is then when shown. Hiding and showing allocate nothing. True by default. + /// + bool IsVisible { get; set; } + /// Sets the color of every vertex of the shape. void SetColor(Color color); } diff --git a/UnityPackage/Tests/Runtime/ReliabilityTests.cs b/UnityPackage/Tests/Runtime/ReliabilityTests.cs index f74a8fd..9c83aba 100644 --- a/UnityPackage/Tests/Runtime/ReliabilityTests.cs +++ b/UnityPackage/Tests/Runtime/ReliabilityTests.cs @@ -4,6 +4,8 @@ using NUnit.Framework; using UnityEngine; using UnityEngine.TestTools; +using UnityEngine.TestTools.Constraints; +using Is = UnityEngine.TestTools.Constraints.Is; namespace reromanlee.Wireframes.Tests { @@ -27,6 +29,10 @@ public void WithoutGraphics_ShapesWorkButNothingIsCreatedForTheGpu() ICircle circle = container.CreateCircle(bone, Vector3.zero, 1f); circle.Radius = 2f; + circle.IsVisible = false; + circle.IsVisible = true; + container.IsVisible = false; + container.IsVisible = true; container.CreateLine(Vector3.zero, Vector3.one).Dispose(); container.Proxy.Flush(); @@ -41,6 +47,43 @@ public void WithoutGraphics_ShapesWorkButNothingIsCreatedForTheGpu() Assert.That(circle.IsDisposed, Is.True); } + [Test] + public void SteadyFrames_AllocateNothing() + { + WireframeContainer container = CreateContainer(); + Transform[] bones = new Transform[20]; + for (int i = 0; i < bones.Length; i++) + { + bones[i] = CreateBone(new Vector3(i, 0f, 0f), Quaternion.identity); + } + ILine[] lines = new ILine[200]; + for (int i = 0; i < lines.Length; i++) + { + lines[i] = container.CreateLine(bones[i % bones.Length], bones[(i + 1) % bones.Length]); + } + ISphere sphere = container.CreateSphere(bones[0], Vector3.zero, 1f); + // Warms up every path, so buffers and queues already have their size. + Frame(); + Frame(); + + Assert.That(Frame, Is.Not.AllocatingGCMemory()); + + // What a typical frame does: bones move, a few shapes change, and the container flushes before rendering. + void Frame() + { + for (int i = 0; i < bones.Length; i++) + { + bones[i].position += Vector3.up * 0.01f; + } + for (int i = 0; i < lines.Length; i += 7) + { + lines[i].ColorA = Color.red; + } + sphere.Radius += 0.01f; + container.Proxy.Flush(); + } + } + [Test] public void EdgePatterns_AreSharedAndDroppedWithTheLastShapeUsingThem() { diff --git a/UnityPackage/Tests/Runtime/ShaderTests.cs b/UnityPackage/Tests/Runtime/ShaderTests.cs index 6f099da..1ede6e7 100644 --- a/UnityPackage/Tests/Runtime/ShaderTests.cs +++ b/UnityPackage/Tests/Runtime/ShaderTests.cs @@ -131,6 +131,47 @@ public void ShapesInALaterChunk_FollowTheirBonesWhileTheBoneTextureGrows() Assert.That(IsLit(new Vector3(0f, 2f, 0f)), Is.False); } + [Test] + public void HiddenShape_IsNotDrawnUntilShown() + { + WireframeContainer container = CreateContainer(); + ILine line = container.CreateLine(new Vector3(-2f, 0f, 0f), new Vector3(2f, 0f, 0f)); + line.SetColor(Color.red); + + line.IsVisible = false; + Render(); + Assert.That(IsLit(Vector3.zero), Is.False); + + line.IsVisible = true; + Render(); + Assert.That(IsLit(Vector3.zero), Is.True); + } + + [Test] + public void HiddenContainer_DrawsNothingAndAppliesItsEditsWhenShown() + { + WireframeContainer container = CreateContainer(); + ILine line = container.CreateLine(new Vector3(-2f, 2f, 0f), new Vector3(2f, 2f, 0f)); + line.SetColor(Color.red); + Render(); + Assert.That(IsLit(new Vector3(0f, 2f, 0f)), Is.True); + + container.IsVisible = false; + line.WorldPositionA = new Vector3(-2f, -2f, 0f); + line.WorldPositionB = new Vector3(2f, -2f, 0f); + Render(); + + Assert.That(IsLit(new Vector3(0f, 2f, 0f)), Is.False); + Assert.That(IsLit(new Vector3(0f, -2f, 0f)), Is.False); + Assert.That(ChunkOf(line).PendingCount, Is.EqualTo(1), "A hidden container flushed before a render."); + + container.IsVisible = true; + Render(); + + Assert.That(container.IsVisible, Is.True); + Assert.That(IsLit(new Vector3(0f, -2f, 0f)), Is.True); + } + [Test] public void Dispose_StopsDrawingRightAway() { diff --git a/UnityPackage/Tests/Runtime/VisibilityTests.cs b/UnityPackage/Tests/Runtime/VisibilityTests.cs new file mode 100644 index 0000000..a052dab --- /dev/null +++ b/UnityPackage/Tests/Runtime/VisibilityTests.cs @@ -0,0 +1,250 @@ +using System; +using NUnit.Framework; +using UnityEngine; +using UnityEngine.TestTools.Constraints; +using Is = UnityEngine.TestTools.Constraints.Is; + +namespace reromanlee.Wireframes.Tests +{ + /// Hiding shapes and containers without disposing them, and shapes that change their vertex count. + public class VisibilityTests : WireframesTestBase + { + [Test] + public void HiddenShape_LeavesTheDrawnEdgesAndComesBackAsItIsThen() + { + WireframeContainer container = CreateContainer(); + ILine hidden = container.CreateLine(Vector3.zero, Vector3.right); + ILine kept = container.CreateLine(Vector3.up, Vector3.one); + container.Proxy.Flush(); + + hidden.IsVisible = false; + // Edited while hidden, drawn that way once shown. + hidden.WorldPositionB = new Vector3(0f, 0f, 5f); + container.Proxy.Flush(); + + Assert.That(hidden.IsVisible, Is.False); + Assert.That(ChunkOf(kept).Mesh.GetIndices(0), Has.Length.EqualTo(2)); + (Vector3 A, Vector3 B) keptEdge = BakeEdges(container, kept)[0]; + AssertApproximately(Vector3.up, keptEdge.A); + AssertApproximately(Vector3.one, keptEdge.B); + + hidden.IsVisible = true; + + Assert.That(hidden.IsVisible, Is.True); + (Vector3 A, Vector3 B) shownEdge = BakeEdges(container, hidden)[0]; + AssertApproximately(Vector3.zero, shownEdge.A); + AssertApproximately(new Vector3(0f, 0f, 5f), shownEdge.B); + Assert.That(ChunkOf(kept).Mesh.GetIndices(0), Has.Length.EqualTo(4)); + } + + [Test] + public void HidingAndShowing_AllocatesNothing() + { + WireframeContainer container = CreateContainer(); + ISphere sphere = container.CreateSphere(Vector3.zero, 1f); + // Warms up every path once. + sphere.IsVisible = false; + sphere.IsVisible = true; + container.Proxy.Flush(); + + Assert.That(() => + { + sphere.IsVisible = false; + container.Proxy.Flush(); + sphere.IsVisible = true; + container.Proxy.Flush(); + }, Is.Not.AllocatingGCMemory()); + } + + [Test] + public void HiddenShapes_SurviveCompactionAndDisposal() + { + WireframeContainer container = CreateContainer(); + ILine[] lines = new ILine[3000]; + for (int i = 0; i < lines.Length; i++) + { + lines[i] = container.CreateLine(new Vector3(i, 0f, 0f), new Vector3(i, 1f, 0f)); + } + container.Proxy.Flush(); + ILine hiddenKept = lines[2999]; + ILine hiddenDisposed = lines[2998]; + hiddenKept.IsVisible = false; + hiddenDisposed.IsVisible = false; + + // Freeing most of the chunk compacts it, which moves the hidden line's vertices. + for (int i = 0; i < 2998; i++) + { + lines[i].Dispose(); + } + hiddenDisposed.Dispose(); + container.Proxy.Flush(); + Assert.That(ChunkOf(hiddenKept).Allocator.FreeCount, Is.Zero, "The chunk wasn't compacted."); + Assert.That(ChunkOf(hiddenKept).Mesh.GetIndices(0), Is.Empty); + + hiddenKept.IsVisible = true; + + (Vector3 A, Vector3 B) edge = BakeEdges(container, hiddenKept)[0]; + AssertApproximately(new Vector3(2999f, 0f, 0f), edge.A); + AssertApproximately(new Vector3(2999f, 1f, 0f), edge.B); + } + + [Test] + public void Visibility_ThrowsOnceDisposed() + { + WireframeContainer container = CreateContainer(); + ILine line = container.CreateLine(); + line.Dispose(); + + Assert.Throws(() => _ = line.IsVisible); + Assert.Throws(() => line.IsVisible = false); + container.Dispose(); + Assert.Throws(() => _ = container.IsVisible); + Assert.Throws(() => container.IsVisible = false); + } + + [Test] + public void ResizedShape_KeepsItsIdentityAndItsNeighbors() + { + WireframeContainer container = CreateContainer(); + ILine before = container.CreateLine(Vector3.zero, Vector3.one); + ChainShape chain = container.Proxy.Add(new ChainShape(Points(2, 0f))); + ILine after = container.CreateLine(Vector3.one, Vector3.one * 2f); + container.Proxy.Flush(); + + chain.SetPoints(Points(10, 5f)); + AssertChain(container, chain, 10, 5f); + chain.SetPoints(Points(3, 7f)); + AssertChain(container, chain, 3, 7f); + + AssertApproximately(Vector3.one, BakeEdges(container, before)[0].B); + AssertApproximately(Vector3.one * 2f, BakeEdges(container, after)[0].B); + Assert.That(ShapeCountOf(container), Is.EqualTo(3)); + } + + [Test] + public void ResizedShape_MovesToAChunkWithRoomAndBack() + { + WireframeContainer container = CreateContainer(); + ChainShape chain = container.Proxy.Add(new ChainShape(Points(4, 0f))); + MeshChunk first = ChunkOf(chain); + + chain.SetPoints(Points(MeshChunk.MaxVertexCount + 100, 1f)); + Assert.That(ChunkOf(chain).IsLarge, Is.True); + AssertChain(container, chain, MeshChunk.MaxVertexCount + 100, 1f); + + chain.SetPoints(Points(4, 2f)); + Assert.That(ChunkOf(chain), Is.SameAs(first)); + AssertChain(container, chain, 4, 2f); + } + + [Test] + public void ResizingWithoutFlushes_KeepsTheQueuesFromGrowing() + { + WireframeContainer container = CreateContainer(); + ChainShape chain = container.Proxy.Add(new ChainShape(Points(4, 0f))); + MeshChunk first = ChunkOf(chain); + int capacity = first.PendingCapacity; + Vector3[] large = Points(MeshChunk.MaxVertexCount + 10, 1f); + Vector3[] small = Points(4, 2f); + + // Back and forth between the chunk and a large one, with no render to empty the queues. + for (int i = 0; i < 200; i++) + { + chain.SetPoints(i % 2 == 0 ? large : small); + } + + Assert.That(first.PendingCapacity, Is.EqualTo(capacity)); + Assert.That(container.Proxy.Chunks, Has.Count.EqualTo(2)); + AssertChain(container, chain, 4, 2f); + } + + [Test] + public void ResizedHiddenShape_StaysHiddenUntilShown() + { + WireframeContainer container = CreateContainer(); + ChainShape chain = container.Proxy.Add(new ChainShape(Points(3, 0f))); + chain.IsVisible = false; + + chain.SetPoints(Points(6, 1f)); + container.Proxy.Flush(); + Assert.That(ChunkOf(chain).Mesh.GetIndices(0), Is.Empty); + + chain.IsVisible = true; + AssertChain(container, chain, 6, 1f); + } + + private static Vector3[] Points(int count, float height) + { + Vector3[] points = new Vector3[count]; + for (int i = 0; i < count; i++) + { + points[i] = new Vector3(i, height, 0f); + } + return points; + } + + private static void AssertChain(WireframeContainer container, ChainShape chain, int count, float height) + { + (Vector3 A, Vector3 B)[] edges = BakeEdges(container, chain); + Assert.That(edges, Has.Length.EqualTo(count - 1)); + AssertApproximately(new Vector3(0f, height, 0f), edges[0].A); + AssertApproximately(new Vector3(count - 1, height, 0f), edges[count - 2].B); + } + + /// World-space points joined in a chain, whose number of points can change like a text's glyphs. + private sealed class ChainShape : Shape + { + private Vector3[] _points; + + internal ChainShape(Vector3[] points) : base(points.Length, ChainEdges(points.Length)) + { + _points = points; + } + + internal void SetPoints(Vector3[] points) + { + EnsureUsable(); + _points = points; + Resize(points.Length, ChainEdges(points.Length)); + } + + public override void SetColor(Color color) + { + } + + internal override void WritePositions(Span positions) + { + _points.AsSpan().CopyTo(positions); + } + + internal override void WriteColors(Span colors) + { + colors.Fill(new Color32(255, 255, 255, 255)); + } + + internal override void WriteBoneIndices(Span boneIndices) + { + boneIndices.Fill(BoneRegistry.WorldSlot); + } + + protected override void AcquireBones(BoneRegistry bones) + { + } + + protected override void ReleaseBones(BoneRegistry bones) + { + } + + private static int[] ChainEdges(int count) + { + int[] pattern = new int[(count - 1) * 2]; + for (int edge = 0; edge < count - 1; edge++) + { + pattern[edge * 2] = edge; + pattern[edge * 2 + 1] = edge + 1; + } + return pattern; + } + } + } +} diff --git a/UnityPackage/Tests/Runtime/VisibilityTests.cs.meta b/UnityPackage/Tests/Runtime/VisibilityTests.cs.meta new file mode 100644 index 0000000..744ce0b --- /dev/null +++ b/UnityPackage/Tests/Runtime/VisibilityTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 76d9986fc19b9fbabe30bcd2ad22c111 \ No newline at end of file From 701f040f51c7a940922fff870fc57a808b38e783 Mon Sep 17 00:00:00 2001 From: Roman Likhadievski Date: Tue, 29 Sep 2026 21:45:25 +0300 Subject: [PATCH 09/13] feature: profiling --- .../Runtime/Container/WireframeContainer.cs | 11 + .../Runtime/Container/WireframeStatistics.cs | 62 ++++++ .../Container/WireframeStatistics.cs.meta | 2 + .../Runtime/Core/WireframesCounters.cs | 71 +++++++ .../Runtime/Core/WireframesCounters.cs.meta | 2 + .../Runtime/Core/WireframesMarkers.cs | 38 ++++ .../Runtime/Core/WireframesMarkers.cs.meta | 2 + .../Runtime/Rendering/BoneRegistry.cs | 41 ++-- UnityPackage/Runtime/Rendering/BoneTexture.cs | 33 ++- .../Runtime/Rendering/ChunkAllocator.cs | 20 +- .../Runtime/Rendering/HeadlessHost.cs | 27 +++ UnityPackage/Runtime/Rendering/MeshChunk.cs | 134 ++++++++++--- UnityPackage/Runtime/Rendering/MeshProxy.cs | 48 ++++- .../Runtime/reromanlee.Wireframes.asmdef | 12 +- UnityPackage/Tests/Runtime/ProfilingTests.cs | 189 ++++++++++++++++++ .../Tests/Runtime/ProfilingTests.cs.meta | 2 + ...reromanlee.Wireframes.Tests.Runtime.asmdef | 8 +- 17 files changed, 633 insertions(+), 69 deletions(-) create mode 100644 UnityPackage/Runtime/Container/WireframeStatistics.cs create mode 100644 UnityPackage/Runtime/Container/WireframeStatistics.cs.meta create mode 100644 UnityPackage/Runtime/Core/WireframesCounters.cs create mode 100644 UnityPackage/Runtime/Core/WireframesCounters.cs.meta create mode 100644 UnityPackage/Runtime/Core/WireframesMarkers.cs create mode 100644 UnityPackage/Runtime/Core/WireframesMarkers.cs.meta create mode 100644 UnityPackage/Tests/Runtime/ProfilingTests.cs create mode 100644 UnityPackage/Tests/Runtime/ProfilingTests.cs.meta diff --git a/UnityPackage/Runtime/Container/WireframeContainer.cs b/UnityPackage/Runtime/Container/WireframeContainer.cs index 36dc1bb..a9afe1e 100644 --- a/UnityPackage/Runtime/Container/WireframeContainer.cs +++ b/UnityPackage/Runtime/Container/WireframeContainer.cs @@ -68,6 +68,17 @@ public bool IsDisposed get => _isDisposed; } + /// What the container holds and the memory it uses right now. Reading it allocates nothing. + /// The container is disposed. + public WireframeStatistics Statistics + { + get + { + MainThread.Check(); + return Proxy.GetStatistics(); + } + } + /// /// False hides every shape of the container without disposing anything, and costs nothing while it lasts: edits /// made meanwhile are applied when it is shown again. Each shape's own still diff --git a/UnityPackage/Runtime/Container/WireframeStatistics.cs b/UnityPackage/Runtime/Container/WireframeStatistics.cs new file mode 100644 index 0000000..1b3b2e4 --- /dev/null +++ b/UnityPackage/Runtime/Container/WireframeStatistics.cs @@ -0,0 +1,62 @@ +namespace reromanlee.Wireframes +{ + /// + /// What a container holds and the memory it uses, read from . Reading it + /// walks the container's chunks and allocates nothing. + /// + public readonly struct WireframeStatistics + { + internal WireframeStatistics( + int shapeCount, + int hiddenShapeCount, + int vertexCount, + int edgeCount, + int boneCount, + int chunkCount, + long cpuMemory, + long gpuMemory) + { + ShapeCount = shapeCount; + HiddenShapeCount = hiddenShapeCount; + VertexCount = vertexCount; + EdgeCount = edgeCount; + BoneCount = boneCount; + ChunkCount = chunkCount; + CpuMemory = cpuMemory; + GpuMemory = gpuMemory; + } + + /// Shapes in the container, hidden ones included. + public int ShapeCount { get; } + + /// Shapes hidden with . + public int HiddenShapeCount { get; } + + /// Vertices the shapes use. + public int VertexCount { get; } + + /// Edges drawn, which leaves out those of hidden shapes. + public int EdgeCount { get; } + + /// Distinct Transforms the shapes follow. + public int BoneCount { get; } + + /// + /// Meshes the shapes are split into, of up to 65,535 vertices each. Every chunk is one draw call per camera, or + /// two with . + /// + public int ChunkCount { get; } + + /// Bytes of CPU memory in the container's buffers, about; the shapes' own objects aren't counted. + public long CpuMemory { get; } + + /// Bytes of GPU memory in the container's vertex, index and bone buffers. + public long GpuMemory { get; } + + public override string ToString() + { + return $"{ShapeCount} shapes ({HiddenShapeCount} hidden), {VertexCount} vertices, {EdgeCount} edges, " + + $"{BoneCount} bones, {ChunkCount} chunks, {CpuMemory / 1024} KB CPU, {GpuMemory / 1024} KB GPU"; + } + } +} diff --git a/UnityPackage/Runtime/Container/WireframeStatistics.cs.meta b/UnityPackage/Runtime/Container/WireframeStatistics.cs.meta new file mode 100644 index 0000000..79dbf48 --- /dev/null +++ b/UnityPackage/Runtime/Container/WireframeStatistics.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 5441ba86ba206f9725d06dab077e64ef \ No newline at end of file diff --git a/UnityPackage/Runtime/Core/WireframesCounters.cs b/UnityPackage/Runtime/Core/WireframesCounters.cs new file mode 100644 index 0000000..a368d53 --- /dev/null +++ b/UnityPackage/Runtime/Core/WireframesCounters.cs @@ -0,0 +1,71 @@ +using System.Diagnostics; +#if WIREFRAMES_PROFILING_CORE +using System.Collections.Generic; +using Unity.Profiling; +using UnityEngine; +#endif + +namespace reromanlee.Wireframes +{ + /// + /// Profiler counters with the totals of every container, to add to a module in the Profiler's Module Editor. They + /// exist only in projects with the com.unity.profiling.core package, and count only in the Editor and development + /// builds. + /// + internal static class WireframesCounters + { +#if WIREFRAMES_PROFILING_CORE + private static readonly ProfilerCounterValue Shapes = + new(ProfilerCategory.Render, "Wireframes Shapes", ProfilerMarkerDataUnit.Count); + private static readonly ProfilerCounterValue Vertices = + new(ProfilerCategory.Render, "Wireframes Vertices", ProfilerMarkerDataUnit.Count); + private static readonly ProfilerCounterValue Edges = + new(ProfilerCategory.Render, "Wireframes Edges", ProfilerMarkerDataUnit.Count); + private static readonly ProfilerCounterValue Bones = + new(ProfilerCategory.Render, "Wireframes Bones", ProfilerMarkerDataUnit.Count); + private static readonly ProfilerCounterValue Chunks = + new(ProfilerCategory.Render, "Wireframes Chunks", ProfilerMarkerDataUnit.Count); + private static readonly ProfilerCounterValue GpuMemory = + new(ProfilerCategory.Render, "Wireframes GPU Memory", ProfilerMarkerDataUnit.Bytes); + + private static int _lastFrame = -1; +#endif + + /// Sums every container into the counters, at most once per frame. + [Conditional("WIREFRAMES_PROFILING_CORE")] + internal static void Update() + { +#if WIREFRAMES_PROFILING_CORE && ENABLE_PROFILER + int frame = Time.frameCount; + if (frame == _lastFrame) + { + return; + } + _lastFrame = frame; + int shapes = 0; + int vertices = 0; + int edges = 0; + int bones = 0; + int chunks = 0; + long gpuMemory = 0; + IReadOnlyList proxies = MeshProxy.LiveProxies; + for (int i = 0; i < proxies.Count; i++) + { + WireframeStatistics statistics = proxies[i].GetStatistics(); + shapes += statistics.ShapeCount; + vertices += statistics.VertexCount; + edges += statistics.EdgeCount; + bones += statistics.BoneCount; + chunks += statistics.ChunkCount; + gpuMemory += statistics.GpuMemory; + } + Shapes.Value = shapes; + Vertices.Value = vertices; + Edges.Value = edges; + Bones.Value = bones; + Chunks.Value = chunks; + GpuMemory.Value = gpuMemory; +#endif + } + } +} diff --git a/UnityPackage/Runtime/Core/WireframesCounters.cs.meta b/UnityPackage/Runtime/Core/WireframesCounters.cs.meta new file mode 100644 index 0000000..aaf9d15 --- /dev/null +++ b/UnityPackage/Runtime/Core/WireframesCounters.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: fddde7a2e2572f6a72d8574c77343fdb \ No newline at end of file diff --git a/UnityPackage/Runtime/Core/WireframesMarkers.cs b/UnityPackage/Runtime/Core/WireframesMarkers.cs new file mode 100644 index 0000000..b7da74e --- /dev/null +++ b/UnityPackage/Runtime/Core/WireframesMarkers.cs @@ -0,0 +1,38 @@ +using Unity.Profiling; + +namespace reromanlee.Wireframes +{ + /// + /// The package's Profiler markers, all named Wireframes.* in the Render category, so a capture shows where its time + /// goes. Outside the Editor and development builds they cost next to nothing. + /// + internal static class WireframesMarkers + { + /// Everything a container does right before a render. + internal static readonly ProfilerMarker Flush = new(ProfilerCategory.Render, "Wireframes.Flush"); + + /// Reading the local-to-world matrix of every bone in use. + internal static readonly ProfilerMarker ReadBones = new(ProfilerCategory.Render, "Wireframes.ReadBones"); + + /// Writing the bone matrices into the bone texture and uploading it. + internal static readonly ProfilerMarker UploadBones = new(ProfilerCategory.Render, "Wireframes.UploadBones"); + + /// Writing the queued shapes into the CPU copies of the buffers. + internal static readonly ProfilerMarker WriteShapes = new(ProfilerCategory.Render, "Wireframes.WriteShapes"); + + /// Uploading the changed ranges of the vertex and index buffers. + internal static readonly ProfilerMarker UploadMesh = new(ProfilerCategory.Render, "Wireframes.UploadMesh"); + + /// Packing a chunk's live shapes together, which rewrites all of them. + internal static readonly ProfilerMarker Compact = new(ProfilerCategory.Render, "Wireframes.Compact"); + + /// Growing or shrinking buffers, which copies them and recreates the GPU ones. + internal static readonly ProfilerMarker ResizeBuffers = new(ProfilerCategory.Render, "Wireframes.ResizeBuffers"); + + /// Creating a chunk's mesh and GameObject. + internal static readonly ProfilerMarker CreateChunk = new(ProfilerCategory.Render, "Wireframes.CreateChunk"); + + /// Releasing chunks that stayed empty. + internal static readonly ProfilerMarker ReleaseChunks = new(ProfilerCategory.Render, "Wireframes.ReleaseChunks"); + } +} diff --git a/UnityPackage/Runtime/Core/WireframesMarkers.cs.meta b/UnityPackage/Runtime/Core/WireframesMarkers.cs.meta new file mode 100644 index 0000000..30a2d92 --- /dev/null +++ b/UnityPackage/Runtime/Core/WireframesMarkers.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 7587e672592fb930528687eecd67652d \ No newline at end of file diff --git a/UnityPackage/Runtime/Rendering/BoneRegistry.cs b/UnityPackage/Runtime/Rendering/BoneRegistry.cs index bb65794..3649df3 100644 --- a/UnityPackage/Runtime/Rendering/BoneRegistry.cs +++ b/UnityPackage/Runtime/Rendering/BoneRegistry.cs @@ -47,6 +47,18 @@ internal int Count get => _slots.Count; } + /// Bytes of CPU memory the registry takes, about. + internal long CpuMemory + { + get + { + // Per slot: its transform, matrix, reference count and two live-list positions; per bone, a map entry. + long slotSize = IntPtr.Size + 16 * sizeof(float) + 3 * sizeof(int); + long entrySize = IntPtr.Size + 3 * sizeof(int); + return _transforms.Length * slotSize + (long)_slots.Count * entrySize; + } + } + /// Each slot's latest matrix, sized to the capacity. internal Matrix4x4[] Matrices { @@ -113,21 +125,24 @@ internal void Release(int slot) /// internal void ReadMatrices() { - // Backwards, so a slot that stops being read swaps in one that was already read. - for (int i = _liveCount - 1; i >= 0; i--) + using (WireframesMarkers.ReadBones.Auto()) { - int slot = _liveSlots[i]; - Transform bone = _transforms[slot]; - if (bone == null) - { - StopReading(slot); - continue; - } - Matrix4x4 matrix = bone.localToWorldMatrix; - if (!matrix.Equals(_matrices[slot])) + // Backwards, so a slot that stops being read swaps in one that was already read. + for (int i = _liveCount - 1; i >= 0; i--) { - _matrices[slot] = matrix; - HasChanges = true; + int slot = _liveSlots[i]; + Transform bone = _transforms[slot]; + if (bone == null) + { + StopReading(slot); + continue; + } + Matrix4x4 matrix = bone.localToWorldMatrix; + if (!matrix.Equals(_matrices[slot])) + { + _matrices[slot] = matrix; + HasChanges = true; + } } } } diff --git a/UnityPackage/Runtime/Rendering/BoneTexture.cs b/UnityPackage/Runtime/Rendering/BoneTexture.cs index afad45a..da7da23 100644 --- a/UnityPackage/Runtime/Rendering/BoneTexture.cs +++ b/UnityPackage/Runtime/Rendering/BoneTexture.cs @@ -13,6 +13,8 @@ internal sealed class BoneTexture : IDisposable { internal const int BonesPerRow = 256; private const int TexelsPerBone = 3; + // Four 32-bit floats. + private const int TexelSize = 16; internal static readonly int PropertyId = Shader.PropertyToID("_WireframesBones"); @@ -23,6 +25,14 @@ internal Texture2D Texture get => _texture; } + /// + /// Bytes the texture takes, on the GPU and again on the CPU, where Unity keeps a copy that each upload writes into. + /// + internal long Memory + { + get => _texture != null ? (long)_texture.width * _texture.height * TexelSize : 0; + } + /// /// Writes every slot's matrix and uploads the texture when a matrix changed or the texture had to grow. Returns /// true when a new texture was created, which the renderers then have to be given. @@ -34,18 +44,21 @@ internal bool Upload(BoneRegistry bones) { return false; } - NativeArray texels = _texture.GetPixelData(0); - Matrix4x4[] matrices = bones.Matrices; - int end = bones.End; - for (int slot = 0; slot < end; slot++) + using (WireframesMarkers.UploadBones.Auto()) { - Matrix4x4 matrix = matrices[slot]; - int texel = slot / BonesPerRow * BonesPerRow * TexelsPerBone + slot % BonesPerRow * TexelsPerBone; - texels[texel] = new Vector4(matrix.m00, matrix.m01, matrix.m02, matrix.m03); - texels[texel + 1] = new Vector4(matrix.m10, matrix.m11, matrix.m12, matrix.m13); - texels[texel + 2] = new Vector4(matrix.m20, matrix.m21, matrix.m22, matrix.m23); + NativeArray texels = _texture.GetPixelData(0); + Matrix4x4[] matrices = bones.Matrices; + int end = bones.End; + for (int slot = 0; slot < end; slot++) + { + Matrix4x4 matrix = matrices[slot]; + int texel = slot / BonesPerRow * BonesPerRow * TexelsPerBone + slot % BonesPerRow * TexelsPerBone; + texels[texel] = new Vector4(matrix.m00, matrix.m01, matrix.m02, matrix.m03); + texels[texel + 1] = new Vector4(matrix.m10, matrix.m11, matrix.m12, matrix.m13); + texels[texel + 2] = new Vector4(matrix.m20, matrix.m21, matrix.m22, matrix.m23); + } + _texture.Apply(false, false); } - _texture.Apply(false, false); bones.ClearChanges(); return created; } diff --git a/UnityPackage/Runtime/Rendering/ChunkAllocator.cs b/UnityPackage/Runtime/Rendering/ChunkAllocator.cs index 55c85b6..75a4628 100644 --- a/UnityPackage/Runtime/Rendering/ChunkAllocator.cs +++ b/UnityPackage/Runtime/Rendering/ChunkAllocator.cs @@ -139,9 +139,12 @@ internal void ReleaseIdleChunks(float time) } if (time - chunk.EmptySince >= ReleaseDelay) { - _chunks.RemoveAt(i); - chunk.Dispose(); - UnityObjects.Destroy(chunk.Renderer.gameObject); + using (WireframesMarkers.ReleaseChunks.Auto()) + { + _chunks.RemoveAt(i); + chunk.Dispose(); + UnityObjects.Destroy(chunk.Renderer.gameObject); + } } } } @@ -188,10 +191,13 @@ private MeshChunk FindChunk(int vertexCount) private MeshChunk CreateChunk(int vertexCapacity, int edgeCapacity) { - MeshChunk chunk = new(this, vertexCapacity, edgeCapacity); - chunk.Renderer.SetPropertyBlock(_propertyBlock); - _chunks.Add(chunk); - return chunk; + using (WireframesMarkers.CreateChunk.Auto()) + { + MeshChunk chunk = new(this, vertexCapacity, edgeCapacity); + chunk.Renderer.SetPropertyBlock(_propertyBlock); + _chunks.Add(chunk); + return chunk; + } } private void CompactOneChunk() diff --git a/UnityPackage/Runtime/Rendering/HeadlessHost.cs b/UnityPackage/Runtime/Rendering/HeadlessHost.cs index 296be1b..0571598 100644 --- a/UnityPackage/Runtime/Rendering/HeadlessHost.cs +++ b/UnityPackage/Runtime/Rendering/HeadlessHost.cs @@ -12,6 +12,8 @@ internal sealed class HeadlessHost : IShapeHost, IDisposable private Shape[] _shapes = new Shape[InitialCapacity]; private int _shapeCount; + private int _hiddenShapeCount; + private int _vertexCount; internal HeadlessHost(BoneRegistry bones) { @@ -25,6 +27,17 @@ internal int ShapeCount get => _shapeCount; } + internal int HiddenShapeCount + { + get => _hiddenShapeCount; + } + + /// Vertices the shapes would use if they were drawn. + internal int VertexCount + { + get => _vertexCount; + } + internal void Add(Shape shape) { if (_shapeCount == _shapes.Length) @@ -33,6 +46,11 @@ internal void Add(Shape shape) } shape.ShapeIndex = _shapeCount; _shapes[_shapeCount++] = shape; + _vertexCount += shape.VertexCount; + if (shape.IsHidden) + { + _hiddenShapeCount++; + } } public void Enqueue(Shape shape) @@ -47,14 +65,21 @@ public void Remove(Shape shape) _shapes[shape.ShapeIndex] = moved; moved.ShapeIndex = shape.ShapeIndex; _shapes[last] = null; + _vertexCount -= shape.VertexCount; + if (shape.IsHidden) + { + _hiddenShapeCount--; + } } public void Show(Shape shape) { + _hiddenShapeCount--; } public void Hide(Shape shape) { + _hiddenShapeCount++; } public IShapeHost Reattach(Shape shape) @@ -71,6 +96,8 @@ public void Dispose() _shapes[i] = null; } _shapeCount = 0; + _hiddenShapeCount = 0; + _vertexCount = 0; } } } diff --git a/UnityPackage/Runtime/Rendering/MeshChunk.cs b/UnityPackage/Runtime/Rendering/MeshChunk.cs index 2d88353..8a388df 100644 --- a/UnityPackage/Runtime/Rendering/MeshChunk.cs +++ b/UnityPackage/Runtime/Rendering/MeshChunk.cs @@ -30,6 +30,8 @@ internal sealed class MeshChunk : IShapeHost, IDisposable // Freed vertex blocks are packed away once they fill half the buffer, but never in small meshes. private const int CompactionThreshold = 1024; private const int PositionSize = 12; + // Position, color and bone slot. + private const int VertexSize = PositionSize + 4 + 4; private const int PositionStream = 0; private const int ColorStream = 1; @@ -73,6 +75,7 @@ internal sealed class MeshChunk : IShapeHost, IDisposable private Shape[] _shapes = new Shape[InitialShapeCapacity]; private int _shapeCount; + private int _hiddenShapeCount; private Shape[] _pending = new Shape[InitialShapeCapacity]; private int _pendingCount; @@ -162,6 +165,36 @@ internal int ShapeCount get => _shapeCount; } + internal int HiddenShapeCount + { + get => _hiddenShapeCount; + } + + /// Vertices that live shapes use, leaving out freed blocks that aren't reused yet. + internal int UsedVertexCount + { + get => _allocator.End - _allocator.FreeCount; + } + + /// Bytes of CPU memory in the chunk's buffers and lists, about. + internal long CpuMemory + { + get + { + // An edge slot holds two indices, its owner and the owner's edge number, then its 16-bit copy. + long edgeSize = 3 * sizeof(int) + IntPtr.Size + 2 * sizeof(ushort); + return (long)_positions.Length * VertexSize + + _edges.Capacity * edgeSize + + (long)(_shapes.Length + _pending.Length) * IntPtr.Size; + } + } + + /// Bytes of GPU memory in the chunk's vertex and index buffers. + internal long GpuMemory + { + get => (long)_meshVertexCapacity * VertexSize + (long)_meshIndexCapacity * IndexSize; + } + /// Entries in the queue of shapes to write on the next flush, including those emptied by removals. internal int PendingCount { @@ -239,7 +272,11 @@ internal void Add(Shape shape) } shape.VertexStart = _allocator.Allocate(shape.VertexCount); - if (!shape.IsHidden) + if (shape.IsHidden) + { + _hiddenShapeCount++; + } + else { AddEdges(shape); } @@ -254,7 +291,11 @@ internal void Add(Shape shape) public void Remove(Shape shape) { - if (!shape.IsHidden) + if (shape.IsHidden) + { + _hiddenShapeCount--; + } + else { RemoveEdges(shape); } @@ -278,12 +319,14 @@ public void Show(Shape shape) { EnsureEdgeCapacity(_edges.Count + shape.EdgeCount); AddEdges(shape); + _hiddenShapeCount--; shape.MarkDirty(DirtyFlags.Edges); } public void Hide(Shape shape) { RemoveEdges(shape); + _hiddenShapeCount++; } public IShapeHost Reattach(Shape shape) @@ -310,9 +353,15 @@ public void Enqueue(Shape shape) /// Writes queued shapes into the buffers and uploads what changed. internal void Flush() { - WritePendingShapes(); - UploadVertices(); - UploadIndices(); + using (WireframesMarkers.WriteShapes.Auto()) + { + WritePendingShapes(); + } + using (WireframesMarkers.UploadMesh.Auto()) + { + UploadVertices(); + UploadIndices(); + } } /// @@ -321,20 +370,30 @@ internal void Flush() /// internal void Compact() { - _allocator.Reset(); - for (int i = 0; i < _shapeCount; i++) + using (WireframesMarkers.Compact.Auto()) { - Shape shape = _shapes[i]; - shape.VertexStart = _allocator.Allocate(shape.VertexCount); - shape.MarkDirty(DirtyFlags.All); - } + _allocator.Reset(); + for (int i = 0; i < _shapeCount; i++) + { + Shape shape = _shapes[i]; + shape.VertexStart = _allocator.Allocate(shape.VertexCount); + shape.MarkDirty(DirtyFlags.All); + } - int vertexCapacity = ShrunkCapacity(_positions.Length, _allocator.End, _minimumVertexCapacity); - if (vertexCapacity < _positions.Length) - { - ResizeVertexStreams(vertexCapacity); + int vertexCapacity = ShrunkCapacity(_positions.Length, _allocator.End, _minimumVertexCapacity); + if (vertexCapacity < _positions.Length) + { + ResizeVertexStreams(vertexCapacity); + } + int edgeCapacity = ShrunkCapacity(_edges.Capacity, _edges.Count, _minimumEdgeCapacity); + if (edgeCapacity < _edges.Capacity) + { + using (WireframesMarkers.ResizeBuffers.Auto()) + { + _edges.Shrink(edgeCapacity); + } + } } - _edges.Shrink(ShrunkCapacity(_edges.Capacity, _edges.Count, _minimumEdgeCapacity)); } public void Dispose() @@ -345,6 +404,7 @@ public void Dispose() _shapes[i] = null; } _shapeCount = 0; + _hiddenShapeCount = 0; Array.Clear(_pending, 0, _pendingCount); _pendingCount = 0; // Play Mode destroys objects at the end of the frame, and a render before that must not draw the chunk. @@ -488,10 +548,13 @@ private void UploadVertices() } // A resized buffer starts with garbage, so every stream is rewritten in full. - _mesh.SetVertexBufferParams(capacity, VertexLayout); - _mesh.SetVertexBufferData(_positions, 0, 0, capacity, PositionStream, UploadFlags); - _mesh.SetVertexBufferData(_colors, 0, 0, capacity, ColorStream, UploadFlags); - _mesh.SetVertexBufferData(_boneIndices, 0, 0, capacity, BoneStream, UploadFlags); + using (WireframesMarkers.ResizeBuffers.Auto()) + { + _mesh.SetVertexBufferParams(capacity, VertexLayout); + _mesh.SetVertexBufferData(_positions, 0, 0, capacity, PositionStream, UploadFlags); + _mesh.SetVertexBufferData(_colors, 0, 0, capacity, ColorStream, UploadFlags); + _mesh.SetVertexBufferData(_boneIndices, 0, 0, capacity, BoneStream, UploadFlags); + } _positionRanges.Clear(); _colorRanges.Clear(); _boneRanges.Clear(); @@ -519,12 +582,15 @@ private void UploadIndices() if (_meshIndexCapacity != indexCapacity || _meshIndexFormat != format) { - _mesh.SetIndexBufferParams(indexCapacity, format); - _meshIndexCapacity = indexCapacity; - _meshIndexFormat = format; - _edgeRanges.Clear(); - UploadIndexRange(0, edgeCount * 2); - _meshEdgeCount = -1; + using (WireframesMarkers.ResizeBuffers.Auto()) + { + _mesh.SetIndexBufferParams(indexCapacity, format); + _meshIndexCapacity = indexCapacity; + _meshIndexFormat = format; + _edgeRanges.Clear(); + UploadIndexRange(0, edgeCount * 2); + _meshEdgeCount = -1; + } } else { @@ -597,10 +663,13 @@ private void EnsureVertexCapacity(int required) private void ResizeVertexStreams(int capacity) { - // New elements default to zero, which is bone slot 0 (world space), a valid index for the shader. - Array.Resize(ref _positions, capacity); - Array.Resize(ref _colors, capacity); - Array.Resize(ref _boneIndices, capacity); + using (WireframesMarkers.ResizeBuffers.Auto()) + { + // New elements default to zero, which is bone slot 0 (world space), a valid index for the shader. + Array.Resize(ref _positions, capacity); + Array.Resize(ref _colors, capacity); + Array.Resize(ref _boneIndices, capacity); + } } private void EnsureEdgeCapacity(int required) @@ -616,7 +685,10 @@ private void EnsureEdgeCapacity(int required) capacity *= 2; } CheckBufferSize((long)capacity * 2 * IndexSize); - _edges.EnsureCapacity(capacity); + using (WireframesMarkers.ResizeBuffers.Auto()) + { + _edges.EnsureCapacity(capacity); + } } /// diff --git a/UnityPackage/Runtime/Rendering/MeshProxy.cs b/UnityPackage/Runtime/Rendering/MeshProxy.cs index 3fa62a5..a1f04c3 100644 --- a/UnityPackage/Runtime/Rendering/MeshProxy.cs +++ b/UnityPackage/Runtime/Rendering/MeshProxy.cs @@ -150,6 +150,36 @@ internal void ReleaseIdleChunks(float time) } } + /// Counts what the container holds by walking its chunks, without allocating. + internal WireframeStatistics GetStatistics() + { + if (_headless != null) + { + return new WireframeStatistics( + _headless.ShapeCount, _headless.HiddenShapeCount, _headless.VertexCount, 0, _bones.Count, 0, + _bones.CpuMemory, 0); + } + int shapes = 0; + int hiddenShapes = 0; + int vertices = 0; + int edges = 0; + long cpuMemory = _bones.CpuMemory + _boneTexture.Memory; + long gpuMemory = _boneTexture.Memory; + IReadOnlyList chunks = _chunks.Chunks; + for (int i = 0; i < chunks.Count; i++) + { + MeshChunk chunk = chunks[i]; + shapes += chunk.ShapeCount; + hiddenShapes += chunk.HiddenShapeCount; + vertices += chunk.UsedVertexCount; + edges += chunk.EdgeCount; + cpuMemory += chunk.CpuMemory; + gpuMemory += chunk.GpuMemory; + } + return new WireframeStatistics( + shapes, hiddenShapes, vertices, edges, _bones.Count, chunks.Count, cpuMemory, gpuMemory); + } + /// Reads the bones, writes queued shapes and uploads what changed. internal void Flush() { @@ -157,12 +187,15 @@ internal void Flush() { return; } - _bones.ReadMatrices(); - if (_boneTexture.Upload(_bones)) + using (WireframesMarkers.Flush.Auto()) { - _chunks.SetBoneTexture(_boneTexture.Texture); + _bones.ReadMatrices(); + if (_boneTexture.Upload(_bones)) + { + _chunks.SetBoneTexture(_boneTexture.Texture); + } + _chunks.Flush(); } - _chunks.Flush(); } internal void Shutdown() @@ -264,12 +297,17 @@ private void OnPreCullCamera(Camera camera) private void FlushBeforeRendering() { - if (_isShutDown || _chunks == null) + if (_isShutDown) { return; } try { + WireframesCounters.Update(); + if (_chunks == null) + { + return; + } Flush(); // Play Mode only, where destroying waits for the end of the frame and is allowed in a render callback. // In Edit Mode, the editor's update loop releases them instead. diff --git a/UnityPackage/Runtime/reromanlee.Wireframes.asmdef b/UnityPackage/Runtime/reromanlee.Wireframes.asmdef index d516564..fac2a60 100644 --- a/UnityPackage/Runtime/reromanlee.Wireframes.asmdef +++ b/UnityPackage/Runtime/reromanlee.Wireframes.asmdef @@ -1,7 +1,9 @@ { "name": "reromanlee.Wireframes", "rootNamespace": "reromanlee.Wireframes", - "references": [], + "references": [ + "Unity.Profiling.Core" + ], "includePlatforms": [], "excludePlatforms": [], "allowUnsafeCode": false, @@ -9,6 +11,12 @@ "precompiledReferences": [], "autoReferenced": true, "defineConstraints": [], - "versionDefines": [], + "versionDefines": [ + { + "name": "com.unity.profiling.core", + "expression": "1.0.0", + "define": "WIREFRAMES_PROFILING_CORE" + } + ], "noEngineReferences": false } \ No newline at end of file diff --git a/UnityPackage/Tests/Runtime/ProfilingTests.cs b/UnityPackage/Tests/Runtime/ProfilingTests.cs new file mode 100644 index 0000000..94f0f46 --- /dev/null +++ b/UnityPackage/Tests/Runtime/ProfilingTests.cs @@ -0,0 +1,189 @@ +using System; +using System.Collections; +using NUnit.Framework; +using Unity.Profiling; +using UnityEngine; +using UnityEngine.Profiling; +using UnityEngine.TestTools; +using UnityEngine.TestTools.Constraints; +using Is = UnityEngine.TestTools.Constraints.Is; + +namespace reromanlee.Wireframes.Tests +{ + /// What a container reports about itself: its statistics, Profiler markers and Profiler counters. + public class ProfilingTests : WireframesTestBase + { + [Test] + public void Statistics_CountWhatTheContainerHolds() + { + WireframeContainer container = CreateContainer(); + Transform first = CreateBone(Vector3.zero, Quaternion.identity); + Transform second = CreateBone(Vector3.one, Quaternion.identity); + ICircle circle = container.CreateCircle(first, Vector3.zero, 1f, 16); + ILine line = container.CreateLine(first, second); + container.CreateLine(Vector3.zero, Vector3.one); + container.Proxy.Flush(); + + WireframeStatistics statistics = container.Statistics; + Assert.That(statistics.ShapeCount, Is.EqualTo(3)); + Assert.That(statistics.HiddenShapeCount, Is.Zero); + Assert.That(statistics.VertexCount, Is.EqualTo(20)); + Assert.That(statistics.EdgeCount, Is.EqualTo(18)); + Assert.That(statistics.BoneCount, Is.EqualTo(2)); + Assert.That(statistics.ChunkCount, Is.EqualTo(1)); + Assert.That(statistics.CpuMemory, Is.Positive); + Assert.That(statistics.GpuMemory, Is.Positive); + + circle.IsVisible = false; + line.Dispose(); + statistics = container.Statistics; + + Assert.That(statistics.ShapeCount, Is.EqualTo(2)); + Assert.That(statistics.HiddenShapeCount, Is.EqualTo(1)); + Assert.That(statistics.VertexCount, Is.EqualTo(18)); + Assert.That(statistics.EdgeCount, Is.EqualTo(1)); + Assert.That(statistics.BoneCount, Is.EqualTo(1)); + } + + [Test] + public void Statistics_AddUpEveryChunk() + { + WireframeContainer container = CreateContainer(); + int lineCount = MeshChunk.MaxVertexCount / 2 + 1; + for (int i = 0; i < lineCount; i++) + { + container.CreateLine(); + } + container.Proxy.Flush(); + + WireframeStatistics statistics = container.Statistics; + + Assert.That(statistics.ChunkCount, Is.EqualTo(2)); + Assert.That(statistics.ShapeCount, Is.EqualTo(lineCount)); + Assert.That(statistics.VertexCount, Is.EqualTo(lineCount * 2)); + Assert.That(statistics.EdgeCount, Is.EqualTo(lineCount)); + Assert.That(statistics.GpuMemory, Is.GreaterThan(ChunkOf(container).GpuMemory)); + } + + [Test] + public void Statistics_WithoutGraphics_CountShapesButNoGpuMemory() + { + WireframeContainer container; + MeshProxy.SimulateNoGraphics = true; + try + { + container = CreateContainer(); + } + finally + { + MeshProxy.SimulateNoGraphics = false; + } + Transform bone = CreateBone(Vector3.zero, Quaternion.identity); + container.CreateCircle(bone, Vector3.zero, 1f, 16).IsVisible = false; + container.CreateLine(); + + WireframeStatistics statistics = container.Statistics; + + Assert.That(statistics.ShapeCount, Is.EqualTo(2)); + Assert.That(statistics.HiddenShapeCount, Is.EqualTo(1)); + Assert.That(statistics.VertexCount, Is.EqualTo(18)); + Assert.That(statistics.EdgeCount, Is.Zero); + Assert.That(statistics.BoneCount, Is.EqualTo(1)); + Assert.That(statistics.ChunkCount, Is.Zero); + Assert.That(statistics.GpuMemory, Is.Zero); + } + + [Test] + public void ReadingStatistics_AllocatesNothing() + { + WireframeContainer container = CreateContainer(); + container.CreateSphere(Vector3.zero, 1f); + int shapeCount = 0; + Read(); + + Assert.That(Read, Is.Not.AllocatingGCMemory()); + Assert.That(shapeCount, Is.EqualTo(1)); + + void Read() + { + shapeCount = container.Statistics.ShapeCount; + } + } + + [Test] + public void StatisticsOfADisposedContainer_Throw() + { + WireframeContainer container = CreateContainer(); + container.Dispose(); + + Assert.Throws(() => _ = container.Statistics); + } + + [Test] + public void Flush_RecordsEachPhaseUnderItsProfilerMarker() + { + WireframeContainer container = CreateContainer(); + Transform bone = CreateBone(Vector3.zero, Quaternion.identity); + ILine line = container.CreateLine(bone, bone); + container.Proxy.Flush(); + string[] names = + { + "Wireframes.Flush", "Wireframes.ReadBones", "Wireframes.UploadBones", "Wireframes.WriteShapes", + "Wireframes.UploadMesh" + }; + Recorder[] recorders = Array.ConvertAll(names, Recorder.Get); + + // As the Test Framework counts allocations: on this thread only, which makes the counts readable right away. + foreach (Recorder recorder in recorders) + { + recorder.enabled = false; + recorder.FilterToCurrentThread(); + recorder.enabled = true; + } + bone.position = Vector3.one; + line.ColorA = Color.red; + container.Proxy.Flush(); + foreach (Recorder recorder in recorders) + { + recorder.enabled = false; + recorder.CollectFromAllThreads(); + } + + for (int i = 0; i < names.Length; i++) + { + Assert.That(recorders[i].sampleBlockCount, Is.EqualTo(1), names[i]); + } + } + +#if WIREFRAMES_PROFILING_CORE + [UnityTest] + public IEnumerator Counters_ReportTheTotalsOfEveryContainer() + { + // The counters are updated once per frame, so another test's render earlier in this frame would count. + yield return null; + WireframeContainer first = CreateContainer(); + WireframeContainer second = CreateContainer(); + first.CreateLine(); + second.CreateLine(); + second.CreateCircle(Vector3.zero, 1f, 16).IsVisible = false; + RenderTexture target = Track(new RenderTexture(4, 4, 0)); + Camera camera = Track(new GameObject("Counters Test Camera")).AddComponent(); + camera.enabled = false; + camera.targetTexture = target; + // Creates the counters, which the recorders below then find. + camera.Render(); + + using ProfilerRecorder shapes = ProfilerRecorder.StartNew(ProfilerCategory.Render, "Wireframes Shapes"); + using ProfilerRecorder edges = ProfilerRecorder.StartNew(ProfilerCategory.Render, "Wireframes Edges"); + using ProfilerRecorder chunks = ProfilerRecorder.StartNew(ProfilerCategory.Render, "Wireframes Chunks"); + yield return null; + camera.Render(); + yield return null; + + Assert.That(shapes.LastValue, Is.EqualTo(3)); + Assert.That(edges.LastValue, Is.EqualTo(2)); + Assert.That(chunks.LastValue, Is.EqualTo(2)); + } +#endif + } +} diff --git a/UnityPackage/Tests/Runtime/ProfilingTests.cs.meta b/UnityPackage/Tests/Runtime/ProfilingTests.cs.meta new file mode 100644 index 0000000..60c1fb6 --- /dev/null +++ b/UnityPackage/Tests/Runtime/ProfilingTests.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 9238e6a506d37bed706d31b134806dee \ No newline at end of file diff --git a/UnityPackage/Tests/Runtime/reromanlee.Wireframes.Tests.Runtime.asmdef b/UnityPackage/Tests/Runtime/reromanlee.Wireframes.Tests.Runtime.asmdef index 3384dd6..9191982 100644 --- a/UnityPackage/Tests/Runtime/reromanlee.Wireframes.Tests.Runtime.asmdef +++ b/UnityPackage/Tests/Runtime/reromanlee.Wireframes.Tests.Runtime.asmdef @@ -17,6 +17,12 @@ "defineConstraints": [ "UNITY_INCLUDE_TESTS" ], - "versionDefines": [], + "versionDefines": [ + { + "name": "com.unity.profiling.core", + "expression": "1.0.0", + "define": "WIREFRAMES_PROFILING_CORE" + } + ], "noEngineReferences": false } From da35851c972851c0a3727230ab818f7edaf1e3cc Mon Sep 17 00:00:00 2001 From: Roman Likhadievski Date: Tue, 29 Sep 2026 22:17:19 +0300 Subject: [PATCH 10/13] create: ci pipeline --- .github/RELEASE_CHECKLIST.md | 19 ++ .github/workflows/continuous-integration.yml | 155 ++++++++++++++++ UnityPackage/Runtime/AssemblyInfo.cs | 1 + UnityPackage/Runtime/Rendering/MeshChunk.cs | 11 ++ UnityPackage/Tests/Performance.meta | 8 + .../Tests/Performance/PerformanceTests.cs | 160 +++++++++++++++++ .../PerformanceTests.cs.meta | 0 ...manlee.Wireframes.Tests.Performance.asmdef | 31 ++++ ...e.Wireframes.Tests.Performance.asmdef.meta | 7 + UnityPackage/Tests/Runtime/MeshBufferTests.cs | 34 ++++ .../Tests/Runtime/PerformanceTests.cs | 166 ------------------ .../Tests/Runtime/WireframeContainerTests.cs | 4 +- UnityProject/.gitignore | 15 ++ UnityProject/Assets/Editor.meta | 8 + UnityProject/Assets/Editor/PlayerBuild.cs | 58 ++++++ .../Assets/Editor/PlayerBuild.cs.meta | 2 + UnityProject/Packages/manifest.json | 39 ++++ 17 files changed, 550 insertions(+), 168 deletions(-) create mode 100644 .github/RELEASE_CHECKLIST.md create mode 100644 .github/workflows/continuous-integration.yml create mode 100644 UnityPackage/Tests/Performance.meta create mode 100644 UnityPackage/Tests/Performance/PerformanceTests.cs rename UnityPackage/Tests/{Runtime => Performance}/PerformanceTests.cs.meta (100%) create mode 100644 UnityPackage/Tests/Performance/reromanlee.Wireframes.Tests.Performance.asmdef create mode 100644 UnityPackage/Tests/Performance/reromanlee.Wireframes.Tests.Performance.asmdef.meta delete mode 100644 UnityPackage/Tests/Runtime/PerformanceTests.cs create mode 100644 UnityProject/.gitignore create mode 100644 UnityProject/Assets/Editor.meta create mode 100644 UnityProject/Assets/Editor/PlayerBuild.cs create mode 100644 UnityProject/Assets/Editor/PlayerBuild.cs.meta create mode 100644 UnityProject/Packages/manifest.json diff --git a/.github/RELEASE_CHECKLIST.md b/.github/RELEASE_CHECKLIST.md new file mode 100644 index 0000000..e9230d8 --- /dev/null +++ b/.github/RELEASE_CHECKLIST.md @@ -0,0 +1,19 @@ +# Release checklist + +Continuous integration checks what a machine without a GPU can: the tests on the oldest and newest supported Unity, drawn by a software renderer, and player builds for every platform. The rest is checked by hand before each release, most important first. + +### Before merging the release pull request + +1. Every check of the pull request passes: the tests on both Unity versions, and the WebGL, Android, iOS, macOS and Linux builds. +2. The whole test suite passes locally on a real GPU, on the oldest and the newest supported Unity. +3. The Stress Test sample runs smoothly in Windows, Android and WebGL builds, with frame, main thread and GPU times no worse than the previous release's. +4. The Shape Gallery sample and all three occlusion modes look right on the same devices, in the Built-in, URP and HDRP pipelines. +5. A Profiler capture of an Android development build under load shows no new spikes in the `Wireframes.*` markers and no allocations in steady frames. +6. The timings in the test results artifacts are no worse than the previous release's. +7. `package.json` has the new version, `CHANGELOG.md` has its section, and the README and `Documentation/` describe the release; a major release also updates `Documentation/MIGRATION.md`. + +### After merging + +1. Tag the squashed commit on `main` with the version alone, such as `2.0.0`. +2. Export the signed tarball of the package from the Package Manager. +3. Draft the GitHub release from the tag, named like `v2.0.0 (signed)`, with the changelog section as its notes and the tarball attached. diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml new file mode 100644 index 0000000..66d7344 --- /dev/null +++ b/.github/workflows/continuous-integration.yml @@ -0,0 +1,155 @@ +name: Continuous integration + +on: + pull_request: + paths-ignore: + - '**.md' + - '**.jpg' + - '**.png' + push: + branches: + - main + paths-ignore: + - '**.md' + - '**.jpg' + - '**.png' + workflow_dispatch: + +# A newer push to the same branch or pull request replaces the run in progress. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + # The test runner reports its results as a check run. + checks: write + +# A Personal license needs UNITY_LICENSE, UNITY_EMAIL and UNITY_PASSWORD; a Pro license UNITY_SERIAL instead of the first. +env: + UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }} + UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }} + UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }} + UNITY_SERIAL: ${{ secrets.UNITY_SERIAL }} + +jobs: + tests: + name: Tests on ${{ matrix.unityVersion }} + runs-on: ubuntu-latest + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + include: + - unityVersion: 2022.3.62f3 + # Also compiles and tests the Profiler counters, which exist only with this optional package. + - unityVersion: 6000.5.11f1 + optionalPackages: '{"com.unity.profiling.core": "1.0.3"}' + steps: + - name: Free disk space for the Unity image + uses: jlumbroso/free-disk-space@ceedf095f4ec1a097402bc6bd80831f2e1a6fde6 # v2.0.0 + with: + large-packages: false + + - name: Check out the repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + # Without it, Unity takes the project for a new one and adds its template packages to the manifest. + - name: Set the Unity version of the project + env: + UNITY_VERSION: ${{ matrix.unityVersion }} + run: | + mkdir -p UnityProject/ProjectSettings + printf 'm_EditorVersion: %s\n' "$UNITY_VERSION" > UnityProject/ProjectSettings/ProjectVersion.txt + + - name: Add the optional packages + if: matrix.optionalPackages + env: + OPTIONAL_PACKAGES: ${{ matrix.optionalPackages }} + run: | + manifest=UnityProject/Packages/manifest.json + jq --argjson packages "$OPTIONAL_PACKAGES" '.dependencies += $packages' "$manifest" > manifest.tmp + mv manifest.tmp "$manifest" + + - name: Restore the Library folder + uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 + with: + path: UnityProject/Library + key: Library-tests-${{ matrix.unityVersion }}-${{ hashFiles('UnityPackage/**', 'UnityProject/**') }} + restore-keys: Library-tests-${{ matrix.unityVersion }}- + + - name: Run the edit and play mode tests + uses: game-ci/unity-test-runner@32e57712352b500e17974b245a6dce9e11a73213 # v4.4.0 + with: + unityVersion: ${{ matrix.unityVersion }} + cliVersion: v0.1.69 + projectPath: UnityProject + testMode: all + # Code coverage crashes play mode tests on Unity 6, see game-ci/unity-test-runner#301. + coverageEnabled: false + githubToken: ${{ github.token }} + checkName: Test results on ${{ matrix.unityVersion }} + + - name: Check that the package compiles without warnings + if: always() + run: | + warnings=$(grep -hs "warning CS" artifacts/*.log | grep -e "com.reromanlee.wireframes" -e "UnityPackage" | sort -u || true) + if [ -n "$warnings" ]; then + echo "$warnings" + echo "::error::The package compiled with warnings." + exit 1 + fi + + - name: Upload the test results and timings + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: Test results on ${{ matrix.unityVersion }} + path: artifacts + + builds: + name: ${{ matrix.targetPlatform }} build on ${{ matrix.unityVersion }} + runs-on: ubuntu-latest + timeout-minutes: 90 + strategy: + fail-fast: false + matrix: + include: + # Tier 1 platforms on the oldest and the newest supported Unity. + - { unityVersion: 2022.3.62f3, targetPlatform: WebGL } + - { unityVersion: 2022.3.62f3, targetPlatform: Android } + - { unityVersion: 6000.5.11f1, targetPlatform: WebGL } + - { unityVersion: 6000.5.11f1, targetPlatform: Android } + # Tier 2 platforms, checked to build on the newest Unity. + - { unityVersion: 6000.5.11f1, targetPlatform: iOS } + - { unityVersion: 6000.5.11f1, targetPlatform: StandaloneOSX } + - { unityVersion: 6000.5.11f1, targetPlatform: StandaloneLinux64 } + steps: + - name: Free disk space for the Unity image + uses: jlumbroso/free-disk-space@ceedf095f4ec1a097402bc6bd80831f2e1a6fde6 # v2.0.0 + with: + large-packages: false + + - name: Check out the repository + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + # Without it, Unity takes the project for a new one and adds its template packages to the manifest. + - name: Set the Unity version of the project + env: + UNITY_VERSION: ${{ matrix.unityVersion }} + run: | + mkdir -p UnityProject/ProjectSettings + printf 'm_EditorVersion: %s\n' "$UNITY_VERSION" > UnityProject/ProjectSettings/ProjectVersion.txt + + - name: Add the package samples to the project + run: cp -r UnityPackage/Samples~ UnityProject/Assets/Samples + + # Only checks that the player builds; nothing is uploaded or released. + - name: Build the Shape Gallery sample + uses: game-ci/unity-builder@adaf7cd8f42fd48fbbf2d4f5f67ca30d87f819f1 # v5.0.1 + with: + unityVersion: ${{ matrix.unityVersion }} + targetPlatform: ${{ matrix.targetPlatform }} + projectPath: UnityProject + buildMethod: reromanlee.Wireframes.ContinuousIntegration.PlayerBuild.Build + versioning: None diff --git a/UnityPackage/Runtime/AssemblyInfo.cs b/UnityPackage/Runtime/AssemblyInfo.cs index dc1ec6d..ebc5f83 100644 --- a/UnityPackage/Runtime/AssemblyInfo.cs +++ b/UnityPackage/Runtime/AssemblyInfo.cs @@ -3,3 +3,4 @@ [assembly: InternalsVisibleTo("reromanlee.Wireframes.Editor")] [assembly: InternalsVisibleTo("reromanlee.Wireframes.Tests.Editor")] [assembly: InternalsVisibleTo("reromanlee.Wireframes.Tests.Runtime")] +[assembly: InternalsVisibleTo("reromanlee.Wireframes.Tests.Performance")] diff --git a/UnityPackage/Runtime/Rendering/MeshChunk.cs b/UnityPackage/Runtime/Rendering/MeshChunk.cs index 8a388df..ba4bf25 100644 --- a/UnityPackage/Runtime/Rendering/MeshChunk.cs +++ b/UnityPackage/Runtime/Rendering/MeshChunk.cs @@ -207,6 +207,12 @@ internal int PendingCapacity get => _pending.Length; } + /// Vertices the last flush uploaded, counted once for each stream they were uploaded to. + internal int UploadedVertexCount { get; private set; } + + /// Indices the last flush uploaded. + internal int UploadedIndexCount { get; private set; } + internal int EdgeCount { get => _edges.Count; @@ -359,6 +365,8 @@ internal void Flush() } using (WireframesMarkers.UploadMesh.Auto()) { + UploadedVertexCount = 0; + UploadedIndexCount = 0; UploadVertices(); UploadIndices(); } @@ -555,6 +563,7 @@ private void UploadVertices() _mesh.SetVertexBufferData(_colors, 0, 0, capacity, ColorStream, UploadFlags); _mesh.SetVertexBufferData(_boneIndices, 0, 0, capacity, BoneStream, UploadFlags); } + UploadedVertexCount += capacity * 3; _positionRanges.Clear(); _colorRanges.Clear(); _boneRanges.Clear(); @@ -570,6 +579,7 @@ private void UploadRanges(DirtyRanges ranges, T[] data, int stream) where T : { RangeInt range = ranges[i]; _mesh.SetVertexBufferData(data, range.start, range.start, range.length, stream, UploadFlags); + UploadedVertexCount += range.length; } ranges.Clear(); } @@ -624,6 +634,7 @@ private void UploadIndexRange(int start, int count) { return; } + UploadedIndexCount += count; int[] indices = _edges.Indices; if (_meshIndexFormat == IndexFormat.UInt32) { diff --git a/UnityPackage/Tests/Performance.meta b/UnityPackage/Tests/Performance.meta new file mode 100644 index 0000000..5ea88e8 --- /dev/null +++ b/UnityPackage/Tests/Performance.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: d1f26642607d1c470cc746380e623ce7 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityPackage/Tests/Performance/PerformanceTests.cs b/UnityPackage/Tests/Performance/PerformanceTests.cs new file mode 100644 index 0000000..b194d1b --- /dev/null +++ b/UnityPackage/Tests/Performance/PerformanceTests.cs @@ -0,0 +1,160 @@ +using System; +using NUnit.Framework; +using Unity.PerformanceTesting; +using UnityEngine; +using Random = UnityEngine.Random; + +namespace reromanlee.Wireframes.Tests +{ + /// + /// Times the package's own CPU work one frame at a time, the edits of the frame plus the flush that uploads them, + /// and counts the frame's allocations. The Performance Testing package records the results; nothing here asserts a + /// speed, since that depends on the machine. + /// + public class PerformanceTests : WireframesTestBase + { + private const int LineCount = 10000; + private const int SphereCount = 1000; + private const int BoneCount = 100; + private const int EditCount = 1000; + private const int ResizeCount = 100; + private const int MeasurementCount = 10; + + private readonly ILine[] _lines = new ILine[LineCount]; + private readonly ISphere[] _spheres = new ISphere[SphereCount]; + private Transform[] _bones; + private WireframeContainer _container; + + [SetUp] + public void CreateBones() + { + _bones = new Transform[BoneCount]; + for (int i = 0; i < BoneCount; i++) + { + _bones[i] = CreateBone(Random.insideUnitSphere * 10f, Random.rotation); + } + } + + [Test, Performance] + public void CreateLines() + { + MeasureFrame(null, AddLines, SampleUnit.Millisecond); + } + + [Test, Performance] + public void FlushUnchangedLines() + { + MeasureFrame(AddLines, () => { }, SampleUnit.Microsecond); + } + + [Test, Performance] + public void MoveEveryBone() + { + MeasureFrame(AddLines, MoveBones, SampleUnit.Microsecond); + } + + [Test, Performance] + public void EditLineColors() + { + MeasureFrame(AddLines, () => + { + for (int i = 0; i < EditCount; i++) + { + _lines[i * 7919 % LineCount].ColorA = Color.red; + } + }, SampleUnit.Millisecond); + } + + [Test, Performance] + public void DisposeHalfTheLines() + { + MeasureFrame(AddLines, () => + { + for (int i = 0; i < LineCount; i += 2) + { + _lines[i].Dispose(); + } + }, SampleUnit.Millisecond); + } + + [Test, Performance] + public void CreateSpheres() + { + MeasureFrame(null, AddSpheres, SampleUnit.Millisecond); + } + + [Test, Performance] + public void ResizeSpheres() + { + // Resizing rewrites every vertex of a sphere. + MeasureFrame(AddSpheres, () => + { + for (int i = 0; i < ResizeCount; i++) + { + _spheres[i * 7919 % SphereCount].Radius += 0.1f; + } + }, SampleUnit.Millisecond); + } + + [Test, Performance] + public void DisposeHalfTheSpheres() + { + MeasureFrame(AddSpheres, () => + { + for (int i = 0; i < SphereCount; i += 2) + { + _spheres[i].Dispose(); + } + }, SampleUnit.Millisecond); + } + + /// + /// Measures and the flush after it, each time in a new container that + /// filled and flushed beforehand. + /// + private void MeasureFrame(Action fill, Action frame, SampleUnit unit) + { + Measure.Method(() => + { + frame(); + _container.Proxy.Flush(); + }) + .SetUp(() => + { + _container = CreateContainer(); + fill?.Invoke(); + _container.Proxy.Flush(); + }) + .CleanUp(() => _container.Dispose()) + .WarmupCount(1) + .MeasurementCount(MeasurementCount) + .SampleGroup(new SampleGroup("Frame", unit)) + .GC() + .Run(); + } + + private void AddLines() + { + for (int i = 0; i < LineCount; i++) + { + _lines[i] = _container.CreateLine(_bones[i % BoneCount], _bones[(i + 1) % BoneCount]); + } + } + + private void AddSpheres() + { + for (int i = 0; i < SphereCount; i++) + { + _spheres[i] = _container.CreateSphere(_bones[i % BoneCount], Random.insideUnitSphere, 0.5f); + } + } + + private void MoveBones() + { + foreach (Transform bone in _bones) + { + bone.position += Vector3.up; + } + } + } +} diff --git a/UnityPackage/Tests/Runtime/PerformanceTests.cs.meta b/UnityPackage/Tests/Performance/PerformanceTests.cs.meta similarity index 100% rename from UnityPackage/Tests/Runtime/PerformanceTests.cs.meta rename to UnityPackage/Tests/Performance/PerformanceTests.cs.meta diff --git a/UnityPackage/Tests/Performance/reromanlee.Wireframes.Tests.Performance.asmdef b/UnityPackage/Tests/Performance/reromanlee.Wireframes.Tests.Performance.asmdef new file mode 100644 index 0000000..0e41c1c --- /dev/null +++ b/UnityPackage/Tests/Performance/reromanlee.Wireframes.Tests.Performance.asmdef @@ -0,0 +1,31 @@ +{ + "name": "reromanlee.Wireframes.Tests.Performance", + "rootNamespace": "reromanlee.Wireframes.Tests", + "references": [ + "reromanlee.Wireframes", + "reromanlee.Wireframes.Tests.Runtime", + "Unity.PerformanceTesting", + "UnityEngine.TestRunner", + "UnityEditor.TestRunner" + ], + "includePlatforms": [], + "excludePlatforms": [], + "allowUnsafeCode": false, + "overrideReferences": true, + "precompiledReferences": [ + "nunit.framework.dll" + ], + "autoReferenced": false, + "defineConstraints": [ + "UNITY_INCLUDE_TESTS", + "WIREFRAMES_PERFORMANCE_TESTING" + ], + "versionDefines": [ + { + "name": "com.unity.test-framework.performance", + "expression": "3.0.0", + "define": "WIREFRAMES_PERFORMANCE_TESTING" + } + ], + "noEngineReferences": false +} diff --git a/UnityPackage/Tests/Performance/reromanlee.Wireframes.Tests.Performance.asmdef.meta b/UnityPackage/Tests/Performance/reromanlee.Wireframes.Tests.Performance.asmdef.meta new file mode 100644 index 0000000..946025a --- /dev/null +++ b/UnityPackage/Tests/Performance/reromanlee.Wireframes.Tests.Performance.asmdef.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 9e0fdab7a849837a079848b6d676dfea +AssemblyDefinitionImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityPackage/Tests/Runtime/MeshBufferTests.cs b/UnityPackage/Tests/Runtime/MeshBufferTests.cs index c3f3224..c69074e 100644 --- a/UnityPackage/Tests/Runtime/MeshBufferTests.cs +++ b/UnityPackage/Tests/Runtime/MeshBufferTests.cs @@ -49,6 +49,40 @@ public void DisposingShapes_KeepsRemainingEdgesOnTheirOwnVertices() Assert.That(indices, Has.Length.EqualTo(expectedEdges * 2)); } + [Test] + public void Edits_UploadOnlyWhatTheyChange() + { + WireframeContainer container = CreateContainer(); + Transform bone = CreateBone(Vector3.zero, Quaternion.identity); + ILine[] lines = new ILine[100]; + for (int i = 0; i < lines.Length; i++) + { + lines[i] = container.CreateLine(bone, bone); + } + ISphere sphere = container.CreateSphere(bone, Vector3.zero, 1f); + MeshChunk chunk = ChunkOf(container); + container.Proxy.Flush(); + + lines[50].ColorA = Color.red; + container.Proxy.Flush(); + Assert.That(chunk.UploadedVertexCount, Is.EqualTo(2), "A color edit uploaded more than the line's colors."); + Assert.That(chunk.UploadedIndexCount, Is.Zero); + + sphere.Radius = 2f; + container.Proxy.Flush(); + Assert.That(chunk.UploadedVertexCount, Is.EqualTo(((Shape)sphere).VertexCount), + "A radius edit uploaded more than the sphere's positions."); + + bone.position = Vector3.one; + container.Proxy.Flush(); + Assert.That(chunk.UploadedVertexCount, Is.Zero, "Moving a bone uploaded vertices."); + + lines[20].IsVisible = false; + container.Proxy.Flush(); + Assert.That(chunk.UploadedVertexCount, Is.Zero); + Assert.That(chunk.UploadedIndexCount, Is.EqualTo(2), "Hiding a line uploaded more than the edge moved into its slot."); + } + [Test] public void FreedBlock_IsReusedByShapeOfSameSize() { diff --git a/UnityPackage/Tests/Runtime/PerformanceTests.cs b/UnityPackage/Tests/Runtime/PerformanceTests.cs deleted file mode 100644 index 5b783e5..0000000 --- a/UnityPackage/Tests/Runtime/PerformanceTests.cs +++ /dev/null @@ -1,166 +0,0 @@ -using System.Diagnostics; -using NUnit.Framework; -using UnityEngine; -using Debug = UnityEngine.Debug; - -namespace reromanlee.Wireframes.Tests -{ - /// Logs rough CPU timings of the package's own work; it asserts nothing about speed. - public class PerformanceTests : WireframesTestBase - { - private const int LineCount = 10000; - private const int SphereCount = 1000; - private const int BoneCount = 100; - private const int EditCount = 1000; - private const int ResizeCount = 100; - private const int IdleFlushes = 100; - - [Test] - public void LogLineTimings() - { - Transform[] bones = CreateBones(); - - // Warm-up so JIT compilation isn't counted. - WireframeContainer warmUp = CreateContainer(); - for (int i = 0; i < 100; i++) - { - warmUp.CreateLine().BoneA = bones[i % BoneCount]; - } - warmUp.Proxy.Flush(); - warmUp.Dispose(); - - WireframeContainer container = CreateContainer(); - ILine[] lines = new ILine[LineCount]; - Stopwatch stopwatch = Stopwatch.StartNew(); - for (int i = 0; i < LineCount; i++) - { - lines[i] = container.CreateLine(Random.insideUnitSphere, Random.insideUnitSphere); - lines[i].BoneA = bones[i % BoneCount]; - } - double create = Lap(stopwatch); - container.Proxy.Flush(); - double firstFlush = Lap(stopwatch); - - double idleFlush = TimeIdleFlushes(container, stopwatch); - double flushAfterBonesMoved = TimeFlushAfterMoving(bones, container, stopwatch); - - for (int i = 0; i < EditCount; i++) - { - lines[i * 7919 % LineCount].ColorA = Color.red; - } - double edits = Lap(stopwatch); - container.Proxy.Flush(); - double editFlush = Lap(stopwatch); - - for (int i = 0; i < LineCount; i += 2) - { - lines[i].Dispose(); - } - double dispose = Lap(stopwatch); - container.Proxy.Flush(); - double disposeFlush = Lap(stopwatch); - - Debug.Log( - $"[Wireframes timings] {LineCount} lines on {BoneCount} bones: create {create:F2} ms, first flush {firstFlush:F2} ms, " + - $"idle flush {idleFlush * 1000.0:F1} us, flush after moving every bone {flushAfterBonesMoved * 1000.0:F1} us, " + - $"{EditCount} scattered color edits {edits:F2} ms + flush {editFlush:F2} ms, " + - $"dispose {LineCount / 2} lines {dispose:F2} ms + flush {disposeFlush:F2} ms"); - Assert.That(ShapeCountOf(container), Is.EqualTo(LineCount / 2)); - } - - [Test] - public void LogSphereTimings() - { - Transform[] bones = CreateBones(); - - // Warm-up so JIT compilation isn't counted. - WireframeContainer warmUp = CreateContainer(); - for (int i = 0; i < 100; i++) - { - warmUp.CreateSphere(bones[i % BoneCount], Vector3.zero, 0.5f).Radius = 1f; - } - warmUp.Proxy.Flush(); - warmUp.Dispose(); - - WireframeContainer container = CreateContainer(); - ISphere[] spheres = new ISphere[SphereCount]; - Stopwatch stopwatch = Stopwatch.StartNew(); - for (int i = 0; i < SphereCount; i++) - { - spheres[i] = container.CreateSphere(bones[i % BoneCount], Random.insideUnitSphere, 0.5f); - } - double create = Lap(stopwatch); - container.Proxy.Flush(); - double firstFlush = Lap(stopwatch); - - double idleFlush = TimeIdleFlushes(container, stopwatch); - double flushAfterBonesMoved = TimeFlushAfterMoving(bones, container, stopwatch); - - // Resizing rewrites every vertex of a sphere. - for (int i = 0; i < ResizeCount; i++) - { - spheres[i * 7919 % SphereCount].Radius = 1f; - } - double resizes = Lap(stopwatch); - container.Proxy.Flush(); - double resizeFlush = Lap(stopwatch); - - for (int i = 0; i < SphereCount; i += 2) - { - spheres[i].Dispose(); - } - double dispose = Lap(stopwatch); - container.Proxy.Flush(); - double disposeFlush = Lap(stopwatch); - - int vertices = SphereCount * ((Sphere)spheres[1]).VertexCount; - Debug.Log( - $"[Wireframes timings] {SphereCount} spheres ({vertices} vertices) on {BoneCount} bones: create {create:F2} ms, " + - $"first flush {firstFlush:F2} ms, idle flush {idleFlush * 1000.0:F1} us, " + - $"flush after moving every bone {flushAfterBonesMoved * 1000.0:F1} us, " + - $"{ResizeCount} scattered radius edits {resizes:F2} ms + flush {resizeFlush:F2} ms, " + - $"dispose {SphereCount / 2} spheres {dispose:F2} ms + flush {disposeFlush:F2} ms"); - Assert.That(ShapeCountOf(container), Is.EqualTo(SphereCount / 2)); - } - - private Transform[] CreateBones() - { - Transform[] bones = new Transform[BoneCount]; - for (int i = 0; i < BoneCount; i++) - { - bones[i] = CreateBone(Random.insideUnitSphere * 10f, Random.rotation); - } - return bones; - } - - /// Milliseconds per flush when nothing changed. - private static double TimeIdleFlushes(WireframeContainer container, Stopwatch stopwatch) - { - stopwatch.Restart(); - for (int i = 0; i < IdleFlushes; i++) - { - container.Proxy.Flush(); - } - return Lap(stopwatch) / IdleFlushes; - } - - /// Milliseconds for the flush after every bone moved. - private static double TimeFlushAfterMoving(Transform[] bones, WireframeContainer container, Stopwatch stopwatch) - { - foreach (Transform bone in bones) - { - bone.position += Vector3.up; - } - stopwatch.Restart(); - container.Proxy.Flush(); - return Lap(stopwatch); - } - - private static double Lap(Stopwatch stopwatch) - { - double elapsed = stopwatch.Elapsed.TotalMilliseconds; - stopwatch.Restart(); - return elapsed; - } - } -} diff --git a/UnityPackage/Tests/Runtime/WireframeContainerTests.cs b/UnityPackage/Tests/Runtime/WireframeContainerTests.cs index 57c7d7a..06ea3a8 100644 --- a/UnityPackage/Tests/Runtime/WireframeContainerTests.cs +++ b/UnityPackage/Tests/Runtime/WireframeContainerTests.cs @@ -136,7 +136,7 @@ public void Settings_EmptyName_UsesTheDefaultName() [Test] public void Settings_OutOfRange_ThrowBeforeAnythingIsCreated() { - int objectsBefore = Object.FindObjectsByType(FindObjectsSortMode.None).Length; + int objectsBefore = Resources.FindObjectsOfTypeAll().Length; Assert.Throws(() => CreateContainer(new WireframeContainerSettings { Layer = 32 })); Assert.Throws(() => CreateContainer(new WireframeContainerSettings { Layer = -1 })); @@ -144,7 +144,7 @@ public void Settings_OutOfRange_ThrowBeforeAnythingIsCreated() () => CreateContainer(new WireframeContainerSettings { VertexCapacity = -1 })); Assert.Throws( () => CreateContainer(new WireframeContainerSettings { EdgeCapacity = -1 })); - Assert.That(Object.FindObjectsByType(FindObjectsSortMode.None), Has.Length.EqualTo(objectsBefore)); + Assert.That(Resources.FindObjectsOfTypeAll(), Has.Length.EqualTo(objectsBefore)); } [Test] diff --git a/UnityProject/.gitignore b/UnityProject/.gitignore new file mode 100644 index 0000000..e4e0505 --- /dev/null +++ b/UnityProject/.gitignore @@ -0,0 +1,15 @@ +## What Unity generates when it opens the project +/Library/ +/Logs/ +/Temp/ +/UserSettings/ +/obj/ +/Build/ +/ProjectSettings/ +/Packages/packages-lock.json +*.csproj +*.sln + +## Package samples, copied in by continuous integration or imported with the Package Manager +/Assets/Samples/ +/Assets/Samples.meta diff --git a/UnityProject/Assets/Editor.meta b/UnityProject/Assets/Editor.meta new file mode 100644 index 0000000..2d5b091 --- /dev/null +++ b/UnityProject/Assets/Editor.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 318cc905452a8498e95ed5a1f3670245 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/UnityProject/Assets/Editor/PlayerBuild.cs b/UnityProject/Assets/Editor/PlayerBuild.cs new file mode 100644 index 0000000..f4b97b5 --- /dev/null +++ b/UnityProject/Assets/Editor/PlayerBuild.cs @@ -0,0 +1,58 @@ +using System; +using System.IO; +using UnityEditor; +using UnityEditor.Build; +using UnityEditor.Build.Reporting; + +namespace reromanlee.Wireframes.ContinuousIntegration +{ + /// + /// Builds the Shape Gallery sample for the active build target. Continuous integration copies the package samples + /// into Assets/Samples, then GameCI's builder calls with -executeMethod. + /// + public static class PlayerBuild + { + private const string ScenePath = "Assets/Samples/ShapeGallery/ShapeGallery.unity"; + + /// Builds to the path in GameCI's -customBuildPath argument; throwing makes Unity exit with code 1. + public static void Build() + { + if (!File.Exists(ScenePath)) + { + throw new BuildFailedException($"{ScenePath} is missing; copy the package samples into Assets/Samples."); + } + BuildTarget target = EditorUserBuildSettings.activeBuildTarget; + if (target == BuildTarget.Android) + { + // What app stores require, on one architecture to keep the build short. + PlayerSettings.SetScriptingBackend(NamedBuildTarget.Android, ScriptingImplementation.IL2CPP); + PlayerSettings.Android.targetArchitectures = AndroidArchitecture.ARM64; + } + + BuildReport report = BuildPipeline.BuildPlayer(new BuildPlayerOptions + { + scenes = new[] { ScenePath }, + target = target, + targetGroup = BuildPipeline.GetBuildTargetGroup(target), + locationPathName = ValueOf("-customBuildPath") + }); + BuildSummary summary = report.summary; + if (summary.result != BuildResult.Succeeded) + { + throw new BuildFailedException( + $"The {target} build ended as {summary.result} with {summary.totalErrors} errors."); + } + } + + private static string ValueOf(string argument) + { + string[] arguments = Environment.GetCommandLineArgs(); + int index = Array.IndexOf(arguments, argument); + if (index < 0 || index == arguments.Length - 1) + { + throw new ArgumentException($"The command line has no {argument} value."); + } + return arguments[index + 1]; + } + } +} diff --git a/UnityProject/Assets/Editor/PlayerBuild.cs.meta b/UnityProject/Assets/Editor/PlayerBuild.cs.meta new file mode 100644 index 0000000..bcbdf55 --- /dev/null +++ b/UnityProject/Assets/Editor/PlayerBuild.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4f61efb4f56873387fe06f1156f30669 \ No newline at end of file diff --git a/UnityProject/Packages/manifest.json b/UnityProject/Packages/manifest.json new file mode 100644 index 0000000..4457fed --- /dev/null +++ b/UnityProject/Packages/manifest.json @@ -0,0 +1,39 @@ +{ + "dependencies": { + "com.reromanlee.wireframes": "file:../../UnityPackage", + "com.unity.test-framework.performance": "3.5.0", + "com.unity.modules.ai": "1.0.0", + "com.unity.modules.androidjni": "1.0.0", + "com.unity.modules.animation": "1.0.0", + "com.unity.modules.assetbundle": "1.0.0", + "com.unity.modules.audio": "1.0.0", + "com.unity.modules.cloth": "1.0.0", + "com.unity.modules.director": "1.0.0", + "com.unity.modules.imageconversion": "1.0.0", + "com.unity.modules.imgui": "1.0.0", + "com.unity.modules.jsonserialize": "1.0.0", + "com.unity.modules.particlesystem": "1.0.0", + "com.unity.modules.physics": "1.0.0", + "com.unity.modules.physics2d": "1.0.0", + "com.unity.modules.screencapture": "1.0.0", + "com.unity.modules.terrain": "1.0.0", + "com.unity.modules.terrainphysics": "1.0.0", + "com.unity.modules.tilemap": "1.0.0", + "com.unity.modules.ui": "1.0.0", + "com.unity.modules.uielements": "1.0.0", + "com.unity.modules.umbra": "1.0.0", + "com.unity.modules.unityanalytics": "1.0.0", + "com.unity.modules.unitywebrequest": "1.0.0", + "com.unity.modules.unitywebrequestassetbundle": "1.0.0", + "com.unity.modules.unitywebrequestaudio": "1.0.0", + "com.unity.modules.unitywebrequesttexture": "1.0.0", + "com.unity.modules.unitywebrequestwww": "1.0.0", + "com.unity.modules.vehicles": "1.0.0", + "com.unity.modules.video": "1.0.0", + "com.unity.modules.wind": "1.0.0", + "com.unity.modules.xr": "1.0.0" + }, + "testables": [ + "com.reromanlee.wireframes" + ] +} From 31c332513e081d3e9bbb50099ec6c17df850dcff Mon Sep 17 00:00:00 2001 From: Roman Likhadievski Date: Tue, 29 Sep 2026 22:30:18 +0300 Subject: [PATCH 11/13] polish: documentation --- .gitattributes | 1 + CHANGELOG.md | 108 ++++--- Documentation/FEATURES.md | 100 ++++++ Documentation/MIGRATION.md | 47 +++ Documentation/PERFORMANCE.md | 81 +++++ Documentation/USAGE.md | 197 ++++++++++++ README.md | 284 +++++------------- .../Runtime/Container/WireframeStatistics.cs | 5 +- .../Samples~/ShapeGallery/ShapeGallery.unity | 9 +- UnityPackage/package.json | 6 +- 10 files changed, 566 insertions(+), 272 deletions(-) create mode 100644 Documentation/FEATURES.md create mode 100644 Documentation/MIGRATION.md create mode 100644 Documentation/PERFORMANCE.md create mode 100644 Documentation/USAGE.md diff --git a/.gitattributes b/.gitattributes index e69de29..176a458 100644 --- a/.gitattributes +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto diff --git a/CHANGELOG.md b/CHANGELOG.md index 03771be..0d57a7f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,47 +1,61 @@ -# Changelog - -All notable changes to this package are documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this package adheres to -[Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## [1.0.0] - 2026-09-24 - -### Added - -- Shapes: polylines (`IPolyline`, created as polylines, polygons or triangles), rectangles, rounded rectangles, - circles, ellipses, stars, spheres, ellipsoids, spiked spheres, cylinders, cones, capsules, stadiums, frustums and - pyramids. -- `IRigidShape`, shared by every shape except lines and polylines: one bone, a local and world position and rotation, - and a `Color`. Changing the bone keeps the shape's world position, rotation and size. -- `IAxialShape`, shared by long shapes: `Length`, and end B through `LocalEnd` and `WorldEnd`. -- Create methods that take bones first and work in their local space, such as `CreateLine(boneA, boneB)` and - `CreateCircle(bone, localCenter, radius)`. -- Polylines have a bone and a color for every point. -- Shape Gallery sample, a scene with every shape. The Stress Test sample spawns every kind of shape. -- `WireframeCamera`, a component that draws everything its camera renders as wireframe, under the Built-in Render - Pipeline or any Scriptable Render Pipeline. - -### Changed - -- The package lives in the repository's `UnityPackage` folder. Install it from - `https://github.com/reromanlee/Wireframes.git?path=/UnityPackage`. -- Boxes have a center, a rotation and a signed `Size`. Their corner properties still work, and - `CreateBox(center, rotation, size)` creates a rotated box. -- Changing a box's bone keeps its world rotation instead of aligning the box to the new bone's axes. - `CreateBox(bone, localCornerA, localCornerB)` creates a box aligned to a bone. -- `CreateBox()` creates a 1 by 1 by 1 cube instead of a box with both corners at the origin. - -## [0.1.0] - 2026-09-22 - -### Added - -- `LineContainer`, which draws all of its shapes with one GPU-skinned mesh. It is disposed by `Dispose()` or when - the scene it was created in unloads, and accepts an optional material. -- Lines (`ILine`) with local and world positions, colors and a bone for each endpoint. -- Boxes (`IBox`) with local and world corners, one bone and `SetColor`. -- `IShape`, shared by every shape: `SetColor`, `Dispose` and `IsDisposed`. -- Shapes whose bone is destroyed stay in place and switch to world space. -- Edits are applied once per frame and upload only what changed. -- Stress Test sample. -- Edit mode and play mode tests. +# Wireframes 2.0.0 + +### What's new + +1. Shapes follow their bones through the package's own shader skinning instead of a `SkinnedMeshRenderer`, which is faster on WebGL and no longer depends on the GPU Skinning player setting. +2. The package's material draws under the Built-in Render Pipeline, URP and HDRP, hides, shows or fades lines behind other geometry through the `Occlusion` setting, and draws transparent colors through `UseAlpha`. +3. Containers and shapes work in Edit Mode, drawing in the Scene and Game views without ever being saved into scenes. +4. Unity 2022.3 is supported, where 1.0 needed Unity 6. +5. `IsVisible` hides a shape or a whole container without disposing it, and without allocating. +6. `WireframeContainerSettings` sets a container's name, material, layer, persistence across scenes, reserved capacity, occlusion and transparency. +7. Containers split shapes into chunks of up to 65,535 vertices with 16-bit indices, hold any number of shapes, and give memory back as shapes are disposed. +8. `container.Statistics` reports shapes, vertices, edges, bones, chunks and memory without allocating, Profiler markers time each step, and Profiler counters add up every container when the `com.unity.profiling.core` package is installed. +9. Server builds, missing shaders, destroyed bones and failing shapes are handled without exceptions reaching Unity's rendering, and each problem is logged once with a `[Wireframes]` prefix. +10. Steady frames allocate nothing and edits upload only what they change, which tests enforce; continuous integration runs the tests on Unity 2022.3 and 6000.5 and builds for WebGL, Android, iOS, macOS and Linux. +11. The README is rewritten, with detailed Features, Performance, Usage and Migration pages in the `Documentation` folder. + +### Breaking changes + +1. `LineContainer` is now `WireframeContainer`, `ILineContainer` is removed, and a material is passed through `WireframeContainerSettings`. +2. A custom material's shader must move vertices with `WireframesSkin` from the package's `Wireframes.hlsl`. +3. `Segments` and `Sides` are now `SegmentCount` and `SideCount`, and the `segments`, `sides` and star `points` parameters are now `segmentCount`, `sideCount` and `pointCount`. +4. `LocalEnd` and `WorldEnd` of long shapes are now `LocalEndB` and `WorldEndB`, next to the new `LocalEndA` and `WorldEndA`. +5. Edits and bone moves are applied right before each camera renders, instead of at the end of `LateUpdate`. +6. Bones must be Transforms in a scene, and resolutions are capped at 1,024 segments or sides and 512 star points. +7. Calls from other threads throw `InvalidOperationException` in the Editor and development builds. + +### Known issues + +1. Stereo rendering for XR is untested. + +# Wireframes 1.0.0 + +### What's new + +1. New shapes: polylines (`IPolyline`, created as polylines, polygons or triangles), rectangles, rounded rectangles, circles, ellipses, stars, spheres, ellipsoids, spiked spheres, cylinders, cones, capsules, stadiums, frustums and pyramids. +2. `IRigidShape`, shared by every shape except lines and polylines: one bone, a local and world position and rotation, and a `Color`. Changing the bone keeps the shape's world position, rotation and size. +3. `IAxialShape`, shared by long shapes: `Length`, and end B through `LocalEnd` and `WorldEnd`. +4. Create methods that take bones first and work in their local space, such as `CreateLine(boneA, boneB)` and `CreateCircle(bone, localCenter, radius)`. +5. Polylines have a bone and a color for every point. +6. `WireframeCamera`, a component that draws everything its camera renders as wireframe, under the Built-in Render Pipeline or any Scriptable Render Pipeline. +7. The Shape Gallery sample, a scene with every shape, and a Stress Test sample that spawns every kind of shape. + +### Changes + +1. The package lives in the repository's `UnityPackage` folder; install it from `https://github.com/reromanlee/Wireframes.git?path=/UnityPackage`. +2. Boxes have a center, a rotation and a signed `Size`. Their corner properties still work, and `CreateBox(center, rotation, size)` creates a rotated box. +3. Changing a box's bone keeps its world rotation instead of aligning the box to the new bone's axes; `CreateBox(bone, localCornerA, localCornerB)` creates a box aligned to a bone. +4. `CreateBox()` creates a 1 by 1 by 1 cube instead of a box with both corners at the origin. + +# Wireframes 0.1.0 + +### What's new + +1. `LineContainer`, which draws all of its shapes with one GPU-skinned mesh. It is disposed by `Dispose()` or when the scene it was created in unloads, and accepts an optional material. +2. Lines (`ILine`) with local and world positions, colors and a bone for each endpoint. +3. Boxes (`IBox`) with local and world corners, one bone and `SetColor`. +4. `IShape`, shared by every shape: `SetColor`, `Dispose` and `IsDisposed`. +5. Shapes whose bone is destroyed stay in place and switch to world space. +6. Edits are applied once per frame and upload only what changed. +7. The Stress Test sample. +8. Edit Mode and Play Mode tests. diff --git a/Documentation/FEATURES.md b/Documentation/FEATURES.md new file mode 100644 index 0000000..e62c65e --- /dev/null +++ b/Documentation/FEATURES.md @@ -0,0 +1,100 @@ +# Features + +Everything Wireframes does, most important first. How to use each part is in [USAGE.md](USAGE.md). + +### Shapes follow Transforms on the GPU + +A shape attaches to Transforms, called bones, and moves, turns and scales with them from then on. Once per frame, right before a camera renders, the package reads each bone's local-to-world matrix and uploads it to a float texture; the vertex shader moves every vertex by its bone's matrix. + +1. A bone costs the same whether one shape or a thousand follow it. +2. Lines and polylines have a bone per point, so they can join anything; other shapes have one bone and move rigidly with it, like a child Transform. +3. Changing a shape's bone keeps it where it is in the world, like reparenting a Transform. +4. When a bone is destroyed, its shapes stay where they were drawn last and switch to world space. +5. Any Transform in a scene can be a bone. Nothing is added to it, and a prefab asset is refused with an `ArgumentException`. + +### 17 shapes + +| Shape | Usual Create method | What it is | +|---|---|---| +| `ILine` | `CreateLine(a, b)` | A segment; each end has its own bone. | +| `IPolyline` | `CreatePolyline(points)`, `CreatePolygon(points)`, `CreateTriangle(a, b, c)` | Points joined in order, open or closed; each point has its own bone and color. | +| `IBox` | `CreateBox(cornerA, cornerB)` | A box with a center, a rotation and a `Size`. | +| `IRectangle` | `CreateRectangle(cornerA, cornerB)` | A flat rectangle. | +| `IRoundedRectangle` | `CreateRoundedRectangle(cornerA, cornerB, cornerRadius)` | A flat rectangle with round corners. | +| `ICircle` | `CreateCircle(center, radius)` | A flat circle; `CreateCircle(center, normal, radius)` faces a direction. | +| `IEllipse` | `CreateEllipse(tipA, tipB, radius)` | A flat ellipse whose long axis runs between two tips. | +| `IStar` | `CreateStar(center, innerRadius, outerRadius, pointCount)` | A flat star. | +| `ISphere` | `CreateSphere(center, radius)` | Three great circles. | +| `IEllipsoid` | `CreateEllipsoid(tipA, tipB, radius)` | Three ellipses, with a radius along each axis. | +| `ISpikedSphere` | `CreateSpikedSphere(center, baseRadius, spikeLength, spikeCount)` | A 3D star: a Platonic solid with a spike on each face, so 4, 6, 8, 12 or 20 spikes. | +| `ICylinder` | `CreateCylinder(endA, endB, radius)` | Two rings joined by four lines. | +| `ICone` | `CreateCone(tip, baseCenter, radius)` | A base ring joined to the tip by four lines. | +| `ICapsule` | `CreateCapsule(centerA, centerB, radius)` | Two spheres wrapped together, like `Physics.CapsuleCast`; each end can have its own radius. | +| `IStadium` | `CreateStadium(centerA, centerB, radius)` | The flat outline of a capsule. | +| `IFrustum` | `CreateFrustum(endA, endB, radiusA, radiusB, sideCount)` | Two regular polygons joined at every corner. Prisms and regular pyramids are frustums too. | +| `IPyramid` | `CreatePyramid(tip, baseCenter, baseSize)` | A rectangular base joined to a tip, like a camera's view without its near plane. | + +Every shape except lines and polylines also has a Create method on a bone, in its local space, one from a position and a rotation, and one without arguments for a white shape of unit size. Round shapes are drawn like Unity's gizmos, with rings and a few lines, from 3 to 1,024 segments per ring. + +### Edits upload only what they change + +A setter only records the change, and each edited shape is written once per frame however many of its properties changed. Only the changed ranges of the GPU buffers are uploaded, and moving bones uploads nothing but their matrices. Tests check that editing one shape among many uploads that shape alone. + +### Steady frames allocate nothing + +Frames that move bones, edit shapes, hide and show them, or change nothing allocate no managed memory, which tests enforce. Freed space is reused by the next shape of the same size and packed away once it fills half a buffer, and memory past the reserved capacity is given back as shapes are disposed. + +### Containers + +A `WireframeContainer` creates and draws shapes, and disposes them when it is disposed or when the scene it was created in unloads. `WireframeContainerSettings` chooses how, and is serializable, so a script can show it in the Inspector. + +| Setting | Default | What it does | +|---|---|---| +| `Occlusion` | `Hide` | What is drawn of lines that other geometry hides: nothing, everything, or a dimmer line. | +| `UseAlpha` | false | Blends each color by its alpha instead of drawing opaque lines. | +| `Material` | none | Draws with your material instead of the package's. | +| `Layer` | 0 | Layer of the container's GameObjects, for camera culling masks. | +| `PersistAcrossScenes` | false | Keeps the container when other scenes load. | +| `VertexCapacity`, `EdgeCapacity` | 0 | Room reserved up front, so a known load never grows the buffers. | +| `Name` | "Wireframes" | Name of the container's GameObject. | + +Shapes are stored in chunks, meshes of up to 65,535 vertices with 16-bit indices, created as they fill; a shape bigger than a chunk gets a mesh of its own with 32-bit indices. An unreserved chunk that stays empty for 5 seconds is released. + +### Render pipelines and platforms + +1. One shader draws under the Built-in Render Pipeline, URP and HDRP, picked by the active pipeline. +2. Windows, Android and WebGL are tested on devices before each release; macOS, iOS and Linux are built by continuous integration. +3. Without a graphics device, as in server builds, or without a usable shader, shapes keep working and nothing is drawn or uploaded. +4. A custom material works once its shader skins vertices with the package's `Wireframes.hlsl`. +5. The shader carries Unity's stereo rendering macros for XR, which is untested. + +### Occlusion and transparency + +`WireframeOcclusion.Hide` depth-tests lines like any other object, `Show` draws them over everything, and `Fade` draws their hidden parts dimmer, like the Scene view's handles, at the cost of a second draw call. `UseAlpha` blends every color by its alpha, in any occlusion mode. + +### Play Mode, Edit Mode and builds + +Containers and shapes work the same in Edit Mode, drawing in the Scene and Game views. There, a container is never saved into its scene and never marks it as changed; it is disposed before scripts reload and when its scene closes, unless it persists across scenes. Switching between Edit and Play Mode alone never disposes a container. + +### Visibility + +`shape.IsVisible = false` hides a shape without disposing it: it keeps its place, bones and settings, and draws again as it is then once shown. `container.IsVisible = false` hides every shape of a container and costs nothing while it lasts. Neither allocates. + +### Statistics and profiling + +1. `container.Statistics` reports shapes, hidden shapes, vertices, edges, bones, chunks, and CPU and GPU memory, without allocating. +2. Profiler markers named `Wireframes.*` time each step: reading bones, uploading them, writing shapes, uploading meshes, compacting, resizing buffers, and creating and releasing chunks. +3. With the `com.unity.profiling.core` package installed, Profiler counters add up every container's shapes, vertices, edges, bones, chunks and GPU memory. + +### Reliability + +1. A Create call that fails leaves nothing behind. +2. A shape that fails to update is reported once and skipped, and the others keep updating. +3. No exception escapes into Unity's rendering. +4. Warnings and errors start with `[Wireframes]` and are logged once per cause. +5. In the Editor and development builds, calls from other threads throw and positions that aren't finite numbers are reported; release builds skip both checks. +6. Everything works with domain reload disabled. + +### Wireframe camera + +The `WireframeCamera` component draws everything its camera renders as wireframe, like the Scene view's Wireframe draw mode, while other cameras draw normally. It works under every render pipeline, and needs a graphics API with wireframe rendering, which OpenGL ES and WebGL lack. diff --git a/Documentation/MIGRATION.md b/Documentation/MIGRATION.md new file mode 100644 index 0000000..948dba4 --- /dev/null +++ b/Documentation/MIGRATION.md @@ -0,0 +1,47 @@ +# Migrating from 1.x to 2.0 + +Wireframes 2.0 draws shapes through its own shader skinning instead of a `SkinnedMeshRenderer`, and renames the container. Most projects only need the renames; the other changes matter to projects with custom materials, or that depend on when edits are uploaded. Most important first. + +### Renames + +1. **`LineContainer` is now `WireframeContainer`,** and `ILineContainer` is gone. A container's options moved into `WireframeContainerSettings`: + + ```csharp + // 1.x + var wireframes = new LineContainer(material); + + // 2.0 + var wireframes = new WireframeContainer(new WireframeContainerSettings { Material = material }); + ``` + +2. **Resolution members end in `Count`:** `Segments` is now `SegmentCount` and `Sides` is `SideCount`, and the parameters `segments`, `sides` and the star's `points` are now `segmentCount`, `sideCount` and `pointCount`. Only calls that name those arguments change. +3. **Long shapes name both ends:** `LocalEnd` and `WorldEnd` are now `LocalEndB` and `WorldEndB`, next to the new `LocalEndA` and `WorldEndA`. + +Create methods became extension methods, declared in a factory class next to each shape, such as `CircleFactory`, so calls like `container.CreateCircle(...)` stay as they are with `using reromanlee.Wireframes;`. + +### Custom materials + +In 1.x, a `SkinnedMeshRenderer` moved the vertices, so any vertex color shader worked. In 2.0 the shader moves them: a custom material's shader has to call `WireframesSkin` from the package's include file, or shapes are drawn in their bones' local space around the world origin. [USAGE.md](USAGE.md#custom-materials) shows the vertex program. + +The package's material now works under URP and HDRP too, and draws transparent or always-on-top lines through the `UseAlpha` and `Occlusion` settings, so a custom material made only for those reasons can go. + +### When edits show up + +1.x uploaded edits at the end of `LateUpdate`. 2.0 uploads them, and reads the bones, right before each camera renders, so edits made after `LateUpdate` but before rendering now show up in the same frame. Edits made after rendering, such as in `WaitForEndOfFrame`, still show up in the next one. + +### Bones + +1. No component is added to bones anymore. A destroyed bone is noticed when the bones are read, and its shapes stay where they were last drawn, switching to world space the next time they are used. +2. A bone must be a Transform in a scene: a prefab asset throws `ArgumentException`. + +### Limits and checks + +1. Resolutions are capped at 1,024 segments or sides and 512 star points, and larger values throw `ArgumentOutOfRangeException`. +2. Calls from other threads, never supported, now throw `InvalidOperationException` in the Editor and development builds. + +### What changes around the container + +1. A container draws with a `MeshRenderer` per chunk of up to 65,535 vertices, instead of one `SkinnedMeshRenderer`. +2. The **GPU Skinning** player setting no longer matters, and WebGL no longer skins shapes on the CPU. +3. Unity 2022.3 is supported, where 1.x needed Unity 6. +4. Containers also work in Edit Mode, never saved into scenes. diff --git a/Documentation/PERFORMANCE.md b/Documentation/PERFORMANCE.md new file mode 100644 index 0000000..6e5a63a --- /dev/null +++ b/Documentation/PERFORMANCE.md @@ -0,0 +1,81 @@ +# Performance + +What Wireframes costs, measured, and the design behind the numbers. Tips to keep it light are at the end. + +### How it stays fast + +1. **Moving shapes costs one matrix per bone.** Right before rendering, the package reads each bone's local-to-world matrix and uploads all of them in one float texture, and the vertex shader moves every vertex. A bone costs the same whether one shape or a thousand follow it. +2. **Edits cost only what they change.** Each edited shape is written once per frame, however many of its properties changed, and only the changed ranges of the buffers are uploaded. +3. **Steady frames allocate nothing,** which tests enforce for frames that move bones, edit shapes, hide and show them, or change nothing. +4. **Draw calls stay few.** Shapes share chunks, meshes of up to 65,535 vertices, and each chunk is one draw call per camera, or two with `WireframeOcclusion.Fade`. Nothing is created per shape or per bone: no GameObjects, no components. +5. **Work is spread over frames.** Buffers double when full, freed space goes to the next shape of the same size, and a chunk is compacted once freed space fills half of it, one chunk per frame at most. + +### CPU time + +Measured by the package's performance tests in the Editor, on an Intel Core i7-12700K, as the median of 10 frames. Each row is one frame: its edits plus the flush that uploads them. The Editor runs scripts on Mono, slower than IL2CPP player builds. + +| Frame, on 100 bones | Unity 6000.5 | Unity 2022.3 | +|---|---|---| +| Nothing changed, 10,000 lines | 24 µs | 22 µs | +| Every bone moved, 10,000 lines | 78 µs | 95 µs | +| 1,000 scattered line color edits | 0.26 ms | 0.30 ms | +| 100 scattered sphere radius edits | 0.93 ms | 1.07 ms | +| Dispose half of 10,000 lines | 0.49 ms | 0.44 ms | +| Dispose half of 1,000 spheres | 1.46 ms | 1.47 ms | +| Create 10,000 lines and upload them | 11.3 ms | 14.7 ms | +| Create 1,000 spheres, 96,000 vertices, and upload them | 17.9 ms | 19.0 ms | + +Frames that change nothing, move bones or edit shapes allocate nothing. Creating shapes allocates their objects, about 2 per line and 2 per sphere, and disposing thousands of shapes at once allocates a handful of times as internal lists grow. + +### Devices + +The Stress Test sample, 10,000 lines, 1,000 boxes and 2,000 other shapes on 100 orbiting bones, in builds made with Unity 6000.5. Times are whole frames, from Unity's `FrameTimingManager`. + +| Device | Graphics API | Main thread | GPU | +|---|---|---|---| +| Desktop, Intel Core i7-12700K, NVIDIA RTX 4070 Ti | Direct3D 12 | 0.40 ms | 0.46 ms | +| The same desktop, in a Chromium browser | WebGL 2 | 0.49 ms | Not reported | +| Poco X3 NFC, Snapdragon 732G, Adreno 618 | Vulkan | 4.24 ms | 5.64 ms | + +### Compared with skinned meshes + +Up to 1.0, shapes were drawn by a `SkinnedMeshRenderer`. Before 2.0 replaced it with the package's own shader skinning, a prototype compared the two on the same scenes, alternating between them: + +1. **WebGL 2**, where Unity skins meshes on the CPU: frames took 0.6 ms of main thread instead of 1.0 ms at 114,000 vertices, and 0.7 ms instead of 2.2 ms at 454,000. These are rough, from a few samples each. +2. **Windows**: the same CPU time, and 0.10 ms of GPU time instead of 0.15 ms at 114,000 vertices, and 0.21 ms instead of 0.27 ms at 454,000. +3. **Android**, on the Poco X3 NFC with Vulkan: the same GPU time, and 72.9 frames per second instead of 70.3 at 454,000 vertices. + +### Memory + +1. **On the GPU,** a vertex takes 20 bytes, for its position, color and bone index. An edge takes 4 bytes of indices, or 8 in a chunk made for a shape of more than 65,535 vertices, and a bone takes 48 bytes of the bone texture, which holds 256 bones per 12 KB row. +2. **On the CPU,** a vertex takes the same 20 bytes, an edge about 24 bytes with its bookkeeping, and the bone texture as much as on the GPU; shapes and bones add a few bytes each. +3. **Buffers start small,** at 256 vertices and 128 edges per chunk, and double as needed. Memory past the reserved capacity is given back as shapes are disposed, and a chunk that stays empty for 5 seconds is released. + +| Shape, at the default resolution | Vertices | Edges | +|---|---|---| +| Line | 2 | 1 | +| Polyline of n points | n | n - 1, or n when closed | +| Box | 8 | 12 | +| Rectangle | 4 | 4 | +| Rounded rectangle | 36 | 36 | +| Circle, ellipse | 32 | 32 | +| Star with 5 points | 10 | 10 | +| Sphere, ellipsoid | 96 | 96 | +| Spiked sphere with 4, 6 or 8 spikes | 8, 14 or 14 | 18, 36 or 36 | +| Spiked sphere with 12 or 20 spikes | 32 | 90 | +| Cylinder | 64 | 68 | +| Cone | 33 | 36 | +| Capsule | 122 | 132 | +| Stadium | 34 | 34 | +| Frustum with n sides | 2n | 3n | +| Pyramid | 5 | 8 | + +### Tips + +1. **Move bones rather than editing points.** A moving bone uploads its 48 bytes, while an edit rewrites every vertex of its shape. +2. **Reserve capacity for a known load** with `VertexCapacity` and `EdgeCapacity`, so buffers never grow mid-game. +3. **Hide shapes that come back** with `IsVisible` instead of disposing and creating them again. +4. **Lower `segmentCount`** for small or distant round shapes. +5. **Group shapes in few containers.** Each container has its own bone texture and draw calls. +6. **Use `Fade` where it helps,** since it doubles a container's draw calls. +7. **Hide whole containers that are off screen.** Shapes follow arbitrary Transforms, so their meshes have fixed bounds of ±1,000 km and are never frustum-culled. diff --git a/Documentation/USAGE.md b/Documentation/USAGE.md new file mode 100644 index 0000000..217c939 --- /dev/null +++ b/Documentation/USAGE.md @@ -0,0 +1,197 @@ +# Usage + +How to use Wireframes, from the first container to custom shaders. What each part does is in [FEATURES.md](FEATURES.md). + +### Containers + +`new WireframeContainer()` creates a GameObject named **Wireframes** in the active scene, which draws every shape created in the container. + +1. The container lives until you call `Dispose()` or the scene it was created in unloads. Either way, every shape it created is disposed too, and `IsDisposed` becomes true. +2. `new WireframeContainer(settings)` takes a `WireframeContainerSettings`, read once, so changing it later doesn't affect the container. +3. The container never destroys a material passed in its settings. +4. Create containers and shapes on the main thread, from `Awake`, `OnEnable`, `Start` or later, not from constructors or field initializers. In the Editor and development builds, calls from other threads throw `InvalidOperationException`. + +Settings are serializable, so a script can show them in the Inspector: + +```csharp +[SerializeField] private WireframeContainerSettings _settings = new(); + +private void OnEnable() +{ + _wireframes = new WireframeContainer(_settings); +} +``` + +### Creating shapes + +Every shape except lines and polylines has the same kinds of Create methods: + +| Kind | Example | Space | +|---|---|---| +| No arguments | `CreateCylinder()` | A white shape of unit size at the world origin: radius 0.5, sizes 1, length 1. | +| The usual form | `CreateCylinder(endA, endB, radius)` | World space. | +| The same with a bone first | `CreateCylinder(bone, localEndA, localEndB, radius)` | The bone's local space. | +| Position and rotation | `CreateCylinder(position, rotation, length, radius)` | World space. | + +A few shapes add an obvious extra, such as `CreateCircle(center, normal, radius)` or a stadium with an explicit normal. + +Lines and polylines are made of points: they are created from world positions, or from one bone per point, each point sitting at its bone's origin. `CreateLine()` starts with both ends at the world origin. + +### Bones and spaces + +A **bone** is any Transform in a scene that a shape follows; `null` means world space. A prefab asset can't be a bone and throws `ArgumentException`. + +1. Lines and polylines have a bone for every point, because points can go anywhere and still make a line. +2. Every other shape has one bone and moves rigidly with it, like a child Transform (`IRigidShape`). Its position and rotation are relative to that bone, and its sizes are in the bone's units, so it also scales with the bone. +3. `Local*` properties are relative to the bone, or world space without a bone. +4. `World*` properties convert through the bone's current pose, like `Transform.position`. +5. Changing a bone keeps the shape where it is in the world, like reparenting a Transform: points keep their world position, and other shapes also keep their world rotation and size. From then on it moves with the new bone. + +**Rule of thumb: create a shape on its bone when you know the bone, or set the bone first and then the position in whichever space you mean.** + +| You want | Write | +|---|---| +| A line joining two Transforms | `CreateLine(a, b)` | +| A point at an offset from a bone, such as a sword tip | `line.BoneB = sword; line.LocalPositionB = new Vector3(0f, 1f, 0f);` | +| A point pinned where something was hit, moving with it | `line.BoneA = hitTransform; line.WorldPositionA = hit.point;` | +| A shape that stops following | `shape.Bone = null;` (it stays where it is) | + +### A shape's axes + +A shape's rotation turns its own axes: + +1. **+Y is the normal of flat shapes.** Rectangles, circles, ellipses, stars and stadiums lie in their XZ plane, so with no rotation they lie flat on the ground, or flat around their bone. +2. **+Z is the axis of long shapes** (`IAxialShape`: cylinders, cones, capsules, stadiums, frustums and pyramids). They start at end A, at their position, and run `Length` along +Z to end B. Setting one end keeps the other where it is: the axis turns by the smallest rotation and `Length` follows. Setting the position moves the whole shape. +3. Other shapes are centered on their position. +4. `Quaternion.LookRotation(axis, normal)` is the rotation that points a shape's +Z along `axis` with +Y toward `normal`. +5. A shape created from two points turns so that its +Y stays as close to world up, or its bone's up, as it can. A stadium or ellipse created that way lies as flat as it can. +6. Capsule and stadium ends are the centers of their spheres or circles, as in `Physics.CapsuleCast`. When one sphere holds the other, only the bigger one is drawn. + +### Sizes and resolution + +1. Sizes are in the bone's units. Changing a shape's bone multiplies them by the ratio of the two bones' scales, so the shape keeps its size in the world, exactly for uniformly scaled bones. +2. Sizes are used as given: a negative size mirrors the shape, and zero collapses it. A box keeps a signed `Size`, so both of its corners come back exactly as they were set. +3. Round shapes take `segmentCount`, the number of straight pieces a full circle is drawn with: 32 by default, from 3 to 1,024. Cylinders, cones, capsules, stadiums and rounded rectangles need a multiple of 4, because their lines meet their rings at quarter points, and arcs use their share of it. +4. Frustums take a `sideCount` from 3 to 1,024 (4 by default), stars a `pointCount` from 3 to 512 (5 by default), and spiked spheres a `spikeCount` of 4, 6, 8, 12 or 20 (12 by default). +5. Those counts, and the number of points of a polyline, are fixed when a shape is created. To change them, create a new shape. + +### Colors + +1. Lines and polylines have a color per point, blended along the edges. `SetColor` sets them all. +2. Every other shape has one `Color`, which `SetColor` sets too. +3. Colors are stored with 8 bits per channel, so values above 1 are clamped: no HDR glow. +4. In linear color space, the package's shader converts vertex colors so they look the same as that `Color` on a material. +5. Alpha is ignored unless the container's `UseAlpha` setting is on. + +### When edits show up + +Setters only record the change, and each edited shape is queued once, however many of its properties change. Right before a camera renders, the container writes the queued shapes, uploads what changed and reads its bones, so everything edited or moved earlier in the frame shows up in that frame, whether it happened in `Update`, `LateUpdate`, animation or physics. Changes made after rendering, such as in `WaitForEndOfFrame`, show up in the next frame. + +### Visibility + +`shape.IsVisible = false` hides a shape without disposing it. It keeps its place, bones and settings, takes edits while hidden, and draws again as it is then when shown. `container.IsVisible = false` hides every shape of the container, and a hidden container does no per-frame work until it is shown again. Hiding and showing allocate nothing, which makes them the cheap way to toggle shapes that come back. + +### Destroyed bones + +When a bone's GameObject is destroyed, the shapes and points on it stay exactly where they were last drawn, at the same size. The next time you use such a shape, its bone reads as `null` and it continues in world space from there. A move of the bone in the frame it was destroyed, after that frame's render, is lost. + +### Disposing shapes + +`shape.Dispose()` removes a shape and frees its space for the next shape of the same size. After that, `IsDisposed` is true and every other member throws `ObjectDisposedException`. Disposing again does nothing. + +### Edit Mode + +Containers work the same in Edit Mode, drawing in the Scene and Game views, which suits previews from `[ExecuteAlways]` scripts: + +```csharp +[ExecuteAlways] +public sealed class BoundsPreview : MonoBehaviour +{ + private WireframeContainer _wireframes; + + private void OnEnable() + { + _wireframes = new WireframeContainer(); + _wireframes.CreateBox(transform, new Vector3(-0.5f, -0.5f, -0.5f), new Vector3(0.5f, 0.5f, 0.5f)); + } + + private void OnDisable() + { + _wireframes.Dispose(); + } +} +``` + +1. An Edit Mode container is never saved into its scene and never marks it as changed. +2. It is disposed before scripts reload, after which Unity calls `OnEnable` again, and when its scene closes, unless it persists across scenes. +3. Switching between Edit and Play Mode alone never disposes a container, though the script reload that Play Mode starts with by default does. + +### Custom materials + +A custom material draws a container when its shader moves vertices with `WireframesSkin` from the package's include file. Each vertex arrives relative to its bone, with the bone's index in `TEXCOORD0`, and the package sets the `_WireframesBones` texture on every renderer. `WireframesColor` converts vertex colors in linear color space, as the package's shader does. + +```hlsl +#include "Packages/com.reromanlee.wireframes/Runtime/Shaders/Wireframes.hlsl" + +struct Attributes +{ + float3 position : POSITION; + half4 color : COLOR; + float bone : TEXCOORD0; +}; + +struct Varyings +{ + float4 positionCS : SV_POSITION; + half4 color : COLOR; +}; + +Varyings Vertex(Attributes input) +{ + Varyings output; + float3 world = WireframesSkin(input.position, input.bone); + output.positionCS = mul(UNITY_MATRIX_VP, float4(world, 1.0)); + output.color = half4(WireframesColor(input.color.rgb), input.color.a); + return output; +} +``` + +HDRP renders relative to the camera, so there the world position goes through `GetCameraRelativePositionWS` before `TransformWorldToHClip`. The package's own shader, `Runtime/Resources/reromanlee.Wireframes/Unlit.shader`, has complete passes for the Built-in Render Pipeline, URP and HDRP to start from. The `Occlusion` and `UseAlpha` settings apply to the package's material only. + +### Statistics and profiling + +`container.Statistics` returns what a container holds and the memory it uses, without allocating, so it can be read every frame. `ChunkCount` is also the number of draw calls per camera, or twice that with `WireframeOcclusion.Fade`. + +```csharp +_wireframes.CreateLine(_hand, _target); +_wireframes.CreateCircle(_target, Vector3.zero, 1f); +_wireframes.CreateSphere(_target, Vector3.zero, 0.5f).IsVisible = false; + +// Shapes: 3 (1 hidden), vertices: 130, edges: 33, bones: 2, chunks: 1, CPU memory: 25 KB, GPU memory: 17 KB +Debug.Log(_wireframes.Statistics); +``` + +1. In the Profiler, the package's work shows up under markers named `Wireframes.*`, in the Render category. +2. With the `com.unity.profiling.core` package installed, the Profiler counters `Wireframes Shapes`, `Vertices`, `Edges`, `Bones`, `Chunks` and `GPU Memory` add up every container; add them to a module in the Profiler's Module Editor. +3. Warnings and errors start with `[Wireframes]` and are logged once per cause. + +### Wireframe camera + +Add the **WireframeCamera** component to a Camera to draw everything that camera renders as wireframe, like the Scene view's Wireframe draw mode, while other cameras draw normally. It follows the render pipeline if it changes at runtime, though the frame in which the pipeline switches is drawn without wireframe. It uses `GL.wireframe`, which OpenGL ES and WebGL don't support. + +### Samples + +Import them from the package's **Samples** tab in the Package Manager. + +1. **Shape Gallery** lays out every shape in three rows, each turning with its own bone. Open its **ShapeGallery** scene and enter Play Mode. +2. **Stress Test** spawns 10,000 lines, 1,000 boxes and 2,000 other shapes on 100 orbiting bones. Add the `StressTest` component to an empty GameObject in a scene with a camera, and enter Play Mode with the Profiler open. Raise **Recolor Per Frame** to measure the cost of edits. + +### Tests + +To run the package's tests in the Test Runner, add the package to `testables` in `Packages/manifest.json`: + +```json +"testables": ["com.reromanlee.wireframes"] +``` + +The timing tests run when the project also has the `com.unity.test-framework.performance` package, and the Profiler counter test when it has `com.unity.profiling.core`. diff --git a/README.md b/README.md index 2ab4698..3290d60 100644 --- a/README.md +++ b/README.md @@ -1,266 +1,116 @@ -# Wireframes for Unity +### About Wireframes -Wireframe shapes for Unity 6 that follow Transforms at no per-frame CPU cost to your code. +Wireframe shapes for Unity that follow Transforms on the GPU: create a shape once, and it moves with its bones. Every shape, from the Shape Gallery sample -You create a shape once, attach it to any Transforms ("bones"), and it moves with them from then on. The GPU does the moving, through the same skinning Unity uses for animated characters. Every shape in a container is one mesh, drawn in one draw call. +Lines, polylines, boxes, circles, spheres, capsules, cones and more attach to any Transforms, called bones. The package uploads each bone's matrix once per frame and the vertex shader moves every vertex, so moving shapes cost your code nothing and an edit uploads only what it changed. -Stress test running in Play Mode +Shapes draw the same in Play Mode, Edit Mode and player builds, under the Built-in Render Pipeline, URP and HDRP, from desktops and phones to WebGL, while server builds keep them working without drawing. -## Why not `Debug.DrawLine`? +### Features -| | `Debug.DrawLine` / Gizmos | Wireframes | -|---|---|---| -| Moving shapes | Call it again every frame, for every line | Create once; bones move them on the GPU | -| CPU cost per frame | Grows with the number of lines | No work from the package while nothing is edited | -| Player builds | Not drawn | Drawn | +1. Shapes follow Transforms on the GPU. +2. 17 shapes, from lines to capsules. +3. Edits upload only what they change. +4. Steady frames allocate nothing. +5. Built-in Render Pipeline, URP and HDRP. +6. Windows, Android, WebGL and more. +7. Play Mode, Edit Mode and builds. +8. Hidden lines hide, show or fade. +9. Shapes hide without being disposed. +10. Statistics and Profiler markers. +11. Destroyed bones leave shapes in place. +12. A camera that draws in wireframe. -## Requirements +Detailed about features - see [FEATURES.md](Documentation/FEATURES.md). -- Unity 6 (6000.0) or later. -- The default material is an unlit vertex-color shader, written for and tested with the Built-in Render Pipeline. With URP or HDRP, if it doesn't render, pass your own material to the container; any shader that outputs vertex colors works. +### Performance -## Installation +**A frame where nothing changed costs about 25 µs** of CPU time for 10,000 lines on 100 bones, one where every bone moved about 0.1 ms, and one with 1,000 color edits about 0.3 ms, all without allocating. Each mesh of up to 65,535 vertices is one draw call, and a vertex takes 20 bytes on the GPU. -In **Window > Package Manager**, choose **+ > Install package from git URL** and enter: +**The Stress Test sample, 13,000 shapes on 100 moving bones,** runs whole frames in 0.40 ms of main thread and 0.46 ms of GPU time on Windows (RTX 4070 Ti, Direct3D 12), 0.49 ms of main thread in a WebGL 2 build, and 4.24 ms of main thread and 5.64 ms of GPU time on Android (Poco X3 NFC, Vulkan). + +The Stress Test sample in Play Mode + +Detailed about performance - see [PERFORMANCE.md](Documentation/PERFORMANCE.md). + +### Requirements + +1. Unity 2022.3 or later. +2. The Built-in Render Pipeline, URP or HDRP. +3. Shader model 3.5: Direct3D 11 or 12, Vulkan, Metal, OpenGL Core, OpenGL ES 3.0 or WebGL 2. + +### Limitations + +1. Lines are always 1 pixel wide. +2. Colors have 8 bits per channel, so no HDR. +3. A shape has one bone, or lines and polylines one per point. +4. Resolution and point counts are fixed at creation. +5. Rings take up to 1,024 segments. +6. Shapes are never frustum-culled. +7. Shapes are used from the main thread only. +8. Custom materials skin with the package's HLSL. + +### How to install + +**From the git URL:** in **Window > Package Manager**, choose **+ > Install package from git URL** and enter the URL below. Add `#2.0.0` to its end to stay on that version. ``` https://github.com/reromanlee/Wireframes.git?path=/UnityPackage ``` -Or add it to `Packages/manifest.json`: +**From a tarball:** download the `.tgz` file of the [latest release](https://github.com/reromanlee/Wireframes/releases/latest), then in **Window > Package Manager** choose **+ > Install package from tarball** and pick it. -```json -"com.reromanlee.wireframes": "https://github.com/reromanlee/Wireframes.git?path=/UnityPackage" -``` +Upgrading from 1.x - see [MIGRATION.md](Documentation/MIGRATION.md). -The package lives in the repository's `UnityPackage` folder, which the `path` parameter points to. +### How to use -## Quick start +**Create a `WireframeContainer`, then create shapes in it on the bones they follow.** A shape keeps following its bones until you dispose it or its container, with nothing to call per frame. Disposing the container disposes its shapes, and so does unloading the scene it was created in. ```csharp using reromanlee.Wireframes; using UnityEngine; -public class TargetingLine : MonoBehaviour +public sealed class TargetingLine : MonoBehaviour { [SerializeField] private Transform _hand; [SerializeField] private Transform _target; - private LineContainer _wireframes; + private WireframeContainer _wireframes; - private void Start() + private void OnEnable() { - _wireframes = new LineContainer(); + _wireframes = new WireframeContainer(); // A line from the hand to the target that follows both. _wireframes.CreateLine(_hand, _target).SetColor(Color.cyan); - // A box around the target that moves, rotates and scales with it. - _wireframes.CreateBox(_target, new Vector3(-0.5f, -0.5f, -0.5f), new Vector3(0.5f, 0.5f, 0.5f)); - - // A circle of radius 1 lying flat around the target. - _wireframes.CreateCircle(_target, Vector3.zero, 1f).SetColor(Color.yellow); + // A circle of radius 1 lying flat around the target, moving, turning and scaling with it. + _wireframes.CreateCircle(_target, Vector3.zero, 1f).Color = Color.yellow; } - private void OnDestroy() + private void OnDisable() { _wireframes.Dispose(); } } ``` -There is no `Update`: every shape follows `_hand` and `_target` on its own. - -## Shapes - -| Shape | Usual Create method | What it is | -|---|---|---| -| `ILine` | `CreateLine(a, b)` | A segment; each end has its own bone. | -| `IPolyline` | `CreatePolyline(points)`, `CreatePolygon(points)`, `CreateTriangle(a, b, c)` | Points joined in order, open or closed; each point has its own bone and color. | -| `IBox` | `CreateBox(cornerA, cornerB)` | A box with a center, a rotation and a `Size`. | -| `IRectangle` | `CreateRectangle(cornerA, cornerB)` | A flat rectangle. | -| `IRoundedRectangle` | `CreateRoundedRectangle(cornerA, cornerB, cornerRadius)` | A flat rectangle with round corners. | -| `ICircle` | `CreateCircle(center, radius)` | A flat circle; `CreateCircle(center, normal, radius)` faces a direction. | -| `IEllipse` | `CreateEllipse(tipA, tipB, radius)` | A flat ellipse whose long axis runs between two tips. | -| `IStar` | `CreateStar(center, innerRadius, outerRadius, points)` | A flat star. | -| `ISphere` | `CreateSphere(center, radius)` | Three great circles. | -| `IEllipsoid` | `CreateEllipsoid(tipA, tipB, radius)` | Three ellipses, with a radius along each axis. | -| `ISpikedSphere` | `CreateSpikedSphere(center, baseRadius, spikeLength, spikeCount)` | A 3D star: a Platonic solid with a spike on each face, so 4, 6, 8, 12 or 20 spikes. | -| `ICylinder` | `CreateCylinder(endA, endB, radius)` | Two rings joined by four lines. | -| `ICone` | `CreateCone(tip, baseCenter, radius)` | A base ring joined to the tip by four lines. | -| `ICapsule` | `CreateCapsule(centerA, centerB, radius)` | Two spheres wrapped together, like `Physics.CapsuleCast`; each end can have its own radius. | -| `IStadium` | `CreateStadium(centerA, centerB, radius)` | The flat outline of a capsule. | -| `IFrustum` | `CreateFrustum(endA, endB, radiusA, radiusB, sides)` | Two regular polygons joined at every corner. Prisms and regular pyramids are frustums too. | -| `IPyramid` | `CreatePyramid(tip, baseCenter, baseSize)` | A rectangular base joined to a tip, like a camera's view without its near plane. | - -## Concepts - -### Container - -`new LineContainer()` creates a GameObject named **Wireframes** in the active scene, with one skinned mesh for all of its shapes. - -- It lives until you call `Dispose()` or its scene unloads. Either way, every shape it created is disposed too, and `IsDisposed` becomes true. -- `new LineContainer(material)` draws with your material instead. The container never destroys a material you pass in. -- Create containers and shapes on the main thread, from `Awake`, `Start` or later, not from constructors or field initializers. - -### Bones and spaces - -A **bone** is any Transform a shape follows. `null` means world space. - -- Lines and polylines have a bone for every point, because points can go anywhere and still make a line. -- Every other shape has one bone and moves rigidly with it, like a child Transform (`IRigidShape`). It has a position and a rotation relative to that bone, and its sizes are in the bone's units, so it also scales with the bone. -- `Local*` properties are relative to the bone. Without a bone they are world space. -- `World*` properties convert through the bone's current pose, like `Transform.position`. -- Changing a bone keeps the shape where it is in the world, like reparenting a Transform: points keep their world position, and other shapes also keep their world rotation and size. From then on it moves with the new bone. - -**Rule of thumb: create a shape on its bone when you know the bone, or set the bone first and then the position in whichever space you mean.** - -| You want | Write | -|---|---| -| A line joining two transforms | `CreateLine(a, b)` | -| A point at an offset from a bone, e.g. a sword tip | `line.BoneB = sword; line.LocalPositionB = new Vector3(0f, 1f, 0f);` | -| A point pinned where something was hit, moving with it | `line.BoneA = hitTransform; line.WorldPositionA = hit.point;` | -| A shape that stops following | `shape.Bone = null;` (it stays where it is) | - -### Create methods - -Every shape except lines and polylines has the same kinds of Create methods: - -| Kind | Example | Space | -|---|---|---| -| No arguments | `CreateCylinder()` | A white shape of unit size at the world origin: radius 0.5, sizes 1, length 1. | -| The usual form | `CreateCylinder(endA, endB, radius)` | World space. | -| The same with a bone first | `CreateCylinder(bone, localEndA, localEndB, radius)` | The bone's local space. | -| Position and rotation | `CreateCylinder(position, rotation, length, radius)` | World space. | - -A few shapes add an obvious extra, such as `CreateCircle(center, normal, radius)` or a stadium with an explicit normal. - -Lines and polylines are made of points: they are created from world positions, or from one bone per point, each point sitting at its bone's origin. `CreateLine()` starts with both ends at the world origin. - -### A shape's axes - -A shape's rotation turns its own axes: - -- **+Y is the normal of flat shapes.** Rectangles, circles, ellipses, stars and stadiums lie in their XZ plane, so with no rotation they lie flat on the ground, or flat around their bone. -- **+Z is the axis of long shapes** (`IAxialShape`: cylinders, cones, capsules, stadiums, frustums and pyramids). They start at end A, at their position, and run `Length` along +Z to end B. Setting `LocalEnd` or `WorldEnd` moves end B: the axis turns by the smallest rotation and `Length` follows. -- Other shapes are centered on their position. -- `Quaternion.LookRotation(axis, normal)` is the rotation that points a shape's +Z along `axis` with +Y toward `normal`. -- A shape created from two points turns so that its +Y stays as close to world up, or its bone's up, as it can. A stadium or ellipse created that way lies as flat as it can. -- Capsule and stadium ends are the centers of their spheres or circles, as in `Physics.CapsuleCast`. When one sphere holds the other, only the bigger one is drawn. +**Settings choose how a container draws, and properties change a shape at any time.** Every edit shows up in the next render, however many properties change, and hiding a shape keeps it for later. -### Sizes and resolution - -- Sizes are in the bone's units. Changing a shape's bone multiplies them by the ratio of the two bones' scales, so the shape keeps its size in the world. That is exact for uniformly scaled bones. -- Sizes are used as given: a negative size mirrors the shape, and zero collapses it. A box keeps a signed `Size`, so both of its corners come back exactly as they were set. -- Round shapes take `segments`, the number of straight pieces a full circle is drawn with: 32 by default, at least 3. Arcs use their share: capsule caps and stadium ends get half, rounded rectangle corners a quarter. Cylinders, cones, capsules, stadiums and rounded rectangles need a multiple of 4, because their lines meet their rings at quarter points. -- Round shapes are drawn like Unity's gizmos, with rings and a few lines: a sphere is three circles and a cylinder is two rings joined by four lines. Shapes with corners draw every edge. -- The number of segments, sides, star points and spikes, and the number of points of a polyline, are fixed when a shape is created. To change them, create a new shape. - -### Colors - -- Lines and polylines have a color per point, blended along the edges. `SetColor` sets them all. -- Every other shape has one `Color`. `SetColor` sets it too. -- Colors are stored with 8 bits per channel, so values above 1 are clamped: no HDR glow. -- In linear color space (the Unity 6 default), the default shader converts vertex colors so they look the same as that `Color` on a material. -- The default material is opaque, so alpha has no effect. - -### When edits show up - -Setters only record the change. Each edited shape is queued once per frame, however many properties you set. The queue is uploaded at the end of `LateUpdate`, after scripts with the default execution order and before Unity renders, so edits appear in the same frame. Edits made later in the frame, such as in `WaitForEndOfFrame` or camera callbacks, appear in the next one. - -### Destroyed bones - -When a bone's GameObject is destroyed, the shapes and points attached to it stay exactly where they were, at the same size, and switch to world space: their bone becomes `null`. The shapes themselves stay alive. - -This works through a small hidden component that the package adds to every GameObject used as a bone and removes when no shape uses it anymore. If a bone's GameObject is destroyed without ever having been active, Unity doesn't notify that component. The package still notices, but the bone's last pose is lost, so its shapes use their local values as world ones and a warning is logged. - -### Disposing shapes - -`shape.Dispose()` removes a shape and frees its space for the next shape with the same number of vertices. After that, `IsDisposed` is true and every other member throws `ObjectDisposedException`. - -## Wireframe camera - -Add the **WireframeCamera** component to a Camera to draw everything that camera renders as wireframe, like the Scene view's Wireframe draw mode, while other cameras draw normally. - -- It works under the Built-in Render Pipeline and any Scriptable Render Pipeline (URP, HDRP or custom), and follows the pipeline if it changes at runtime. The frame in which the pipeline switches is drawn without wireframe. -- It uses `GL.wireframe`, so the graphics API must support wireframe rendering; OpenGL ES and WebGL don't. -- The package's own shapes are lines already, so they look the same through it. - -## Performance - -- **One mesh, one draw call and one skinned renderer per container**, however many shapes and shape types it holds. -- **Moving bones costs the package nothing.** Unity computes one matrix per bone and skins the vertices on the GPU. -- **Edits cost only what changed.** Each edited shape is written once per frame, and only the changed parts of the GPU buffers are uploaded. Editing a shape rewrites all of its vertices, so a big sphere costs more to edit than a line. -- **Adding and removing shapes is cheap on average.** Buffers double in size when full and never shrink. Freed space is reused by the next shape with the same number of vertices, and it is packed away once it fills half the buffer. -- **Memory:** 20 bytes per vertex on the GPU (position, color, bone index), plus a CPU copy, and 2 indices per edge. Indices start 16-bit and switch to 32-bit automatically once the vertex buffer outgrows them. - -| Shape, with the default resolution | Vertices | Edges | -|---|---|---| -| Line | 2 | 1 | -| Polyline of n points | n | n - 1, or n when closed | -| Box | 8 | 12 | -| Rectangle | 4 | 4 | -| Rounded rectangle | 36 | 36 | -| Circle, ellipse | 32 | 32 | -| Star with 5 points | 10 | 10 | -| Sphere, ellipsoid | 96 | 96 | -| Spiked sphere with 4, 6 or 8 spikes | 8, 14 or 14 | 18, 36 or 36 | -| Spiked sphere with 12 or 20 spikes | 32 | 90 | -| Cylinder | 64 | 68 | -| Cone | 33 | 36 | -| Capsule | 122 | 132 | -| Stadium | 34 | 34 | -| Frustum with n sides | 2n | 3n | -| Pyramid | 5 | 8 | - -Measured by the package's performance tests in the Editor on a desktop PC (Unity 6000.5, Direct3D 12), on 100 bones: - -| Operation | 10,000 lines | 1,000 spheres (96,000 vertices) | -|---|---|---| -| Create the shapes on their bones | 4.6 ms | 3.4 ms | -| First upload | 1.9 ms | 6.8 ms | -| A frame where nothing changed | 0.1 µs | 0.1 µs | -| A frame where all 100 bones moved | 2.3 µs | 0.2 µs | -| Scattered edits: 1,000 line colors, 100 sphere radii | 0.1 ms, plus 0.3 ms upload | 0.01 ms, plus 0.7 ms upload | -| Dispose half of the shapes | 0.4 ms, plus 0.6 ms upload | 1.4 ms, plus 6.4 ms upload | - -Two things to keep in mind: - -- **GPU skinning must be on.** If a project disables it in Player Settings, or the platform has no compute shaders (for example WebGL), Unity skins on the CPU every frame, at a cost that grows with the vertex count. -- **Shapes are never frustum-culled.** Shapes follow arbitrary Transforms, so the mesh uses fixed bounds of ±1,000 km around the world origin instead of recomputing them every frame. - -## Limitations - -- Lines are always 1 pixel wide. -- The default material is opaque and depth-tested. For transparency or always-on-top lines, pass your own material. -- Main thread only. Shapes are drawn in Play mode and in builds, not in Edit mode. -- One bone per shape, except for lines and polylines, which have one per point. -- A shape's resolution and a polyline's number of points can't change after it is created. - -## Samples - -Import them from the package's **Samples** tab in the Package Manager. - -**Shape Gallery** lays out every shape in three rows, each turning with its own bone. Open its **ShapeGallery** scene and enter Play mode: the camera frames the rows against a dim background. - -**Stress Test** spawns 10,000 lines, 1,000 boxes and 2,000 other shapes on 100 orbiting bones. Add the `StressTest` component to an empty GameObject in a scene with a camera, and enter Play mode with the Profiler open. Raise **Recolor Per Frame** to measure the cost of edits. - -## Tests - -To run the package's tests in the Test Runner, add the package to `testables` in `Packages/manifest.json`: +```csharp +_wireframes = new WireframeContainer(new WireframeContainerSettings { Occlusion = WireframeOcclusion.Fade }); -```json -"testables": ["com.reromanlee.wireframes"] +ISphere sphere = _wireframes.CreateSphere(_target, Vector3.zero, 0.5f); +sphere.Radius = 2f; +sphere.IsVisible = false; ``` -## Roadmap +Detailed about usage - see [USAGE.md](Documentation/USAGE.md). -- Arrows, camera frustums and more shapes, all drawn by the same mesh. -- Text drawn with lines. -- Built-in transparent and always-on-top materials. +### License -## License +Made by Roman Likhadievski ([reromanlee.com](https://reromanlee.com)). -[MIT](LICENSE.md) +Licensed under MIT - see [LICENSE.md](LICENSE.md). diff --git a/UnityPackage/Runtime/Container/WireframeStatistics.cs b/UnityPackage/Runtime/Container/WireframeStatistics.cs index 1b3b2e4..68ab944 100644 --- a/UnityPackage/Runtime/Container/WireframeStatistics.cs +++ b/UnityPackage/Runtime/Container/WireframeStatistics.cs @@ -55,8 +55,9 @@ internal WireframeStatistics( public override string ToString() { - return $"{ShapeCount} shapes ({HiddenShapeCount} hidden), {VertexCount} vertices, {EdgeCount} edges, " + - $"{BoneCount} bones, {ChunkCount} chunks, {CpuMemory / 1024} KB CPU, {GpuMemory / 1024} KB GPU"; + return $"Shapes: {ShapeCount} ({HiddenShapeCount} hidden), vertices: {VertexCount}, edges: {EdgeCount}, " + + $"bones: {BoneCount}, chunks: {ChunkCount}, CPU memory: {CpuMemory / 1024} KB, " + + $"GPU memory: {GpuMemory / 1024} KB"; } } } diff --git a/UnityPackage/Samples~/ShapeGallery/ShapeGallery.unity b/UnityPackage/Samples~/ShapeGallery/ShapeGallery.unity index fc2b27e..088f9c8 100644 --- a/UnityPackage/Samples~/ShapeGallery/ShapeGallery.unity +++ b/UnityPackage/Samples~/ShapeGallery/ShapeGallery.unity @@ -13,7 +13,7 @@ OcclusionCullingSettings: --- !u!104 &2 RenderSettings: m_ObjectHideFlags: 0 - serializedVersion: 10 + serializedVersion: 9 m_Fog: 0 m_FogColor: {r: 0.5, g: 0.5, b: 0.5, a: 1} m_FogMode: 3 @@ -42,8 +42,8 @@ RenderSettings: --- !u!157 &3 LightmapSettings: m_ObjectHideFlags: 0 - serializedVersion: 13 - m_BakeOnSceneLoad: 0 + serializedVersion: 12 + m_GIWorkflowMode: 1 m_GISettings: serializedVersion: 2 m_BounceScale: 1 @@ -66,6 +66,9 @@ LightmapSettings: m_LightmapParameters: {fileID: 0} m_LightmapsBakeMode: 1 m_TextureCompression: 1 + m_FinalGather: 0 + m_FinalGatherFiltering: 1 + m_FinalGatherRayCount: 256 m_ReflectionCompression: 2 m_MixedBakeMode: 2 m_BakeBackend: 2 diff --git a/UnityPackage/package.json b/UnityPackage/package.json index f673ec1..acbe795 100644 --- a/UnityPackage/package.json +++ b/UnityPackage/package.json @@ -1,9 +1,9 @@ { "name": "com.reromanlee.wireframes", - "version": "1.0.0", + "version": "2.0.0", "displayName": "Wireframes", - "description": "Wireframe shapes that follow Transforms through GPU skinning: lines, polylines, boxes, circles, spheres, capsules, cones and more. Shapes are created once and cost no per-frame CPU work to animate, and every shape in a container is drawn with a single mesh.", - "unity": "6000.0", + "description": "Wireframe shapes that follow Transforms through GPU skinning: lines, polylines, boxes, circles, spheres, capsules, cones and more. Create a shape once and it moves with its bones on the GPU, with no per-frame work from your code, in Play and Edit Mode, under the Built-in Render Pipeline, URP and HDRP.", + "unity": "2022.3", "license": "MIT", "documentationUrl": "https://github.com/reromanlee/Wireframes#readme", "changelogUrl": "https://github.com/reromanlee/Wireframes/blob/main/CHANGELOG.md", From 65b74c0ba9b9ac929181ee0bf6e72515ceede8a7 Mon Sep 17 00:00:00 2001 From: Roman Likhadievski Date: Tue, 29 Sep 2026 23:06:25 +0300 Subject: [PATCH 12/13] fix: ci coverage flag --- .github/workflows/continuous-integration.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index 66d7344..e7b92ae 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -80,13 +80,15 @@ jobs: - name: Run the edit and play mode tests uses: game-ci/unity-test-runner@32e57712352b500e17974b245a6dce9e11a73213 # v4.4.0 + env: + # Code coverage crashes play mode tests on Unity 6, see game-ci/unity-test-runner#301. The action's + # coverageEnabled input passes --no-coverageEnabled, which the CLI rejects, so it's turned off here instead. + GAME_CI_COVERAGE_ENABLED: false with: unityVersion: ${{ matrix.unityVersion }} cliVersion: v0.1.69 projectPath: UnityProject testMode: all - # Code coverage crashes play mode tests on Unity 6, see game-ci/unity-test-runner#301. - coverageEnabled: false githubToken: ${{ github.token }} checkName: Test results on ${{ matrix.unityVersion }} From 8a36e5d8703d38047fecaecf162e76f5b5208283 Mon Sep 17 00:00:00 2001 From: Roman Likhadievski Date: Tue, 29 Sep 2026 23:17:33 +0300 Subject: [PATCH 13/13] fix: ci build packages --- .github/workflows/continuous-integration.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index e7b92ae..7eb3b23 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -143,6 +143,13 @@ jobs: mkdir -p UnityProject/ProjectSettings printf 'm_EditorVersion: %s\n' "$UNITY_VERSION" > UnityProject/ProjectSettings/ProjectVersion.txt + # Leaves only built-in modules and the local package, so a build never waits on Unity's package registry. + - name: Remove the test-only packages + run: | + manifest=UnityProject/Packages/manifest.json + jq 'del(.dependencies["com.unity.test-framework.performance"]) | del(.testables)' "$manifest" > manifest.tmp + mv manifest.tmp "$manifest" + - name: Add the package samples to the project run: cp -r UnityPackage/Samples~ UnityProject/Assets/Samples