diff --git a/Components/Components/Decals/ChiselDecalComponent.cs b/Components/Components/Decals/ChiselDecalComponent.cs index 866f630e..ed45ba80 100644 --- a/Components/Components/Decals/ChiselDecalComponent.cs +++ b/Components/Components/Decals/ChiselDecalComponent.cs @@ -328,7 +328,18 @@ void OnDrawGizmosSelected() void DrawVolume() { Gizmos.color = kGizmoColor; - Gizmos.matrix = transform.localToWorldMatrix; + if (settings.projection == ChiselDecalProjection.Omni) + { + // how far it reaches; the image lies on each surface it reaches + Gizmos.matrix = transform.localToWorldMatrix; + var reach = math.abs(settings.size.z); + if (settings.reach == ChiselDecalReach.Cube) + Gizmos.DrawWireCube(settings.center, new Vector3(reach, reach, reach)); + else + Gizmos.DrawWireSphere(settings.center, reach * 0.5f); + return; + } + Gizmos.matrix = transform.localToWorldMatrix * (Matrix4x4)ChiselDecalSettings.SkewMatrix(settings.skew); GetCorners(s_Corners); for (int i = 0; i < 4; i++) { diff --git a/Components/Components/Decals/ChiselDecalFades.cs b/Components/Components/Decals/ChiselDecalFades.cs new file mode 100644 index 00000000..f39a4adb --- /dev/null +++ b/Components/Components/Decals/ChiselDecalFades.cs @@ -0,0 +1,63 @@ +using System.Collections.Generic; +using Chisel.Core; +using Unity.Mathematics; +using UnityEngine; + +namespace Chisel.Components +{ + /// + /// Transparent decals that fade with distance (). Each one is a mesh + /// of its own (), and its renderer is cross-faded by a LODGroup: whole + /// from the camera up to the start distance, gone past the end distance. A LODGroup goes by the size the renderer + /// takes up on screen, so the distances hold at and scale with the camera's field of view. + /// The material's shader has to support LOD cross-fading, as URP's Lit does, with LOD Cross Fade on in the pipeline asset. + /// + public static class ChiselDecalFades + { + public const float kFieldOfView = 60.0f; + + // the fade distances of the fading decals, by the chunk of their mesh + static readonly Dictionary s_Distances = new(); + + internal static void Register(in ChiselDecalInstance decal) + { + if (decal.settings.Fades) + s_Distances[ChiselDecalMeshes.FadeChunkOf(decal.entityID)] = decal.settings.fadeDistance; + } + + public static bool TryGetDistances(uint chunk, out float2 distances) => s_Distances.TryGetValue(chunk, out distances); + + /// The height on screen, relative to the screen's, at which something this big this far away is + public static float ScreenHeightAt(float size, float distance) + { + return size / (2.0f * math.max(distance, 1e-4f) * math.tan(math.radians(kFieldOfView) * 0.5f)); + } + + internal static void Apply(ChiselRenderObjects renderable) + { + if (renderable == null || renderable.surfaceParameter != ChiselDecalMeshes.kTransparentDecals || + !s_Distances.TryGetValue(renderable.chunk, out var distances) || !renderable.HasGeometry || !renderable.meshRenderer) + return; + + var bounds = renderable.sharedMesh.bounds; + var scale = renderable.container.transform.lossyScale; + var size = math.max(math.cmax(bounds.size), 1e-4f); + var world = size * math.cmax(math.abs((float3)scale)); + var end = math.max(distances.y, 1e-3f); + var start = math.clamp(distances.x, 0, end); + var gone = math.min(ScreenHeightAt(world, end), 0.999f); + var whole = math.min(ScreenHeightAt(world, start), 1.0f); + // the cross-fade runs over this part of the way from the LOD's height up to the whole screen + var width = gone < 1 ? math.saturate((whole - gone) / (1 - gone)) : 0; + + var group = renderable.container.GetComponent(); + if (group == null) + group = renderable.container.AddComponent(); + group.fadeMode = LODFadeMode.CrossFade; + group.animateCrossFading = false; + group.localReferencePoint = bounds.center; + group.size = size; + group.SetLODs(new[] { new LOD(gone, new Renderer[] { renderable.meshRenderer }) { fadeTransitionWidth = width } }); + } + } +} diff --git a/Components/Components/Decals/ChiselDecalFades.cs.meta b/Components/Components/Decals/ChiselDecalFades.cs.meta new file mode 100644 index 00000000..cca47bd0 --- /dev/null +++ b/Components/Components/Decals/ChiselDecalFades.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 26ad3441676e6f14c8a524d35a35ee8c \ No newline at end of file diff --git a/Components/Components/Generated/ChiselGeneratedObjects.cs b/Components/Components/Generated/ChiselGeneratedObjects.cs index 700fe16e..b1c46737 100644 --- a/Components/Components/Generated/ChiselGeneratedObjects.cs +++ b/Components/Components/Generated/ChiselGeneratedObjects.cs @@ -817,6 +817,12 @@ public int FinishMeshUpdates(ChiselModelComponent model, changedColliders[i].sharedMesh.RecalculateBounds(); Profiler.EndSample(); + // a fading decal's renderer is cross-faded by a LODGroup, sized to its mesh + Profiler.BeginSample("DecalFades"); + foreach (var renderable in renderables) + ChiselDecalFades.Apply(renderable); + Profiler.EndSample(); + Profiler.BeginSample("Schedule Collider bake"); if (changedColliders.Length > 0) ChiselColliderObjects.ScheduleColliderBake(model, changedColliders); diff --git a/Components/Managers/ChiselDecalManager.cs b/Components/Managers/ChiselDecalManager.cs index 16165a5d..a5eb8ab1 100644 --- a/Components/Managers/ChiselDecalManager.cs +++ b/Components/Managers/ChiselDecalManager.cs @@ -299,7 +299,10 @@ public static void Update() try { for (int i = 0; i < state.decals.Count; i++) + { decals[i] = state.decals[i]; + ChiselDecalFades.Register(state.decals[i]); + } for (int i = 0; i < state.targets.Count; i++) targets[i] = state.targets[i]; state.tree.SetDecals(decals, targets); @@ -458,7 +461,8 @@ static void Describe(ChiselDecalComponent decal, DecalState decalState, Matrix4x renderMaterial = decalState.renderMaterial, destinationFlags = decalState.destinationFlags }; - var perspective = settings.projection == ChiselDecalProjection.Perspective; + // a perspective or omni-directional decal's image isn't a box to cut into tiles: it is drawn once + var perspective = settings.projection != ChiselDecalProjection.Orthographic; if (!sheet || perspective) { // uv = offset + scale * (rect.position + rect.size * repeat * p) diff --git a/Components/Tests/ChiselDecalComponentTests.cs b/Components/Tests/ChiselDecalComponentTests.cs index 89370444..6bdd48a7 100644 --- a/Components/Tests/ChiselDecalComponentTests.cs +++ b/Components/Tests/ChiselDecalComponentTests.cs @@ -304,6 +304,120 @@ public void Decal_IsDrawnOnTheFloors() Assert.That(other, Is.EqualTo(1.0).Within(kTolerance), "the opaque decal replaces a square metre of the two floors"); } + // Skewed 45 degrees, the square metre leans into a parallelogram twice as wide, of the same area + [Test] + public void ASkewedDecal_IsAParallelogramOnTheFloors() + { + var model = Floors(out _, out _, out var decal); + var settings = decal.Settings; + settings.skew = new Unity.Mathematics.float2(45, 0); + decal.Settings = settings; + Update(); + Assert.That(UpAreas(model).decal, Is.EqualTo(1.0).Within(kTolerance)); + float minX = float.PositiveInfinity, maxX = float.NegativeInfinity; + foreach (var r in model.generated.renderables) + { + if (r == null || !r.Valid || r.sharedMesh == null || r.renderMaterials == null) + continue; + var vertices = r.sharedMesh.vertices; + for (int s = 0; s < r.sharedMesh.subMeshCount && s < r.renderMaterials.Length; s++) + { + if (r.renderMaterials[s] != decalMaterial) + continue; + foreach (var index in r.sharedMesh.GetTriangles(s)) + { + minX = Mathf.Min(minX, vertices[index].x); + maxX = Mathf.Max(maxX, vertices[index].x); + } + } + } + Assert.That(minX, Is.EqualTo(-1).Within(kTolerance)); + Assert.That(maxX, Is.EqualTo(1).Within(kTolerance)); + } + + // The area drawn with the decal's material on triangles facing this way + double DecalAreaFacing(ChiselModelComponent model, Vector3 direction) + { + double area = 0; + foreach (var r in model.generated.renderables) + { + if (r == null || !r.Valid || r.sharedMesh == null || r.renderMaterials == null) + continue; + var vertices = r.sharedMesh.vertices; + for (int s = 0; s < r.sharedMesh.subMeshCount && s < r.renderMaterials.Length; s++) + { + if (r.renderMaterials[s] != decalMaterial) + continue; + var triangles = r.sharedMesh.GetTriangles(s); + for (int t = 0; t < triangles.Length; t += 3) + { + var normal = Vector3.Cross(vertices[triangles[t + 1]] - vertices[triangles[t]], vertices[triangles[t + 2]] - vertices[triangles[t]]); + if (Vector3.Dot(normal.normalized, direction) > 0.99f) + area += normal.magnitude * 0.5; + } + } + } + return area; + } + + // An omni-directional decal reaching half a metre: just above the floors, its whole image is on them; raised out of + // their reach, it is on the wall beside it instead + [Test] + public void AnOmniDecal_IsItsWholeImageOnEverySurfaceItReaches() + { + var model = Floors(out _, out _, out var decal); + var settings = decal.Settings; + settings.projection = ChiselDecalProjection.Omni; + decal.Settings = settings; + decal.Size = new Vector3(0.5f, 0.5f, 1); + decal.transform.localPosition = new Vector3(0, 0.1f, 0); + Update(); + Assert.That(DecalAreaFacing(model, Vector3.up), Is.EqualTo(0.25).Within(kTolerance), "on both floors, round the point under it"); + + Box("Wall", model.transform, new Vector3(0.3f, 0, -0.5f)); + decal.transform.localPosition = new Vector3(0, 0.6f, 0); + Update(); + Assert.That(DecalAreaFacing(model, Vector3.up), Is.EqualTo(0).Within(kTolerance), "the floors are out of reach"); + Assert.That(DecalAreaFacing(model, Vector3.left), Is.EqualTo(0.25).Within(kTolerance), "on the wall, 0.3 away"); + } + + // A transparent decal that fades is a renderer of its own, cross-faded by a LODGroup between its distances; one that + // doesn't fade stays in the decals' mesh, and an opaque decal never fades + [Test] + public void AFadingDecal_IsCrossFadedByALODGroup() + { + // both on B, in one of the decals' 32 m cells (x = 0 is the edge of one) + var model = Floors(out _, out _, out var decal); + decal.Transparent = true; + decal.transform.localPosition = new Vector3(0.5f, 0, 0); + var settings = decal.Settings; + settings.fadeDistance = new Unity.Mathematics.float2(5, 10); + decal.Settings = settings; + var still = Object.Instantiate(decal.gameObject, decal.transform.parent).GetComponent(); + still.transform.localPosition = new Vector3(0.6f, 0, 0); + var stillSettings = still.Settings; + stillSettings.fadeDistance = Unity.Mathematics.float2.zero; + still.Settings = stillSettings; + Update(); + + var decalMeshes = System.Array.FindAll(model.generated.renderables, r => r != null && r.surfaceParameter == ChiselDecalMeshes.kTransparentDecals); + Assert.That(decalMeshes.Length, Is.EqualTo(2), "the fading decal's own, and the decals' mesh"); + var fading = System.Array.Find(decalMeshes, r => r.container.GetComponent() != null); + Assert.That(fading, Is.Not.Null); + var lods = fading.container.GetComponent().GetLODs(); + Assert.That(lods.Length, Is.EqualTo(1)); + Assert.That(lods[0].renderers, Is.EqualTo(new Renderer[] { fading.meshRenderer })); + var size = fading.sharedMesh.bounds.size; + var expected = ChiselDecalFades.ScreenHeightAt(Mathf.Max(size.x, Mathf.Max(size.y, size.z)), 10); + Assert.That(lods[0].screenRelativeTransitionHeight, Is.EqualTo(expected).Within(1e-5f), "gone at 10"); + Assert.That(lods[0].fadeTransitionWidth, Is.GreaterThan(0).And.LessThan(1), "fading from 5"); + + decal.Transparent = false; + Update(); + Assert.That(System.Array.FindAll(model.generated.renderables, r => r != null && r.container.GetComponent() != null), Is.Empty, + "an opaque decal doesn't fade"); + } + // Nothing tells the manager about a moved transform: it has to see it [Test] public void MovedDecal_MovesOnTheFloors() diff --git a/Core/1.Input/Decals/ChiselDecal.cs b/Core/1.Input/Decals/ChiselDecal.cs index 1e1e5ba6..3cdc6613 100644 --- a/Core/1.Input/Decals/ChiselDecal.cs +++ b/Core/1.Input/Decals/ChiselDecal.cs @@ -12,7 +12,22 @@ public enum ChiselDecalProjection : byte /// From a point behind the box, like a projector. The image has the decal's size at the center /// of the box, and grows with depth. - Perspective = 1 + Perspective = 1, + + /// From the center out in every direction, like Source's infodecal: every surface the decal reaches + /// takes the whole image, laid flat on it round the point nearest the center, turned with the surface's + /// texture. + Omni = 2 + } + + /// Which surfaces an omni-directional decal reaches: those passing within the decal's reach of its center. + public enum ChiselDecalReach : byte + { + /// Within a sphere: a surface no further than the reach from the center. + Sphere = 0, + + /// Within a cube turned with the decal: a surface passing through it. + Cube = 1 } /// @@ -26,16 +41,21 @@ public struct ChiselDecalSettings : IEquatable public const float kMinFieldOfView = 0.1f; public const float kMaxFieldOfView = 170.0f; public const float kDefaultSurfaceOffset = 0.001f; + public const float kMaxSkew = 80.0f; /// The center of the box. public float3 center; /// The size of the box. X and Y are the image's width and height (for a perspective decal: at the - /// center of the box), Z is how deep the decal reaches. + /// center of the box), Z is how deep the decal reaches (for an omni-directional decal: the diameter of its + /// sphere, or its cube's edge). public float3 size; public ChiselDecalProjection projection; + /// The shape an omni-directional decal reaches in. + public ChiselDecalReach reach; + /// The vertical field of view of a perspective decal, in degrees. public float fieldOfView; @@ -65,6 +85,27 @@ public struct ChiselDecalSettings : IEquatable /// square ends. A trim decal's pieces are cut this way where they meet round a corner. public float2 endSlant; + /// Where a transparent decal fades out, by its distance from the camera: x where it starts to fade, y where + /// it is gone (at a 60 degree field of view). Each fading decal is a renderer of its own, cross-faded by a + /// LODGroup. A y of 0 never fades; opaque decals never fade, since they replace the surface below. + public float2 fadeDistance; + + public readonly bool Fades => transparent && fadeDistance.y > 0; + + /// How far the decal is sheared, in degrees, so its image frame is a parallelogram: x leans the image's + /// sides (x moves with y), y leans its top and bottom (y moves with x). The shear is about the decal's origin. + public float2 skew; + + /// The shear describes, from the decal's unsheared space to its space + public static float4x4 SkewMatrix(float2 skew) + { + var lean = math.tan(math.radians(math.clamp(skew, -kMaxSkew, kMaxSkew))); + return new float4x4(1, lean.x, 0, 0, + lean.y, 1, 0, 0, + 0, 0, 1, 0, + 0, 0, 0, 1); + } + /// Where in the image a point p of the decal (0 to 1 across it) looks, for an image turned this many quarter /// turns counter-clockwise public static float2 TurnImage(float2 p, int quarterTurns) @@ -106,6 +147,8 @@ public readonly bool Equals(ChiselDecalSettings other) return math.all(center == other.center) && math.all(size == other.size) && projection == other.projection && + reach == other.reach && + math.all(fadeDistance == other.fadeDistance) && fieldOfView == other.fieldOfView && maxAngle == other.maxAngle && transparent == other.transparent && @@ -113,7 +156,8 @@ public readonly bool Equals(ChiselDecalSettings other) math.all(uvScale == other.uvScale) && math.all(uvOffset == other.uvOffset) && (imageTurns & 3) == (other.imageTurns & 3) && - math.all(endSlant == other.endSlant); + math.all(endSlant == other.endSlant) && + math.all(skew == other.skew); } public override readonly bool Equals(object obj) => obj is ChiselDecalSettings other && Equals(other); @@ -126,6 +170,8 @@ public override readonly int GetHashCode() hash = math.hash(new uint2(hash, math.hash(new float3(fieldOfView, maxAngle, surfaceOffset)))); hash = math.hash(new uint3(hash, math.hash(uvScale), math.hash(uvOffset))); hash = math.hash(new uint3(hash, math.hash(endSlant), (uint)(imageTurns & 3))); + hash = math.hash(new uint3(hash, math.hash(skew), (uint)reach)); + hash = math.hash(new uint2(hash, math.hash(fadeDistance))); return (int)hash; } } @@ -213,6 +259,13 @@ public static class ChiselDecalMeshes /// The surface parameter of a chunk's transparent decals' mesh: all their materials, one sub-mesh per /// material, in the order the decals stack, so that they draw in that order public const ulong kTransparentDecals = ulong.MaxValue; + + /// The chunk of a fading decal's mesh (see ): its own, so + /// that it is a renderer of its own + public static uint FadeChunkOf(ulong decalEntityID) + { + return math.hash(new uint3((uint)decalEntityID, (uint)(decalEntityID >> 32), 0x46414445u)); + } } public static class CSGTreeDecalExtensions diff --git a/Core/2.Processing/Decals/DecalVolume.cs b/Core/2.Processing/Decals/DecalVolume.cs index a194a58b..be926ca8 100644 --- a/Core/2.Processing/Decals/DecalVolume.cs +++ b/Core/2.Processing/Decals/DecalVolume.cs @@ -10,7 +10,10 @@ internal enum DecalVolumeFlags : byte None = 0, Perspective = 1 << 0, Transparent = 1 << 1, - AngleLimited = 1 << 2 + AngleLimited = 1 << 2, + Omni = 1 << 3, + OmniCube = 1 << 4, + Fades = 1 << 5 } // The six planes around a decal's volume, in tree space. Their normals point inwards and are unit length, so @@ -61,6 +64,8 @@ internal struct DecalVolume public const double kPlaneEpsilon = 1e-4; // Smaller boxes don't make a decal. public const double kMinSize = 1e-4; + // How far an omni-directional decal's volume on a surface reaches either side of it + const double kOmniSlab = 0.01; public ulong entityID; // where the decal was in the list given to SetDecals: how decals stack @@ -84,6 +89,11 @@ internal struct DecalVolume public double3 facingPoint; // the box's center: surfaces facing away from it are skipped public double3 axis; // unit, the direction the image travels in (orthographic) public double3 apex; // perspective: where the image comes from + + // Omni-directional: the image's size, how far the decal reaches from facingPoint, and its axes (a cube's) + public double2 omniImage; + public double omniReach; + public double3x3 omniAxes; public DecalPlanes planes; public float3 boundsMin; public float3 boundsMax; @@ -104,6 +114,20 @@ public readonly bool IsPerspective get { return (flags & DecalVolumeFlags.Perspective) != 0; } } + // A transparent decal with a fade distance: its pieces are a mesh of their own (ChiselDecalMeshes.FadeChunkOf) + public readonly bool Fades + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get { return (flags & DecalVolumeFlags.Fades) != 0; } + } + + // An omni-directional decal has no volume of its own: each surface it reaches gets one (OnSurface) + public readonly bool IsOmni + { + [MethodImpl(MethodImplOptions.AggressiveInlining)] + get { return (flags & DecalVolumeFlags.Omni) != 0; } + } + // False for a decal that cannot draw anything: no material, a flat box, a transformation without an inverse. public static bool TryCreate(in ChiselDecalInstance instance, out DecalVolume volume) { @@ -118,6 +142,10 @@ public static bool TryCreate(in ChiselDecalInstance instance, out DecalVolume vo return false; var decalToTree = (double4x4)instance.decalToTree; + if (settings.projection == ChiselDecalProjection.Omni) + return TryCreateOmni(instance, decalToTree, size, out volume); + if (math.all(math.isfinite(settings.skew)) && math.any(settings.skew != float2.zero)) + decalToTree = math.mul(decalToTree, (double4x4)ChiselDecalSettings.SkewMatrix(settings.skew)); if (!math.all(math.isfinite(decalToTree.c0)) || !math.all(math.isfinite(decalToTree.c1)) || !math.all(math.isfinite(decalToTree.c2)) || !math.all(math.isfinite(decalToTree.c3))) return false; @@ -198,6 +226,7 @@ public static bool TryCreate(in ChiselDecalInstance instance, out DecalVolume vo var flags = DecalVolumeFlags.None; if (perspective) flags |= DecalVolumeFlags.Perspective; if (settings.transparent) flags |= DecalVolumeFlags.Transparent; + if (settings.Fades) flags |= DecalVolumeFlags.Fades; var cosMaxAngle = -1.0; if (settings.maxAngle < 180.0f) { @@ -236,6 +265,99 @@ public static bool TryCreate(in ChiselDecalInstance instance, out DecalVolume vo return true; } + static bool TryCreateOmni(in ChiselDecalInstance instance, double4x4 decalToTree, double3 size, out DecalVolume volume) + { + volume = default; + var settings = instance.settings; + var axes = new double3x3(decalToTree.c0.xyz, decalToTree.c1.xyz, decalToTree.c2.xyz); + var scale = new double3(math.length(axes.c0), math.length(axes.c1), math.length(axes.c2)); + if (!math.all(scale > 1e-12)) + return false; + var image = size.xy * scale.xy; + var reach = size.z * 0.5 * scale.z; + var cube = settings.reach == ChiselDecalReach.Cube; + var point = math.mul(decalToTree, new double4((double3)settings.center, 1)).xyz; + // a surface the decal reaches passes within it, and the image on it goes no further than its half diagonal + var extent = (cube ? reach * math.sqrt(3.0) : reach) + math.length(image) * 0.5; + + var flags = DecalVolumeFlags.Omni; + if (cube) flags |= DecalVolumeFlags.OmniCube; + if (settings.transparent) flags |= DecalVolumeFlags.Transparent; + if (settings.Fades) flags |= DecalVolumeFlags.Fades; + var uvScale = settings.uvScale; + if (math.all(uvScale == float2.zero)) + uvScale = new float2(1, 1); + + volume = new DecalVolume + { + entityID = instance.entityID, + flags = flags, + renderMaterial = instance.renderMaterial, + materialFlags = instance.destinationFlags, + surfaceOffset = settings.transparent ? math.max(0, settings.surfaceOffset) : 0, + uvScale = uvScale, + uvOffset = settings.uvOffset, + imageTurns = settings.imageTurns, + cosMaxAngle = -1, + facingPoint = point, + omniImage = image, + omniReach = reach, + omniAxes = new double3x3(axes.c0 / scale.x, axes.c1 / scale.y, axes.c2 / scale.z), + boundsMin = (float3)(point - extent - kPlaneEpsilon), + boundsMax = (float3)(point + extent + kPlaneEpsilon) + }; + return true; + } + + // Whether an omni-directional decal reaches a surface on this plane (tree space, unit normal on its visible side): + // the center is in front of it, and the plane passes through the sphere or cube + public readonly bool Reaches(double4 plane) + { + var distance = math.dot(plane.xyz, facingPoint) + plane.w; + if (distance < -kPlaneEpsilon) + return false; + var reach = omniReach; + if ((flags & DecalVolumeFlags.OmniCube) != 0) + reach *= math.csum(math.abs(math.mul(math.transpose(omniAxes), plane.xyz))); + return distance <= reach + kPlaneEpsilon; + } + + // An omni-directional decal on one surface: an orthographic decal laid flat on it, its image round the point of + // the surface's plane nearest the center, its right along uDirection (the surface texture's u) + public readonly DecalVolume OnSurface(double3 normal, double3 pointOnPlane, double3 uDirection) + { + var n = math.normalizesafe(normal); + var origin = facingPoint - n * math.dot(n, facingPoint - pointOnPlane); + var u = uDirection - n * math.dot(uDirection, n); + if (!(math.lengthsq(u) > 1e-12)) + u = math.cross(n, math.abs(n.x) < 0.9 ? new double3(1, 0, 0) : new double3(0, 1, 0)); + u = math.normalize(u); + var v = math.cross(n, u); + + var half = omniImage * 0.5; + var surface = this; + surface.flags &= ~(DecalVolumeFlags.Omni | DecalVolumeFlags.OmniCube | DecalVolumeFlags.Perspective | DecalVolumeFlags.AngleLimited); + surface.treeToDecal = math.inverse(new double4x4(new double4(u, 0), new double4(v, 0), new double4(-n, 0), new double4(origin, 1))); + surface.center = double3.zero; + surface.size = new double3(omniImage, kOmniSlab * 2); + surface.focal = 0; + surface.facingPoint = origin + n; + surface.axis = -n; + surface.apex = double3.zero; + var planes = new DecalPlanes(); + planes[0] = new double4( u, -math.dot(u, origin) + half.x); + planes[1] = new double4(-u, math.dot(u, origin) + half.x); + planes[2] = new double4( v, -math.dot(v, origin) + half.y); + planes[3] = new double4(-v, math.dot(v, origin) + half.y); + planes[4] = new double4( n, -math.dot(n, origin) + kOmniSlab); + planes[5] = new double4(-n, math.dot(n, origin) + kOmniSlab); + surface.planes = planes; + var extent = math.abs(u) * half.x + math.abs(v) * half.y + math.abs(n) * kOmniSlab; + surface.boundsMin = (float3)(origin - extent - kPlaneEpsilon); + surface.boundsMax = (float3)(origin + extent + kPlaneEpsilon); + return surface; + } + // Whether the decal may draw on a surface of a brush: always, unless it has targets and none of them is it public readonly bool Targets(Unity.Collections.NativeArray targets, ulong brushEntityID, int surfaceIndex) { diff --git a/Core/2.Processing/Jobs/GenerateSurfaceTrianglesJob.cs b/Core/2.Processing/Jobs/GenerateSurfaceTrianglesJob.cs index 44988c0f..b3a32cdd 100644 --- a/Core/2.Processing/Jobs/GenerateSurfaceTrianglesJob.cs +++ b/Core/2.Processing/Jobs/GenerateSurfaceTrianglesJob.cs @@ -220,8 +220,35 @@ unsafe void FinishSurface(int surf, int brushNodeOrder, ulong entityID, ref Blob { var candidates = new UnsafeList(decalCandidates.GetUnsafeReadOnlyPtr() + candidateStart, candidateCount); var targeted = new UnsafeList(decalTargeted.GetUnsafeReadOnlyPtr() + candidateStart, candidateCount); + var volumes = decalVolumes; + var anyOmni = false; + for (int k = 0; k < candidateCount && !anyOmni; k++) + anyOmni = decalVolumes[candidates[k]].IsOmni; + NativeArray onSurface = default; + UnsafeList onSurfaceIndices = default; + if (anyOmni && uniqueVertexMapper.surfaceRenderVertices.Length > 0) + { + // an omni-directional decal lies flat on this surface, turned with its texture's u + onSurface = new NativeArray(candidateCount, Allocator.Temp); + onSurfaceIndices = new UnsafeList(candidateCount, Allocator.Temp); + var pointOnPlane = (double3)uniqueVertexMapper.surfaceRenderVertices[0].position; + var uDirection = new double3(uvMat.c0.x, uvMat.c1.x, uvMat.c2.x); + for (int k = 0; k < candidateCount; k++) + { + var volume = decalVolumes[candidates[k]]; + onSurface[k] = volume.IsOmni ? volume.OnSurface(finalFaceNormal, pointOnPlane, uDirection) : volume; + onSurfaceIndices.Add(k); + } + volumes = onSurface; + candidates = onSurfaceIndices; + } decalCut = decalBuilder.Build(surfaceIndexList, uniqueVertexMapper.surfaceRenderVertices, - uniqueVertexMapper.surfaceSelectVertices, decalVolumes, candidates, targeted, flags); + uniqueVertexMapper.surfaceSelectVertices, volumes, candidates, targeted, flags); + if (onSurface.IsCreated) + { + onSurface.Dispose(); + onSurfaceIndices.Dispose(); + } for (int k = 0; k < candidateCount; k++) { var decalOutput = decalBuilder.decals[k]; @@ -242,6 +269,7 @@ unsafe void FinishSurface(int surf, int brushNodeOrder, ulong entityID, ref Blob // the volumes stack lowest first if (volume.IsTransparent) surfaceBuffers[slot].decalStack = decalCandidates[candidateStart + k] + 1; + surfaceBuffers[slot].decalFades = volume.Fades; } } @@ -692,13 +720,20 @@ unsafe void FindDecalCandidates(ref BlobArray baseSurfaces, float4x { var d = nearBrush[i]; ref var volume = ref volumes[d]; - // Does the surface's plane pass through the decal's bounds? - var center = ((double3)volume.boundsMin + (double3)volume.boundsMax) * 0.5; - var extents = ((double3)volume.boundsMax - (double3)volume.boundsMin) * 0.5; - var distance = math.dot(plane.xyz, center) + plane.w; - var reach = math.dot(math.abs(plane.xyz), extents); - if (math.abs(distance) > reach + DecalVolume.kPlaneEpsilon) - continue; + if (volume.IsOmni) + { + if (!volume.Reaches(subtractiveWorkflow ? -plane : plane)) + continue; + } else + { + // Does the surface's plane pass through the decal's bounds? + var center = ((double3)volume.boundsMin + (double3)volume.boundsMax) * 0.5; + var extents = ((double3)volume.boundsMax - (double3)volume.boundsMin) * 0.5; + var distance = math.dot(plane.xyz, center) + plane.w; + var reach = math.dot(math.abs(plane.xyz), extents); + if (math.abs(distance) > reach + DecalVolume.kPlaneEpsilon) + continue; + } var isTargeted = volume.Targets(decalTargets, brushEntityID, baseSurfaces[s].descriptionIndex); if (!isTargeted && volume.IsTransparent) continue; diff --git a/Core/2.Processing/Jobs/JobData/ChiselBrushRenderBuffer.cs b/Core/2.Processing/Jobs/JobData/ChiselBrushRenderBuffer.cs index ca3e097e..d8b5df8a 100644 --- a/Core/2.Processing/Jobs/JobData/ChiselBrushRenderBuffer.cs +++ b/Core/2.Processing/Jobs/JobData/ChiselBrushRenderBuffer.cs @@ -17,6 +17,8 @@ internal struct ChiselSurfaceRenderBuffer public ulong decalEntityID; // a transparent decal's piece: where its decal stacks (1 the lowest), which its mesh draws it in; 0 otherwise public int decalStack; + // a transparent decal's piece that fades (DecalVolume.Fades): its mesh is its decal's own + public bool decalFades; public SurfaceDestinationFlags destinationFlags; public SurfaceDestinationParameters destinationParameters; // How many lightmap texels the surface gets (SurfaceOutputFlags.NoLightmap, SingleLightmapTexel) @@ -195,6 +197,7 @@ public void Construct(BlobBuilder builder, this.baseSurfaceIndex = baseSurfaceIndex; this.decalEntityID = 0; this.decalStack = 0; + this.decalFades = false; this.destinationFlags = destinationFlags; this.destinationParameters = destinationParameters; this.outputFlags = outputFlags; @@ -220,6 +223,7 @@ public void Construct(BlobBuilder builder, this.baseSurfaceIndex = baseSurfaceIndex; this.decalEntityID = 0; this.decalStack = 0; + this.decalFades = false; this.destinationFlags = destinationFlags; this.destinationParameters = destinationParameters; diff --git a/Core/2.Processing/Jobs/SubMeshesJob.cs b/Core/2.Processing/Jobs/SubMeshesJob.cs index 9b0c5cdd..cdea594f 100644 --- a/Core/2.Processing/Jobs/SubMeshesJob.cs +++ b/Core/2.Processing/Jobs/SubMeshesJob.cs @@ -706,12 +706,15 @@ public static uint CellOf(MinMaxAABB aabb) // The key a surface of a query is grouped by: its parameter, and its chunk public static SurfaceGroupKey KeyOf(ref ChiselBrushRenderBuffer buffer, ChiselQuerySurface surface, uint brushChunk, bool chunked, bool surfaceChunked, bool oneMaterial) { - // a render query's transparent decals are one mesh per chunk, whatever their material - var transparentDecal = surfaceChunked && !oneMaterial && buffer.surfaces[surface.surfaceIndex].decalStack > 0; + // a render query's transparent decals are one mesh per chunk, whatever their material; one that fades is a mesh + // of its own + ref var bufferSurface = ref buffer.surfaces[surface.surfaceIndex]; + var transparentDecal = surfaceChunked && !oneMaterial && bufferSurface.decalStack > 0; return new SurfaceGroupKey { parameter = oneMaterial ? 0 : transparentDecal ? ChiselDecalMeshes.kTransparentDecals : surface.surfaceParameter, - chunk = chunked ? brushChunk : surfaceChunked ? CellOf(buffer.surfaces[surface.surfaceIndex].aabb) : 0 + chunk = transparentDecal && bufferSurface.decalFades ? ChiselDecalMeshes.FadeChunkOf(bufferSurface.decalEntityID) : + chunked ? brushChunk : surfaceChunked ? CellOf(bufferSurface.aabb) : 0 }; } diff --git a/Core/2.Processing/Managers/CSGManager.SkipUnchangedTrees.cs b/Core/2.Processing/Managers/CSGManager.SkipUnchangedTrees.cs index e7bb6efc..3c39a8e9 100644 --- a/Core/2.Processing/Managers/CSGManager.SkipUnchangedTrees.cs +++ b/Core/2.Processing/Managers/CSGManager.SkipUnchangedTrees.cs @@ -251,6 +251,9 @@ static unsafe void AppendDecals(ref UnsafeAppendBuffer buffer, CSGTree tree, Dic decalBuffer.Add(settings.uvOffset); decalBuffer.Add(settings.endSlant); decalBuffer.Add(settings.imageTurns & 3); + decalBuffer.Add(settings.skew); + decalBuffer.Add((int)settings.reach); + decalBuffer.Add(settings.fadeDistance); decalBuffer.Add(GetIdentity(identities, surfaceParameterIdentity, decal.renderMaterial)); decalBuffer.Add((int)decal.destinationFlags); decalBuffer.Add(decal.targetCount); diff --git a/Core/3.Output/OutputMeshes/OutputHotspotSections.cs b/Core/3.Output/OutputMeshes/OutputHotspotSections.cs index fce79ae3..ef65fd24 100644 --- a/Core/3.Output/OutputMeshes/OutputHotspotSections.cs +++ b/Core/3.Output/OutputMeshes/OutputHotspotSections.cs @@ -501,6 +501,7 @@ static BlobAssetReference Cut(BlobAssetReference Copy(BlobAssetReferenc to.baseSurfaceIndex = from.baseSurfaceIndex; to.decalEntityID = from.decalEntityID; to.decalStack = from.decalStack; + to.decalFades = from.decalFades; to.destinationFlags = from.destinationFlags; to.destinationParameters = from.destinationParameters; to.outputFlags = from.outputFlags; diff --git a/Core/Tests/Decals/DecalClippingTests.cs b/Core/Tests/Decals/DecalClippingTests.cs index e94b7fe0..00e57578 100644 --- a/Core/Tests/Decals/DecalClippingTests.cs +++ b/Core/Tests/Decals/DecalClippingTests.cs @@ -186,6 +186,61 @@ public void AnImageTurnedLeft_IsTurnedCounterClockwise() Assert.That(Volume(instance).ProjectUV(new double3(-0.5, 0.5, 0)), Is.EqualTo(new float2(0, 0)), "turned right, the lower left is at the upper left"); } + // Skewed 45 degrees along x, the image's sides lean: its corners are on the parallelogram, and so is its volume + [Test] + public void ASkewedDecal_HasAParallelogramFrame() + { + var instance = Decal(new float3(1), float4x4.identity); + instance.settings.skew = new float2(45, 0); + var volume = Volume(instance); + Assert.That(math.distance(volume.ProjectUV(new double3(-1, -0.5, 0)), new float2(0, 0)), Is.LessThan(1e-5f)); + Assert.That(math.distance(volume.ProjectUV(new double3( 1, 0.5, 0)), new float2(1, 1)), Is.LessThan(1e-5f)); + Assert.That(math.distance(volume.ProjectUV(new double3( 0.5, 0.5, 0)), new float2(0.5f, 1)), Is.LessThan(1e-5f), "the top edge's middle"); + Assert.That(volume.Contains(new double3(0.9, 0.45, 0)), Is.True, "inside the leaning side"); + Assert.That(volume.Contains(new double3(-0.9, 0.45, 0)), Is.False, "outside the other leaning side"); + Assert.That(volume.boundsMax.x, Is.EqualTo(1).Within(1e-3f)); + + instance.settings.skew = new float2(0, 45); + Assert.That(math.distance(Volume(instance).ProjectUV(new double3(0.5, 1, 0)), new float2(1, 1)), Is.LessThan(1e-5f), "skewed along y, the top and bottom lean"); + } + + // An omni-directional decal 0.3 above a floor, reaching half a metre: the floor's plane is reached from above, not + // from below, and not with the decal 0.6 above it - unless its reach is a cube turned so its corner points down + [Test] + public void AnOmniDecal_ReachesThePlanesPassingWithinItsReach() + { + var instance = Decal(new float3(0.5f, 0.5f, 1), float4x4.identity, projection: ChiselDecalProjection.Omni); + instance.settings.center = new float3(0, 0.3f, 0); + var floor = new double4(0, 1, 0, 0); + Assert.That(Volume(instance).Reaches(floor), Is.True); + Assert.That(Volume(instance).Reaches(-floor), Is.False, "the center is behind it"); + + instance.settings.center = new float3(0, 0.6f, 0); + Assert.That(Volume(instance).Reaches(floor), Is.False, "beyond the sphere"); + + instance.settings.reach = ChiselDecalReach.Cube; + instance.decalToTree = float4x4.RotateZ(math.radians(45)); + instance.settings.center = new float3(0.6f * math.sin(math.radians(45)), 0.6f * math.cos(math.radians(45)), 0); + Assert.That(Volume(instance).Reaches(floor), Is.True, "the turned cube's edge reaches 0.71 down"); + } + + // On a surface, an omni-directional decal is its whole image laid flat round the point under its center, its right + // along the surface texture's u + [Test] + public void AnOmniDecal_OnASurface_IsItsImageRoundThePointUnderItsCenter() + { + var instance = Decal(new float3(0.5f, 0.5f, 1), float4x4.identity, projection: ChiselDecalProjection.Omni); + instance.settings.center = new float3(1, 0.3f, 2); + var surface = Volume(instance).OnSurface(new double3(0, 1, 0), new double3(5, 0, 5), new double3(1, 0, 0)); + Assert.That(surface.IsOmni, Is.False); + // the image's up is the normal turned from u: -z + Assert.That(math.distance(surface.ProjectUV(new double3(1, 0, 2)), new float2(0.5f, 0.5f)), Is.LessThan(1e-5f)); + Assert.That(math.distance(surface.ProjectUV(new double3(1.25, 0, 1.75)), new float2(1, 1)), Is.LessThan(1e-5f)); + Assert.That(surface.Contains(new double3(1.2, 0, 2.2)), Is.True); + Assert.That(surface.Contains(new double3(1.3, 0, 2)), Is.False, "past the image's side"); + Assert.That(surface.Accepts(new double3(0, 1, 0), new double3(1, 0, 2)), Is.True); + } + // A mapping turned with TurnMapping gives the same image coordinates as the mapping, turned [Test] public void ATurnedMapping_IsTheMappingTurned() diff --git a/Editor/ComponentEditors/Decals/ChiselDecalEditor.cs b/Editor/ComponentEditors/Decals/ChiselDecalEditor.cs index 5a21e9c3..3a915543 100644 --- a/Editor/ComponentEditors/Decals/ChiselDecalEditor.cs +++ b/Editor/ComponentEditors/Decals/ChiselDecalEditor.cs @@ -21,8 +21,12 @@ public sealed class ChiselDecalEditor : Editor static readonly GUIContent kRotateLeftContent = new GUIContent("90° Left", "A quarter turn counter-clockwise, as seen looking at the surface."); static readonly GUIContent kRotateRightContent = new GUIContent("90° Right", "A quarter turn clockwise, as seen looking at the surface."); static readonly GUIContent kSizeContent = new GUIContent("Size", "X and Y are the image's width and height (for a perspective decal: at the center of the box), Z is how deep the decal reaches."); - static readonly GUIContent kProjectionContent = new GUIContent("Projection", "Orthographic keeps the image's size at every depth; perspective projects it from a point behind the box, like a projector."); + static readonly GUIContent kOmniSizeContent = new GUIContent("Size", "X and Y are the image's width and height on each surface; Z is the diameter of the sphere it reaches in, or its cube's edge."); + static readonly GUIContent kReachContent = new GUIContent("Reach", "Sphere: every surface no further from the center than half of Size Z takes the decal. Cube: every surface passing through the cube of Size Z, turned with the decal."); + static readonly GUIContent kSkewContent = new GUIContent("Skew", "Shears the decal so its image is a parallelogram, in degrees: X leans the image's sides, Y its top and bottom. For slanted geometry such as stair stringers and ramps."); + static readonly GUIContent kProjectionContent = new GUIContent("Projection", "Orthographic keeps the image's size at every depth; perspective projects it from a point behind the box, like a projector. Omni goes out from the center in every direction: every surface it reaches takes the whole image, laid flat on it and turned with its texture, like Source's infodecal."); static readonly GUIContent kFieldOfViewContent = new GUIContent("Field Of View", "The perspective projection's vertical angle, in degrees."); + static readonly GUIContent kFadeDistanceContent = new GUIContent("Fade Distance", "X: how far from the camera the decal starts to fade, Y: where it is gone (at a 60 degree field of view). A Y of 0 never fades. The decal becomes a renderer of its own, cross-faded by a LODGroup; its material's shader has to support LOD cross-fading, as URP's Lit does."); static readonly GUIContent kTransparentContent = new GUIContent("Transparent", "A transparent decal is drawn over the surfaces it covers. An opaque decal replaces them in the rendered mesh; the collider keeps them."); static readonly GUIContent kMaxAngleContent = new GUIContent("Max Angle", "Surfaces turned further than this from the direction the image comes from don't take the decal. 180 takes every surface that doesn't face away from the box's center."); static readonly GUIContent kUVScaleContent = new GUIContent("UV Scale", "The texture coordinates are UV Offset + UV Scale * p, where p runs from (0,0) at the image's lower left to (1,1) at its upper right."); @@ -75,6 +79,9 @@ public override void OnInspectorGUI() var isTrim = !shapeValue.hasMultipleDifferentValues && shapeValue.enumValueIndex == (int)ChiselDecalShape.Trim; EditorGUILayout.PropertyField(material, kMaterialContent); MaterialToggles(transparent, isTrim ? serializedObject.FindProperty(ChiselDecalComponent.kTrimMirrorName) : null); + // opaque decals replace the surface below, so only transparent ones fade + if (transparent.boolValue || transparent.hasMultipleDifferentValues) + EditorGUILayout.PropertyField(settings.FindPropertyRelative(nameof(ChiselDecalSettings.fadeDistance)), kFadeDistanceContent); if (material.objectReferenceValue == null && !material.hasMultipleDifferentValues) EditorGUILayout.HelpBox(kNoMaterialMessage, MessageType.Info); @@ -91,16 +98,24 @@ public override void OnInspectorGUI() { var projection = settings.FindPropertyRelative(nameof(ChiselDecalSettings.projection)); EditorGUILayout.PropertyField(projection, kProjectionContent); + var omni = !projection.hasMultipleDifferentValues && projection.enumValueIndex == (int)ChiselDecalProjection.Omni; if (projection.hasMultipleDifferentValues || projection.enumValueIndex == (int)ChiselDecalProjection.Perspective) { EditorGUILayout.Slider(settings.FindPropertyRelative(nameof(ChiselDecalSettings.fieldOfView)), ChiselDecalSettings.kMinFieldOfView, ChiselDecalSettings.kMaxFieldOfView, kFieldOfViewContent); } - EditorGUILayout.Slider(settings.FindPropertyRelative(nameof(ChiselDecalSettings.maxAngle)), 0, 180, kMaxAngleContent); + if (omni) + EditorGUILayout.PropertyField(settings.FindPropertyRelative(nameof(ChiselDecalSettings.reach)), kReachContent); + else + EditorGUILayout.Slider(settings.FindPropertyRelative(nameof(ChiselDecalSettings.maxAngle)), 0, 180, kMaxAngleContent); EditorGUILayout.PropertyField(settings.FindPropertyRelative(nameof(ChiselDecalSettings.center)), kCenterContent); - EditorGUILayout.PropertyField(settings.FindPropertyRelative(nameof(ChiselDecalSettings.size)), kSizeContent); - EditorGUILayout.PropertyField(serializedObject.FindProperty(ChiselDecalComponent.kRepeatName), kRepeatContent); + EditorGUILayout.PropertyField(settings.FindPropertyRelative(nameof(ChiselDecalSettings.size)), omni ? kOmniSizeContent : kSizeContent); + if (!omni) + { + EditorGUILayout.PropertyField(settings.FindPropertyRelative(nameof(ChiselDecalSettings.skew)), kSkewContent); + EditorGUILayout.PropertyField(serializedObject.FindProperty(ChiselDecalComponent.kRepeatName), kRepeatContent); + } } ImageField(material, serializedObject.FindProperty(ChiselDecalComponent.kImageTileName)); @@ -268,7 +283,9 @@ void OnSceneGUI() return; } var settings = decal.Settings; - using (new Handles.DrawingScope(kHandleColor, decal.transform.localToWorldMatrix)) + // the box handle in the decal's unsheared space, so a skewed decal's handle is its parallelogram + var skew = settings.projection == ChiselDecalProjection.Omni ? Unity.Mathematics.float2.zero : settings.skew; + using (new Handles.DrawingScope(kHandleColor, decal.transform.localToWorldMatrix * (Matrix4x4)ChiselDecalSettings.SkewMatrix(skew))) { s_BoxHandle.center = settings.center; s_BoxHandle.size = settings.size;