Make picture frame properties relative and fix picture aspect ratio

Frame thickness, corner radius, padding, inner depth and picture depth
are now normalized factors (scale with Size, no dead clamp zones).
Picture aspect is computed inside-out so padding and frame thickness
no longer distort the image. New Frame Inner.mat as inner default.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Fable 5 committed 2026-07-09 21:32:19 +02:00
1 parent 07aef0c7b2
commit 6a15a68a2e
4 files changed
+244 -35

No files matched your search

+136
View File
@@ -0,0 +1,136 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!21 &2100000
Material:
serializedVersion: 8
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_Name: Frame Inner
m_Shader: {fileID: 4800000, guid: 023c98a32f6cc00478e50479839530c5, type: 3}
m_Parent: {fileID: 0}
m_ModifiedSerializedProperties: 0
m_ValidKeywords: []
m_InvalidKeywords: []
m_LightmapFlags: 4
m_EnableInstancingVariants: 0
m_DoubleSidedGI: 0
m_CustomRenderQueue: -1
stringTagMap:
RenderType: Opaque
disabledShaderPasses: []
m_LockedProperties:
m_SavedProperties:
serializedVersion: 3
m_TexEnvs:
- _BaseMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _BumpMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _DetailAlbedoMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _DetailMask:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _DetailNormalMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _EmissionMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _MainTex:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _MetallicGlossMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _OcclusionMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _ParallaxMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- _SpecGlossMap:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- unity_Lightmaps:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- unity_LightmapsInd:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
- unity_ShadowMasks:
m_Texture: {fileID: 0}
m_Scale: {x: 1, y: 1}
m_Offset: {x: 0, y: 0}
m_Ints: []
m_Floats:
- _AddPrecomputedVelocity: 0
- _AlphaClip: 0
- _AlphaToMask: 0
- _Blend: 0
- _BlendModePreserveSpecular: 1
- _BumpScale: 1
- _ClearCoatMask: 0
- _ClearCoatSmoothness: 0
- _Cull: 2
- _Cutoff: 0.5
- _DetailAlbedoMapScale: 1
- _DetailNormalMapScale: 1
- _DstBlend: 0
- _DstBlendAlpha: 0
- _EnvironmentReflections: 1
- _GlossMapScale: 0
- _Glossiness: 0
- _GlossyReflections: 0
- _Metallic: 0
- _OcclusionStrength: 1
- _Parallax: 0.005
- _QueueOffset: 0
- _ReceiveShadows: 1
- _Smoothness: 0
- _SmoothnessTextureChannel: 0
- _SpecularHighlights: 1
- _SrcBlend: 1
- _SrcBlendAlpha: 1
- _Surface: 0
- _WorkflowMode: 1
- _XRMotionVectorsPass: 1
- _ZWrite: 1
m_Colors:
- _BaseColor: {r: 0.8805031, g: 0.8805031, b: 0.8805031, a: 1}
- _Color: {r: 0.8805031, g: 0.8805031, b: 0.8805031, a: 1}
- _EmissionColor: {r: 0, g: 0, b: 0, a: 1}
- _SpecColor: {r: 0.19999996, g: 0.19999996, b: 0.19999996, a: 1}
m_BuildTextureStacks: []
m_AllowLocking: 1
--- !u!114 &6905797306856702536
MonoBehaviour:
m_ObjectHideFlags: 11
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 0}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: d0353a89b1f911e48b9e16bdc9f2e058, type: 3}
m_Name:
m_EditorClassIdentifier: Unity.RenderPipelines.Universal.Editor::UnityEditor.Rendering.Universal.AssetVersion
version: 10
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: bc0ac45503ad5c44b8b966da0d0a40d3
NativeFormatImporter:
externalObjects: {}
mainObjectFileID: 2100000
userData:
assetBundleName:
assetBundleVariant:
@@ -3,11 +3,13 @@ using UnityEngine;
namespace Chameleon.Components namespace Chameleon.Components
{ {
// Procedural picture frame: a box-profile frame with a recessed inner floor (FrameInnerDepth) and a // Procedural picture frame, generated live in the editor (and at runtime) from the inspector
// picture plane offset relative to it (PictureDepth: + sunken, - raised plinth), generated live in the // parameters. Sizing is inside-out: the picture keeps the texture's aspect ratio EXACTLY and the
// editor (and at runtime) from the inspector parameters - resize via Size (width/height follow the // outer frame grows around it by padding + thickness (Size = longer outer side) - insetting a
// picture texture's aspect ratio), NOT via transform scale, so the frame profile never stretches // uniform border from an aspect-scaled outer rect would distort the picture. All shape parameters
// (9-slice behaviour). Four submeshes with their own // are relative fractions (thickness/padding of the picture's longer side, radius of the maximum
// possible rounding, depths of the frame depth), so a frame keeps its proportions when resized via
// Size - 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 // 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. // sides, edge rounding), 3 back plate. Front faces -Z like Unity's built-in Quad.
// //
@@ -17,7 +19,11 @@ namespace Chameleon.Components
// //
// GenerateColliders maintains matching BoxColliders (corner radius ignored): 4 border bars, a slab // 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 // 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. // _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] [ExecuteAlways]
[RequireComponent(typeof(MeshFilter), typeof(MeshRenderer))] [RequireComponent(typeof(MeshFilter), typeof(MeshRenderer))]
public sealed class PictureFrameBuilder : MonoBehaviour public sealed class PictureFrameBuilder : MonoBehaviour
@@ -37,22 +43,22 @@ namespace Chameleon.Components
[SerializeField] Vector2 _aspect = Vector2.one; [SerializeField] Vector2 _aspect = Vector2.one;
[Header("Frame")] [Header("Frame")]
[Tooltip("Border width of the frame profile in the picture plane.")] [Tooltip("Border width of the frame as a fraction of the picture's longer side.")]
[SerializeField, Range(0.001f, 1.000f)] float _frameThickness = 0.08f; [SerializeField, Range(0f, 1f)] float _frameThickness = 0.05f;
[Tooltip("Depth of the frame, front face to back face.")] [Tooltip("Depth of the frame, front face to back face.")]
[SerializeField, Range(0.002f, 0.500f)] float _frameDepth = 0.12f; [SerializeField, Range(0.002f, 0.500f)] float _frameDepth = 0.05f;
[Tooltip("Rounding radius of the outer frame edges; 0 = sharp box. Clamped to thickness / half depth.")] [Tooltip("Rounding of the outer frame edges relative to the maximum possible radius (thickness / half depth); 0 = sharp box, 1 = fully rounded.")]
[SerializeField, Range(0.000f, 0.500f)] float _cornerRadius; [SerializeField, Range(0f, 1f)] float _cornerRadius = 0.2f;
[Tooltip("Arc segments of the edge rounding.")] [Tooltip("Arc segments of the edge rounding.")]
[SerializeField, Range(1, 8)] int _bevelSegments = 4; [SerializeField, Range(1, 8)] int _bevelSegments = 4;
[Tooltip("How deep behind the front face the inner floor (padding ring) sits.")] [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(0.000f, 0.498f)] float _frameInnerDepth = 0.06f; [SerializeField, Range(-1f, 1f)] float _frameInnerDepth = -0.1f;
[Header("Picture")] [Header("Picture")]
[Tooltip("Gap between the frame's inner edge and the picture (visible inner-material ring).")] [Tooltip("Gap between the frame's inner edge and the picture as a fraction of the picture's longer side.")]
[SerializeField, Range(0.000f, 1.000f)] float _padding = 0.02f; [SerializeField, Range(0f, 1f)] float _padding = 0.01f;
[Tooltip("Picture plane relative to the inner floor: positive sinks it into the frame, negative raises it out like a plinth, 0 = flush.")] [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(-0.500f, 0.500f)] float _pictureDepth; [SerializeField, Range(-1f, 1f)] float _pictureDepth = 0.1f;
[Header("Materials")] [Header("Materials")]
[SerializeField] Material _pictureMaterial; [SerializeField] Material _pictureMaterial;
@@ -65,6 +71,7 @@ namespace Chameleon.Components
[SerializeField] bool _generateColliders = true; [SerializeField] bool _generateColliders = true;
// Ownership list so rebuilds never touch colliders the user added by hand. // Ownership list so rebuilds never touch colliders the user added by hand.
[SerializeField, HideInInspector] List<BoxCollider> _colliders = new List<BoxCollider>(); [SerializeField, HideInInspector] List<BoxCollider> _colliders = new List<BoxCollider>();
[SerializeField, HideInInspector] Transform _colliderRoot;
readonly List<Vector3> _verts = new List<Vector3>(); readonly List<Vector3> _verts = new List<Vector3>();
readonly List<Vector3> _normals = new List<Vector3>(); readonly List<Vector3> _normals = new List<Vector3>();
@@ -117,19 +124,30 @@ namespace Chameleon.Components
#if UNITY_EDITOR #if UNITY_EDITOR
ResolveAspect(); ResolveAspect();
#endif #endif
float w = Mathf.Max(_size * _aspect.x, 0.01f); // Inside-out sizing: the picture keeps the texture aspect exactly; thickness and padding
float h = Mathf.Max(_size * _aspect.y, 0.01f); // are fractions of the picture's longer side, and Size stays the longer OUTER side:
float t = Mathf.Clamp(_frameThickness, 0.001f, Mathf.Min(w, h) * 0.5f - 0.001f); // size = pLong + 2*(t + pad) = pLong * (1 + thickness + padding). No clamp dead zones -
// every 0..1 combination yields valid geometry.
float d = Mathf.Max(_frameDepth, 0.002f); float d = Mathf.Max(_frameDepth, 0.002f);
float r = Mathf.Min(_cornerRadius, Mathf.Min(t, d * 0.5f) - 0.0005f); float pLong = Mathf.Max(_size, 0.01f) / (1f + _frameThickness + _padding);
float t = Mathf.Max(_frameThickness * pLong * 0.5f, 0.001f);
float pad = _padding * pLong * 0.5f;
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; if (r < 1e-4f) r = 0f;
float ow = w - 2f * t; // Depths are fractions of the frame depth. Inner floor: -1 = back plate .. +1 = a full
float oh = h - 2f * t; // frame depth in front of the face. Picture: interpolates from the back plate (-1) over
float pad = Mathf.Max(0f, Mathf.Min(_padding, Mathf.Min(ow, oh) * 0.5f - 0.001f)); // the inner floor (0) to a full frame depth in front (+1) - endpoints are absolute so
float pw = Mathf.Max(ow - 2f * pad, 0.001f); // the extremes always mean "all the way in/out" regardless of where the floor sits.
float ph = Mathf.Max(oh - 2f * pad, 0.001f); float id = -_frameInnerDepth * d;
float id = Mathf.Clamp(_frameInnerDepth, 0f, d); float pz = _pictureDepth < 0f
float pz = Mathf.Min(id + _pictureDepth, d); ? Mathf.Lerp(id, d, -_pictureDepth)
: Mathf.Lerp(id, -d, _pictureDepth);
_verts.Clear(); _verts.Clear();
_normals.Clear(); _normals.Clear();
@@ -148,9 +166,11 @@ namespace Chameleon.Components
if (Mathf.Abs(pz - id) > 1e-4f) 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); 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 + recess walls from the opening down to it. // 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); Ring(SubInner, ow * 0.5f, oh * 0.5f, pw * 0.5f, ph * 0.5f, id);
if (id > 1e-4f) PerimeterWalls(SubInner, ow * 0.5f, oh * 0.5f, 0f, id, -1f); 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. // 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); Ring(SubFrame, w * 0.5f - r, h * 0.5f - r, ow * 0.5f, oh * 0.5f, 0f);
@@ -185,6 +205,22 @@ namespace Chameleon.Components
void RebuildColliders(float w, float h, float t, float d, float ow, float oh, float pw, float ph, float id, float pz) 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); _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; int count = 0;
if (_generateColliders) if (_generateColliders)
{ {
@@ -206,20 +242,48 @@ namespace Chameleon.Components
{ {
var extra = _colliders[i]; var extra = _colliders[i];
_colliders.RemoveAt(i); _colliders.RemoveAt(i);
if (!extra) continue; if (extra) DestroyOwned(extra);
if (Application.isPlaying) Destroy(extra); }
else DestroyImmediate(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) void SetBox(int index, Vector3 center, Vector3 size)
{ {
while (_colliders.Count <= index) _colliders.Add(gameObject.AddComponent<BoxCollider>()); while (_colliders.Count <= index)
_colliders.Add(EnsureColliderRoot().gameObject.AddComponent<BoxCollider>());
var box = _colliders[index]; var box = _colliders[index];
if (box.center != center) box.center = center; if (box.center != center) box.center = center;
if (box.size != size) box.size = size; 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) void EnsureMesh(MeshFilter filter)
{ {
string meshName = "PictureFrame " + GetInstanceID(); string meshName = "PictureFrame " + GetInstanceID();
@@ -5,9 +5,10 @@ MonoImporter:
serializedVersion: 2 serializedVersion: 2
defaultReferences: defaultReferences:
- _pictureMaterial: {instanceID: 0} - _pictureMaterial: {instanceID: 0}
- _innerMaterial: {fileID: 2100000, guid: a7794896ef878a64399cca929db5c43c, type: 2} - _innerMaterial: {fileID: 2100000, guid: bc0ac45503ad5c44b8b966da0d0a40d3, type: 2}
- _frameMaterial: {fileID: 2100000, guid: a7794896ef878a64399cca929db5c43c, type: 2} - _frameMaterial: {fileID: 2100000, guid: a7794896ef878a64399cca929db5c43c, type: 2}
- _backMaterial: {fileID: 2100000, guid: a7794896ef878a64399cca929db5c43c, type: 2} - _backMaterial: {fileID: 2100000, guid: a7794896ef878a64399cca929db5c43c, type: 2}
- _colliderRoot: {instanceID: 0}
executionOrder: 0 executionOrder: 0
icon: {instanceID: 0} icon: {instanceID: 0}
userData: userData: