Files
adminandClaude Fable 5 57a4627b81 PictureFrameBuilder/Shader: lighting parity, HTrace GBuffer pass, absolute frame metrics
Shader (PictureTessellationLit):
- Lighting parity with URP Lit: lightmaps, shadowmask/mixed, APV, DBuffer
  decals, vertex additional lights, rendering layers, fragment fog
- UniversalGBuffer pass (with tessellation + graded albedo) so HTrace WSGI
  composes correct GI instead of a cyan veil over pictures
- Skip GPU tessellation entirely when strength/displacement is 0
- Inspector ranges matched to builder (-3..3 bump, -10..10 tessellation)

Builder:
- Frame Thickness & Padding are now absolute meters (size-independent)
  instead of fractions of the picture size
- Tooltip updates for the new default/range semantics

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-11 12:30:19 +02:00

677 lines
37 KiB
C#

using System.Collections.Generic;
using UnityEngine;
namespace Chameleon.Components
{
// Procedural picture frame, generated live in the editor (and at runtime) from the inspector
// parameters. Sizing is inside-out: the picture keeps the texture's aspect ratio EXACTLY and the
// outer frame grows around it by padding + thickness (Size = longer outer side) - insetting a
// uniform border from an aspect-scaled outer rect would distort the picture. Thickness and padding
// are absolute meters (independent of Size - the picture shrinks as they grow); corner radius is a
// fraction of the maximum possible rounding and the depths are fractions of the frame depth.
// Resize via Size, NOT via transform scale. Four submeshes with their own
// material slots: 0 picture, 1 inner cavity (recess walls + padding ring), 2 frame (front ring, outer
// sides, edge rounding), 3 back plate. Front faces -Z like Unity's built-in Quad. An assigned
// PictureTexture becomes the picture material's base map via a per-frame DontSave material
// instance (the shared material asset is never modified) and drives the aspect ratio; an optional
// PictureNormalMap rides on the same instance as its bump map.
//
// The generated mesh is a per-instance HideFlags.DontSave object (never saved into the scene file);
// it is rebuilt on scene load / domain reload via OnEnable. Duplicated frames get their own mesh
// (identified by instance id), so two frames never write into one shared mesh.
//
// GenerateColliders maintains matching BoxColliders (corner radius ignored): 4 border bars, a slab
// behind the inner floor, plus a plinth box when the picture is raised. Only colliders tracked in
// _colliders are touched - manually added ones are left alone. The boxes live on a dedicated
// "Colliders" child: adding/removing a component on the inspected GameObject itself would rebuild
// its inspector mid-drag and release the slider being held (Unity 6's UI Toolkit inspector never
// sets GUIUtility.hotControl, so the mouse state cannot be polled) - on a child they can appear
// and disappear live without ever disturbing the editing.
[ExecuteAlways]
[RequireComponent(typeof(MeshFilter), typeof(MeshRenderer))]
public sealed class PictureFrameBuilder : MonoBehaviour
{
const int SubPicture = 0;
const int SubInner = 1;
const int SubFrame = 2;
const int SubBack = 3;
[Header("Size")]
[Tooltip("Length of the frame's longer side in meters; the shorter side follows the picture's aspect ratio.")]
[SerializeField, Min(0.01f)] float _size = 1f;
// Auto-resolved in the editor from the picture material's base map texture (longer side = 1,
// 1:1 when no texture is found) and serialized, because the source image file/importer is not
// available in a build. Manual edits are overwritten on the next rebuild.
[Tooltip("Width : height factors derived from the picture texture (auto-set in the editor, longer side = 1).")]
[SerializeField] Vector2 _aspect = Vector2.one;
[Header("Frame")]
[Tooltip("Border width of the frame in meters (independent of Size).")]
[SerializeField, Range(0.001f, 0.5f)] float _frameThickness = 0.03f;
[Tooltip("Depth of the frame, front face to back face.")]
[SerializeField, Range(0.002f, 0.500f)] float _frameDepth = 0.05f;
[Tooltip("Rounding of the outer frame edges relative to the maximum possible radius (thickness / half depth); 0 = sharp box, 1 = fully rounded.")]
[SerializeField, Range(0f, 1f)] float _cornerRadius = 0.2f;
[Tooltip("Arc segments of the edge rounding.")]
[SerializeField, Range(1, 8)] int _bevelSegments = 4;
[Tooltip("Inner floor relative to the frame depth: -1 = fully sunken (on the back plate), 0 = flush with the front face, 1 = a full frame depth in front.")]
[SerializeField, Range(-1f, 1f)] float _frameInnerDepth = -0.1f;
[Header("Picture")]
[Tooltip("Gap between the frame's inner edge and the picture in meters (independent of Size).")]
[SerializeField, Range(0f, 0.5f)] float _padding = 0.005f;
[Tooltip("Picture plane: -1 = fully inside (on the back plate), 0 = flush with the inner floor, 1 = a full frame depth in front of the frame.")]
[SerializeField, Range(-1f, 1f)] float _pictureDepth = 0.1f;
[Tooltip("Photo for this frame: applied as the picture material's base map via a per-frame material instance (the material asset itself is never modified) and drives the aspect ratio. Empty = the material's own base map is shown.")]
[SerializeField] Texture _pictureTexture;
[Tooltip("Optional normal map for the picture: applied as the picture material's bump map via the same per-frame material instance.")]
[SerializeField] Texture _pictureNormalMap;
[Tooltip("Intensity of the picture normal map (URP _BumpScale). 0 = flat, negative inverts the relief.")]
[SerializeField, Range(-3f, 3f)] float _normalMapStrength = 0;
[Tooltip("Optional height map for the picture: set as _HeightMap on the per-frame material instance (tessellation shader); on plain URP Lit it falls back to the parallax map.")]
[SerializeField] Texture _pictureHeightMap;
[Tooltip("Scales the material's occlusion (_OcclusionStrength). 0 = no occlusion darkening.")]
[SerializeField, Range(0f, 3f)] float _occlusionStrength = 0;
[Tooltip("Contrast of the occlusion map around mid-gray (_OcclusionContrast). 0 = unchanged, -1 = flat, 3 = strongly exaggerated.")]
[SerializeField, Range(-1f, 3f)] float _occlusionContrast;
[Tooltip("Brightness multiplier on the picture base map (_Brightness) to compensate the darkening from occlusion. 1 = unchanged.")]
[SerializeField, Range(0f, 3f)] float _brightness = 1;
[Tooltip("Scales the material's tessellation displacement (_TessellationStrength). 0 = flat (GPU tessellation is skipped entirely), negative inverts the relief.")]
[SerializeField, Range(-10f, 10f)] float _tessellationStrength = 0;
[Tooltip("Triangle grid resolution of the picture plane mesh (cells along its longer side). 1 = plain quad; extra triangles visible in wireframe beyond this come from the shader's GPU Tessellation Factor, not this mesh.")]
[SerializeField, Range(1, 10)] int _pictureSubdivisions = 1;
[Header("Picture Crop")]
[Tooltip("Cut away this fraction of the picture from the left edge; the frame aspect follows the cropped region.")]
[SerializeField, Range(0f, 0.9f)] float _cropLeft;
[Tooltip("Cut away this fraction of the picture from the right edge.")]
[SerializeField, Range(0f, 0.9f)] float _cropRight;
[Tooltip("Cut away this fraction of the picture from the top edge.")]
[SerializeField, Range(0f, 0.9f)] float _cropTop;
[Tooltip("Cut away this fraction of the picture from the bottom edge.")]
[SerializeField, Range(0f, 0.9f)] float _cropBottom;
[Header("Materials")]
[SerializeField] Material _pictureMaterial;
[SerializeField] Material _innerMaterial;
[SerializeField] Material _frameMaterial;
[SerializeField] Material _backMaterial;
[Header("Colliders")]
[Tooltip("Automatically maintain BoxColliders matching the frame (corner radius is ignored).")]
[SerializeField] bool _generateColliders = true;
// Ownership list so rebuilds never touch colliders the user added by hand.
[SerializeField, HideInInspector] List<BoxCollider> _colliders = new List<BoxCollider>();
[SerializeField, HideInInspector] Transform _colliderRoot;
readonly List<Vector3> _verts = new List<Vector3>();
readonly List<Vector3> _normals = new List<Vector3>();
readonly List<Vector2> _uvs = new List<Vector2>();
readonly List<int>[] _tris =
{
new List<int>(), new List<int>(), new List<int>(), new List<int>()
};
Mesh _mesh;
Material _pictureMaterialInstance;
bool _rebuildQueued;
bool _rebuilding;
void OnEnable() => Rebuild();
// Mesh/renderer writes are not allowed during OnValidate, so it only flags; the actual
// rebuild runs in Update (ExecuteAlways covers edit mode).
void OnValidate()
{
_rebuildQueued = true;
#if UNITY_EDITOR
// In edit mode Update only ticks when the editor decides to pump the player loop;
// request one so the last inspector change never stays un-applied.
UnityEditor.EditorApplication.QueuePlayerLoopUpdate();
#endif
}
void Update()
{
if (!_rebuildQueued || _rebuilding) return;
_rebuildQueued = false;
_rebuilding = true;
try { Rebuild(); }
finally { _rebuilding = false; }
}
void OnDestroy()
{
if (_pictureMaterialInstance)
{
DestroyOwned(_pictureMaterialInstance);
_pictureMaterialInstance = null;
}
if (!_mesh) return;
if (Application.isPlaying) Destroy(_mesh);
else DestroyImmediate(_mesh);
}
[ContextMenu("Rebuild")]
public void Rebuild()
{
var filter = GetComponent<MeshFilter>();
EnsureMesh(filter);
#if UNITY_EDITOR
ResolveAspect();
#endif
// Inside-out sizing: the picture keeps the texture aspect exactly; thickness and padding
// are absolute meters (they do NOT scale with Size), and Size stays the longer OUTER
// side: pLong = size - 2*(t + pad), clamped so the picture never degenerates (the outer
// side then exceeds Size instead).
float d = Mathf.Max(_frameDepth, 0.002f);
float t = Mathf.Max(_frameThickness, 0.001f);
float pad = Mathf.Max(_padding, 0f);
float pLong = Mathf.Max(Mathf.Max(_size, 0.01f) - 2f * (t + pad), 0.01f);
float pw = Mathf.Max(pLong * _aspect.x, 0.001f);
float ph = Mathf.Max(pLong * _aspect.y, 0.001f);
float ow = pw + 2f * pad;
float oh = ph + 2f * pad;
float w = ow + 2f * t;
float h = oh + 2f * t;
float r = _cornerRadius * Mathf.Max(Mathf.Min(t, d * 0.5f) - 0.0005f, 0f);
if (r < 1e-4f) r = 0f;
// Depths are fractions of the frame depth. Inner floor: -1 = back plate .. +1 = a full
// frame depth in front of the face. Picture: interpolates from the back plate (-1) over
// the inner floor (0) to a full frame depth in front (+1) - endpoints are absolute so
// the extremes always mean "all the way in/out" regardless of where the floor sits.
float id = -_frameInnerDepth * d;
float pz = _pictureDepth < 0f
? Mathf.Lerp(id, d, -_pictureDepth)
: Mathf.Lerp(id, -d, _pictureDepth);
_verts.Clear();
_normals.Clear();
_uvs.Clear();
for (int i = 0; i < _tris.Length; i++) _tris[i].Clear();
// Picture plane, subdivided into a grid so displacement/tessellation shaders have
// vertices to work with; cell counts follow the aspect so cells stay roughly square.
// The UVs sample only the cropped sub-rect of the texture (full texture when no crop);
// normal/height maps ride the same UV set, so they crop along automatically.
CropSpans(out float cu0, out float cu1, out float cv0, out float cv1);
int cellsX = Mathf.Max(1, Mathf.RoundToInt(_pictureSubdivisions * _aspect.x));
int cellsY = Mathf.Max(1, Mathf.RoundToInt(_pictureSubdivisions * _aspect.y));
for (int cy = 0; cy < cellsY; cy++)
for (int cx = 0; cx < cellsX; cx++)
{
float u0 = (float)cx / cellsX;
float u1 = (float)(cx + 1) / cellsX;
float v0 = (float)cy / cellsY;
float v1 = (float)(cy + 1) / cellsY;
Quad(SubPicture,
new Vector3(pw * (u0 - 0.5f), ph * (v0 - 0.5f), pz), new Vector3(pw * (u1 - 0.5f), ph * (v0 - 0.5f), pz),
new Vector3(pw * (u1 - 0.5f), ph * (v1 - 0.5f), pz), new Vector3(pw * (u0 - 0.5f), ph * (v1 - 0.5f), pz),
Vector3.back,
new Vector2(Mathf.Lerp(cu0, cu1, u0), Mathf.Lerp(cv0, cv1, v0)),
new Vector2(Mathf.Lerp(cu0, cu1, u1), Mathf.Lerp(cv0, cv1, v0)),
new Vector2(Mathf.Lerp(cu0, cu1, u1), Mathf.Lerp(cv0, cv1, v1)),
new Vector2(Mathf.Lerp(cu0, cu1, u0), Mathf.Lerp(cv0, cv1, v1)));
}
// Picture side walls when the plane sits off the inner floor: sunken = inward-facing recess,
// raised = outward-facing plinth.
if (Mathf.Abs(pz - id) > 1e-4f)
PerimeterWalls(SubInner, pw * 0.5f, ph * 0.5f, Mathf.Min(pz, id), Mathf.Max(pz, id), pz > id ? -1f : 1f);
// Inner cavity: padding ring around the picture + walls between the front face and the
// inner floor (inward recess when sunken, outward block sides when it protrudes).
Ring(SubInner, ow * 0.5f, oh * 0.5f, pw * 0.5f, ph * 0.5f, id);
if (Mathf.Abs(id) > 1e-4f)
PerimeterWalls(SubInner, ow * 0.5f, oh * 0.5f, Mathf.Min(id, 0f), Mathf.Max(id, 0f), id > 0f ? -1f : 1f);
// Frame: mitred front ring (from the rounded-edge inset to the opening), outer sides, bevels.
Ring(SubFrame, w * 0.5f - r, h * 0.5f - r, ow * 0.5f, oh * 0.5f, 0f);
OuterSides(w, h, d, r);
if (r > 0f) Bevels(w, h, d, r, Mathf.Max(1, _bevelSegments));
// Back plate.
Quad(SubBack,
new Vector3(-(w * 0.5f - r), -(h * 0.5f - r), d), new Vector3(w * 0.5f - r, -(h * 0.5f - r), d),
new Vector3(w * 0.5f - r, h * 0.5f - r, d), new Vector3(-(w * 0.5f - r), h * 0.5f - r, d),
Vector3.forward,
new Vector2(-(w * 0.5f - r), -(h * 0.5f - r)), new Vector2(w * 0.5f - r, -(h * 0.5f - r)),
new Vector2(w * 0.5f - r, h * 0.5f - r), new Vector2(-(w * 0.5f - r), h * 0.5f - r));
_mesh.Clear();
_mesh.SetVertices(_verts);
_mesh.SetNormals(_normals);
_mesh.SetUVs(0, _uvs);
_mesh.subMeshCount = _tris.Length;
for (int i = 0; i < _tris.Length; i++) _mesh.SetTriangles(_tris[i], i, false);
_mesh.RecalculateBounds();
_mesh.RecalculateTangents();
ApplyMaterials();
RebuildColliders(w, h, t, d, ow, oh, pw, ph, id, pz);
}
// Box approximation, corner radius intentionally ignored: 4 border bars (full depth), one slab
// filling the opening from the inner floor to the back, and a plinth box only when the picture
// is raised out of the floor. A sunken picture recess is NOT carved out - the slab stays full
// (colliders would need a per-side split for a cosmetic dent).
void RebuildColliders(float w, float h, float t, float d, float ow, float oh, float pw, float ph, float id, float pz)
{
_colliders.RemoveAll(c => !c);
// Migrate colliders older versions created directly on this GameObject: they must move to
// the child root, otherwise every structural change here would rebuild the inspector.
for (int i = _colliders.Count - 1; i >= 0; i--)
{
if (_colliderRoot && _colliders[i].transform == _colliderRoot) continue;
DestroyOwned(_colliders[i]);
_colliders.RemoveAt(i);
}
// The root child is fully owned, so any box that slipped out of the list (undo, aborted
// rebuild) is adopted back instead of leaking as an orphan.
if (_colliderRoot)
foreach (var box in _colliderRoot.GetComponents<BoxCollider>())
if (!_colliders.Contains(box)) _colliders.Add(box);
int count = 0;
if (_generateColliders)
{
SetBox(count++, new Vector3(0f, (h - t) * 0.5f, d * 0.5f), new Vector3(w, t, d));
SetBox(count++, new Vector3(0f, -(h - t) * 0.5f, d * 0.5f), new Vector3(w, t, d));
SetBox(count++, new Vector3((w - t) * 0.5f, 0f, d * 0.5f), new Vector3(t, oh, d));
SetBox(count++, new Vector3(-(w - t) * 0.5f, 0f, d * 0.5f), new Vector3(t, oh, d));
// Keep a minimal slab even when the inner floor sits on the back plate, so the opening
// is never backless.
float slabFront = Mathf.Min(id, d - 0.002f);
SetBox(count++, new Vector3(0f, 0f, (slabFront + d) * 0.5f), new Vector3(ow, oh, d - slabFront));
if (pz < id - 1e-4f)
SetBox(count++, new Vector3(0f, 0f, (pz + id) * 0.5f), new Vector3(pw, ph, id - pz));
}
for (int i = _colliders.Count - 1; i >= count; i--)
{
var extra = _colliders[i];
_colliders.RemoveAt(i);
if (extra) DestroyOwned(extra);
}
// Generation switched off: drop the root too, but only when nothing foreign lives on it.
if (!_generateColliders && _colliderRoot && _colliderRoot.childCount == 0 &&
_colliderRoot.GetComponents<Component>().Length == 1)
{
var go = _colliderRoot.gameObject;
_colliderRoot = null;
DestroyOwned(go);
}
}
void SetBox(int index, Vector3 center, Vector3 size)
{
while (_colliders.Count <= index)
_colliders.Add(EnsureColliderRoot().gameObject.AddComponent<BoxCollider>());
var box = _colliders[index];
if (box.center != center) box.center = center;
if (box.size != size) box.size = size;
}
Transform EnsureColliderRoot()
{
if (_colliderRoot) return _colliderRoot;
var existing = transform.Find("Colliders");
if (existing) _colliderRoot = existing;
else
{
var go = new GameObject("Colliders") { layer = gameObject.layer };
go.transform.SetParent(transform, false);
_colliderRoot = go.transform;
}
return _colliderRoot;
}
static void DestroyOwned(Object o)
{
if (Application.isPlaying) Destroy(o);
else DestroyImmediate(o);
}
void EnsureMesh(MeshFilter filter)
{
string meshName = "PictureFrame " + GetInstanceID();
if (!_mesh)
{
// Re-adopt our own mesh after a domain reload; a duplicated object has a different
// instance id and therefore gets a fresh mesh instead of fighting over the original's.
var existing = filter.sharedMesh;
_mesh = existing && existing.name == meshName
? existing
: new Mesh { name = meshName, hideFlags = HideFlags.DontSave };
}
if (filter.sharedMesh != _mesh) filter.sharedMesh = _mesh;
}
void ApplyMaterials()
{
var picture = ResolvePictureMaterial();
var meshRenderer = GetComponent<MeshRenderer>();
var current = meshRenderer.sharedMaterials;
bool dirty = current.Length != 4
|| current[0] != picture || current[1] != _innerMaterial
|| current[2] != _frameMaterial || current[3] != _backMaterial;
if (dirty)
meshRenderer.sharedMaterials = new[] { picture, _innerMaterial, _frameMaterial, _backMaterial };
}
// With a picture texture assigned, the picture slot uses a per-frame DontSave material
// instance whose base map is that texture - many frames can share ONE picture material asset
// with different photos, and the asset is never mutated. Properties are re-copied from the
// source material on every rebuild, so asset edits keep showing up on the instance. Like the
// mesh, the instance is never saved and is recreated in OnEnable after scene load.
Material ResolvePictureMaterial()
{
bool wantsInstance = _pictureTexture || _pictureNormalMap || _pictureHeightMap
|| _occlusionStrength != 1f || _tessellationStrength != 1f
|| _occlusionContrast != 0f || _brightness != 1f;
if (!wantsInstance || !_pictureMaterial)
{
if (_pictureMaterialInstance)
{
DestroyOwned(_pictureMaterialInstance);
_pictureMaterialInstance = null;
}
return _pictureMaterial;
}
if (!_pictureMaterialInstance)
{
_pictureMaterialInstance = new Material(_pictureMaterial) { hideFlags = HideFlags.DontSave };
}
else
{
if (_pictureMaterialInstance.shader != _pictureMaterial.shader)
_pictureMaterialInstance.shader = _pictureMaterial.shader;
_pictureMaterialInstance.CopyPropertiesFromMaterial(_pictureMaterial);
}
_pictureMaterialInstance.name = _pictureMaterial.name + " (Picture)";
if (_pictureTexture)
{
if (_pictureMaterialInstance.HasProperty("_BaseMap"))
_pictureMaterialInstance.SetTexture("_BaseMap", _pictureTexture);
else
_pictureMaterialInstance.mainTexture = _pictureTexture;
}
if (_pictureNormalMap && _pictureMaterialInstance.HasProperty("_BumpMap"))
{
_pictureMaterialInstance.SetTexture("_BumpMap", _pictureNormalMap);
// URP Lit only samples _BumpMap with the keyword on; CopyPropertiesFromMaterial has
// just reset the keywords to the source material's (which has no bump map).
_pictureMaterialInstance.EnableKeyword("_NORMALMAP");
if (_pictureMaterialInstance.HasProperty("_BumpScale"))
_pictureMaterialInstance.SetFloat("_BumpScale", _normalMapStrength);
}
if (_pictureHeightMap)
{
if (_pictureMaterialInstance.HasProperty("_HeightMap"))
_pictureMaterialInstance.SetTexture("_HeightMap", _pictureHeightMap);
else if (_pictureMaterialInstance.HasProperty("_ParallaxMap"))
{
// Plain URP Lit: no displacement support, use the height map as parallax.
_pictureMaterialInstance.SetTexture("_ParallaxMap", _pictureHeightMap);
_pictureMaterialInstance.EnableKeyword("_PARALLAXMAP");
}
// The height map doubles as the occlusion source (crevices darken) unless the
// material has its own authored occlusion map - a white default map makes the
// occlusion strength slider a mathematical no-op.
if (_pictureMaterialInstance.HasProperty("_OcclusionMap")
&& !_pictureMaterial.GetTexture("_OcclusionMap"))
_pictureMaterialInstance.SetTexture("_OcclusionMap", _pictureHeightMap);
}
if (_pictureMaterialInstance.HasProperty("_OcclusionStrength"))
_pictureMaterialInstance.SetFloat("_OcclusionStrength", _occlusionStrength);
if (_pictureMaterialInstance.HasProperty("_TessellationStrength"))
_pictureMaterialInstance.SetFloat("_TessellationStrength", _tessellationStrength);
if (_pictureMaterialInstance.HasProperty("_OcclusionContrast"))
_pictureMaterialInstance.SetFloat("_OcclusionContrast", _occlusionContrast);
if (_pictureMaterialInstance.HasProperty("_Brightness"))
_pictureMaterialInstance.SetFloat("_Brightness", _brightness);
return _pictureMaterialInstance;
}
// Remaining UV window after the per-side crops (u: left->right, v: bottom->top). Opposing
// crops that would meet or invert collapse to a minimal centered span instead of degenerating.
void CropSpans(out float u0, out float u1, out float v0, out float v1)
{
u0 = Mathf.Clamp01(_cropLeft);
u1 = 1f - Mathf.Clamp01(_cropRight);
v0 = Mathf.Clamp01(_cropBottom);
v1 = 1f - Mathf.Clamp01(_cropTop);
const float minSpan = 0.01f;
if (u1 - u0 < minSpan)
{
float mid = (u0 + u1) * 0.5f;
u0 = mid - minSpan * 0.5f;
u1 = mid + minSpan * 0.5f;
}
if (v1 - v0 < minSpan)
{
float mid = (v0 + v1) * 0.5f;
v0 = mid - minSpan * 0.5f;
v1 = mid + minSpan * 0.5f;
}
}
#if UNITY_EDITOR
// Importer API for the source file's pixel size (public in Unity 6, internal in older
// versions - hence reflection; null-fallback is the imported texture size).
static readonly System.Reflection.MethodInfo s_sourceSize = typeof(UnityEditor.TextureImporter)
.GetMethod("GetSourceTextureWidthAndHeight",
System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.NonPublic
| System.Reflection.BindingFlags.Instance);
// Editor-only: derive _aspect (longer side = 1) from the assigned picture texture (falling
// back to the picture material's base map) and serialize it - the source image file is not
// available in a build. 1:1 whenever the whole chain breaks.
void ResolveAspect()
{
var aspect = Vector2.one;
Texture tex = _pictureTexture;
if (!tex && _pictureMaterial)
{
if (_pictureMaterial.HasProperty("_BaseMap")) tex = _pictureMaterial.GetTexture("_BaseMap");
if (!tex) tex = _pictureMaterial.mainTexture;
}
if (tex)
{
// Prefer the source file's pixel size: importer NPOT/max-size scaling can distort the
// imported texture's proportions (e.g. 4:3 photo -> power-of-two texture).
int w = tex.width;
int h = tex.height;
string path = UnityEditor.AssetDatabase.GetAssetPath(tex);
if (s_sourceSize != null && !string.IsNullOrEmpty(path)
&& UnityEditor.AssetImporter.GetAtPath(path) is UnityEditor.TextureImporter importer)
{
object[] size = { 0, 0 };
s_sourceSize.Invoke(importer, size);
if ((int)size[0] > 0 && (int)size[1] > 0)
{
w = (int)size[0];
h = (int)size[1];
}
}
if (w > 0 && h > 0)
{
// The frame follows the CROPPED region's proportions, not the full image's.
CropSpans(out float u0, out float u1, out float v0, out float v1);
float cw = w * (u1 - u0);
float ch = h * (v1 - v0);
aspect = cw >= ch ? new Vector2(1f, ch / cw) : new Vector2(cw / ch, 1f);
}
}
if ((aspect - _aspect).sqrMagnitude < 1e-12f) return;
_aspect = aspect;
UnityEditor.EditorUtility.SetDirty(this);
}
#endif
// Flat ring between two concentric rects at depth z, facing the viewer (-Z), mitred corners.
void Ring(int sub, float ox, float oy, float ix, float iy, float z)
{
if (oy - iy > 1e-5f)
{
QuadXY(sub, new Vector3(-ix, iy, z), new Vector3(ix, iy, z), new Vector3(ox, oy, z), new Vector3(-ox, oy, z));
QuadXY(sub, new Vector3(-ox, -oy, z), new Vector3(ox, -oy, z), new Vector3(ix, -iy, z), new Vector3(-ix, -iy, z));
}
if (ox - ix > 1e-5f)
{
QuadXY(sub, new Vector3(ix, -iy, z), new Vector3(ox, -oy, z), new Vector3(ox, oy, z), new Vector3(ix, iy, z));
QuadXY(sub, new Vector3(-ox, -oy, z), new Vector3(-ix, -iy, z), new Vector3(-ix, iy, z), new Vector3(-ox, oy, z));
}
}
void QuadXY(int sub, Vector3 a, Vector3 b, Vector3 c, Vector3 d)
=> Quad(sub, a, b, c, d, Vector3.back,
new Vector2(a.x, a.y), new Vector2(b.x, b.y), new Vector2(c.x, c.y), new Vector2(d.x, d.y));
// Vertical walls around rect (hx, hy) spanning z0..z1; sign -1 = facing inward (recess),
// +1 = facing outward (plinth). Winding follows the normals automatically.
void PerimeterWalls(int sub, float hx, float hy, float z0, float z1, float sign)
{
Quad(sub, new Vector3(-hx, hy, z0), new Vector3(hx, hy, z0), new Vector3(hx, hy, z1), new Vector3(-hx, hy, z1),
sign * Vector3.up, new Vector2(-hx, z0), new Vector2(hx, z0), new Vector2(hx, z1), new Vector2(-hx, z1));
Quad(sub, new Vector3(-hx, -hy, z0), new Vector3(hx, -hy, z0), new Vector3(hx, -hy, z1), new Vector3(-hx, -hy, z1),
sign * Vector3.down, new Vector2(-hx, z0), new Vector2(hx, z0), new Vector2(hx, z1), new Vector2(-hx, z1));
Quad(sub, new Vector3(hx, -hy, z0), new Vector3(hx, hy, z0), new Vector3(hx, hy, z1), new Vector3(hx, -hy, z1),
sign * Vector3.right, new Vector2(-hy, z0), new Vector2(hy, z0), new Vector2(hy, z1), new Vector2(-hy, z1));
Quad(sub, new Vector3(-hx, -hy, z0), new Vector3(-hx, hy, z0), new Vector3(-hx, hy, z1), new Vector3(-hx, -hy, z1),
sign * Vector3.left, new Vector2(-hy, z0), new Vector2(hy, z0), new Vector2(hy, z1), new Vector2(-hy, z1));
}
// Flat outer side faces, shortened by the rounding radius on every border.
void OuterSides(float w, float h, float d, float r)
{
float hx = w * 0.5f;
float hy = h * 0.5f;
Quad(SubFrame, new Vector3(hx, -hy + r, r), new Vector3(hx, hy - r, r), new Vector3(hx, hy - r, d - r), new Vector3(hx, -hy + r, d - r),
Vector3.right, new Vector2(-hy + r, r), new Vector2(hy - r, r), new Vector2(hy - r, d - r), new Vector2(-hy + r, d - r));
Quad(SubFrame, new Vector3(-hx, -hy + r, r), new Vector3(-hx, hy - r, r), new Vector3(-hx, hy - r, d - r), new Vector3(-hx, -hy + r, d - r),
Vector3.left, new Vector2(-hy + r, r), new Vector2(hy - r, r), new Vector2(hy - r, d - r), new Vector2(-hy + r, d - r));
Quad(SubFrame, new Vector3(-hx + r, hy, r), new Vector3(hx - r, hy, r), new Vector3(hx - r, hy, d - r), new Vector3(-hx + r, hy, d - r),
Vector3.up, new Vector2(-hx + r, r), new Vector2(hx - r, r), new Vector2(hx - r, d - r), new Vector2(-hx + r, d - r));
Quad(SubFrame, new Vector3(-hx + r, -hy, r), new Vector3(hx - r, -hy, r), new Vector3(hx - r, -hy, d - r), new Vector3(-hx + r, -hy, d - r),
Vector3.down, new Vector2(-hx + r, r), new Vector2(hx - r, r), new Vector2(hx - r, d - r), new Vector2(-hx + r, d - r));
}
// Rounded outer edges: 8 quarter-cylinder edge strips (front + back), 4 vertical corner
// quarter-cylinders and 8 sphere-octant corner caps.
void Bevels(float w, float h, float d, float r, int segs)
{
float hx = w * 0.5f;
float hy = h * 0.5f;
Vector3 front = Vector3.back;
Vector3 back = Vector3.forward;
EdgeBevel(new Vector3(-hx + r, hy - r, r), new Vector3(hx - r, hy - r, r), front, Vector3.up, r, segs);
EdgeBevel(new Vector3(-hx + r, -hy + r, r), new Vector3(hx - r, -hy + r, r), front, Vector3.down, r, segs);
EdgeBevel(new Vector3(hx - r, -hy + r, r), new Vector3(hx - r, hy - r, r), front, Vector3.right, r, segs);
EdgeBevel(new Vector3(-hx + r, -hy + r, r), new Vector3(-hx + r, hy - r, r), front, Vector3.left, r, segs);
EdgeBevel(new Vector3(-hx + r, hy - r, d - r), new Vector3(hx - r, hy - r, d - r), back, Vector3.up, r, segs);
EdgeBevel(new Vector3(-hx + r, -hy + r, d - r), new Vector3(hx - r, -hy + r, d - r), back, Vector3.down, r, segs);
EdgeBevel(new Vector3(hx - r, -hy + r, d - r), new Vector3(hx - r, hy - r, d - r), back, Vector3.right, r, segs);
EdgeBevel(new Vector3(-hx + r, -hy + r, d - r), new Vector3(-hx + r, hy - r, d - r), back, Vector3.left, r, segs);
for (int sx = -1; sx <= 1; sx += 2)
for (int sy = -1; sy <= 1; sy += 2)
{
var corner = new Vector3(sx * (hx - r), sy * (hy - r), 0f);
EdgeBevel(corner + new Vector3(0f, 0f, r), corner + new Vector3(0f, 0f, d - r),
new Vector3(sx, 0f, 0f), new Vector3(0f, sy, 0f), r, segs);
CornerOctant(corner + new Vector3(0f, 0f, r), sx, sy, -1f, r, segs);
CornerOctant(corner + new Vector3(0f, 0f, d - r), sx, sy, 1f, r, segs);
}
}
// Quarter-circle sweep from dirA (angle 0) to dirB (angle 90) along the centerline c0->c1.
void EdgeBevel(Vector3 c0, Vector3 c1, Vector3 dirA, Vector3 dirB, float r, int segs)
{
float along = Vector3.Distance(c0, c1);
for (int s = 0; s < segs; s++)
{
float a0 = Mathf.PI * 0.5f * s / segs;
float a1 = Mathf.PI * 0.5f * (s + 1) / segs;
Vector3 d0 = Mathf.Cos(a0) * dirA + Mathf.Sin(a0) * dirB;
Vector3 d1 = Mathf.Cos(a1) * dirA + Mathf.Sin(a1) * dirB;
Quad(SubFrame,
c0 + r * d0, c1 + r * d0, c1 + r * d1, c0 + r * d1,
d0, d0, d1, d1,
new Vector2(0f, r * a0), new Vector2(along, r * a0),
new Vector2(along, r * a1), new Vector2(0f, r * a1));
}
}
// Eighth-sphere cap where two edge bevels and a vertical corner bevel meet.
void CornerOctant(Vector3 c, float sx, float sy, float sz, float r, int segs)
{
Vector3 Dir(float alpha, float beta) => new Vector3(
sx * Mathf.Sin(alpha) * Mathf.Cos(beta),
sy * Mathf.Sin(alpha) * Mathf.Sin(beta),
sz * Mathf.Cos(alpha));
for (int ia = 0; ia < segs; ia++)
for (int ib = 0; ib < segs; ib++)
{
float a0 = Mathf.PI * 0.5f * ia / segs;
float a1 = Mathf.PI * 0.5f * (ia + 1) / segs;
float b0 = Mathf.PI * 0.5f * ib / segs;
float b1 = Mathf.PI * 0.5f * (ib + 1) / segs;
Vector3 d00 = Dir(a0, b0);
Vector3 d10 = Dir(a1, b0);
Vector3 d11 = Dir(a1, b1);
Vector3 d01 = Dir(a0, b1);
Quad(SubFrame,
c + r * d00, c + r * d10, c + r * d11, c + r * d01,
d00, d10, d11, d01,
new Vector2(r * b0, r * a0), new Vector2(r * b0, r * a1),
new Vector2(r * b1, r * a1), new Vector2(r * b1, r * a0));
}
}
void Quad(int sub, Vector3 a, Vector3 b, Vector3 c, Vector3 d, Vector3 n,
Vector2 uva, Vector2 uvb, Vector2 uvc, Vector2 uvd)
=> Quad(sub, a, b, c, d, n, n, n, n, uva, uvb, uvc, uvd);
// Corners a..d must form a simple loop; winding is derived from the normals, so callers never
// have to reason about triangle order.
void Quad(int sub, Vector3 a, Vector3 b, Vector3 c, Vector3 d,
Vector3 na, Vector3 nb, Vector3 nc, Vector3 nd,
Vector2 uva, Vector2 uvb, Vector2 uvc, Vector2 uvd)
{
int i = _verts.Count;
_verts.Add(a); _verts.Add(b); _verts.Add(c); _verts.Add(d);
_normals.Add(na); _normals.Add(nb); _normals.Add(nc); _normals.Add(nd);
_uvs.Add(uva); _uvs.Add(uvb); _uvs.Add(uvc); _uvs.Add(uvd);
Vector3 winding = Vector3.Cross(b - a, c - a);
if (winding.sqrMagnitude < 1e-12f) winding = Vector3.Cross(c - a, d - a);
var tris = _tris[sub];
if (Vector3.Dot(winding, na + nb + nc + nd) >= 0f)
{
tris.Add(i); tris.Add(i + 1); tris.Add(i + 2);
tris.Add(i); tris.Add(i + 2); tris.Add(i + 3);
}
else
{
tris.Add(i); tris.Add(i + 2); tris.Add(i + 1);
tris.Add(i); tris.Add(i + 3); tris.Add(i + 2);
}
}
}
}