feat(peipei-eye): 新增视口椭圆遮罩与眨眼效果

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-30 13:55:57 +08:00
co-authored by Cursor
parent 488149e8d4
commit 04b441d977
8 changed files with 1790 additions and 1077 deletions
+131
View File
@@ -0,0 +1,131 @@
Shader "UI/EyeViewportOverlay"
{
Properties
{
[PerRendererData] _MainTex ("Sprite Texture", 2D) = "white" {}
_Color ("Tint", Color) = (0, 0, 0, 1)
[Header(Eye Opening)]
_EyeWidth ("Eye Width", Range(0.22, 0.58)) = 0.46
_EyeHeight ("Eye Height", Range(0.15, 0.72)) = 0.28
_EdgeSoftness ("Edge Softness", Range(0.001, 0.2)) = 0.022
_BlinkOpen ("Blink Open", Range(0, 1)) = 1
_StencilComp ("Stencil Comparison", Float) = 8
_Stencil ("Stencil ID", Float) = 0
_StencilOp ("Stencil Operation", Float) = 0
_StencilWriteMask ("Stencil Write Mask", Float) = 255
_StencilReadMask ("Stencil Read Mask", Float) = 255
_ColorMask ("Color Mask", Float) = 15
}
SubShader
{
Tags
{
"Queue" = "Transparent"
"IgnoreProjector" = "True"
"RenderType" = "Transparent"
"PreviewType" = "Plane"
"CanUseSpriteAtlas" = "True"
}
Stencil
{
Ref [_Stencil]
Comp [_StencilComp]
Pass [_StencilOp]
ReadMask [_StencilReadMask]
WriteMask [_StencilWriteMask]
}
Cull Off
Lighting Off
ZWrite Off
ZTest [unity_GUIZTestMode]
Blend SrcAlpha OneMinusSrcAlpha
ColorMask [_ColorMask]
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#pragma target 2.0
#include "UnityCG.cginc"
#include "UnityUI.cginc"
struct appdata
{
float4 vertex : POSITION;
float4 color : COLOR;
float2 uv : TEXCOORD0;
};
struct v2f
{
float4 vertex : SV_POSITION;
fixed4 color : COLOR;
float2 uv : TEXCOORD0;
float4 worldPosition : TEXCOORD1;
};
sampler2D _MainTex;
fixed4 _Color;
float4 _ClipRect;
float _EyeWidth;
float _EyeHeight;
float _EdgeSoftness;
float _BlinkOpen;
v2f vert(appdata v)
{
v2f o;
o.worldPosition = v.vertex;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = v.uv;
o.color = v.color * _Color;
return o;
}
float FeatherBoundaryAlpha(float insideHard, float edgeDist, float softness)
{
float outside = 1.0 - insideHard;
float edgeFeather = (1.0 - smoothstep(0.0, softness, edgeDist)) * insideHard;
return saturate(outside + edgeFeather);
}
float ComputeViewportAlpha(float2 uv)
{
float2 c = uv - 0.5;
float softness = max(0.001, _EdgeSoftness);
float blinkOpen = saturate(_BlinkOpen);
if (blinkOpen <= 0.001)
{
return 1.0;
}
float rx = max(_EyeWidth, 0.001);
float ry = max(_EyeHeight * blinkOpen, 0.001);
float ellipseDist = length(float2(c.x / rx, c.y / ry));
float insideHard = 1.0 - step(1.0, ellipseDist);
float edgeDist = (1.0 - ellipseDist) * insideHard;
return FeatherBoundaryAlpha(insideHard, edgeDist, softness);
}
fixed4 frag(v2f i) : SV_Target
{
float alpha = ComputeViewportAlpha(i.uv);
fixed4 col = fixed4(i.color.rgb, i.color.a * alpha);
col.a *= UnityGet2DClipping(i.worldPosition.xy, _ClipRect);
return col;
}
ENDCG
}
}
Fallback Off
}
@@ -0,0 +1,9 @@
fileFormatVersion: 2
guid: 6652ed847ed1bc641bd702f03c650adf
ShaderImporter:
externalObjects: {}
defaultTextures: []
nonModifiableTextures: []
userData:
assetBundleName:
assetBundleVariant:
File diff suppressed because it is too large Load Diff
@@ -7,6 +7,9 @@ namespace AibisDream
[CustomEditor(typeof(EyeSystem))]
public class EyeSystemEditor : Editor
{
private bool editorOverlayPreview;
private float editorBlinkPreview = 1f;
public override void OnInspectorGUI()
{
serializedObject.Update();
@@ -29,11 +32,13 @@ namespace AibisDream
EditorGUILayout.PropertyField(replayOnChange);
EditorGUILayout.HelpBox(
"Blur / Reveal 扫过 / Wrong Color / HSV:改完即写入材质。\n" +
"时长 / colorReveal 范围:Play 下需勾选「自动重播」或点「重播当前目标效果」。\n" +
"Edit 模式:改参数后会自动把所有目标重置为 idle(灰+糊)预览。",
"常驻视线 = 椭圆(eyeWidth / eyeHeight。\n" +
"眨眼 = 同一椭圆纵向压扁(BlinkOpen 1→0),不是另一套眼皮形状。\n" +
"用「BlinkOpen 预览」滑条看闭合;eyeWidth / eyeHeight 在 BlinkOpen=1 时调。",
MessageType.Info);
DrawViewportOverlayControls(visualChanged);
EditorGUILayout.BeginHorizontal();
if (GUILayout.Button("立即应用(Idle 预览)"))
{
@@ -55,6 +60,12 @@ namespace AibisDream
system.ApplyVisualSettingsNow(
previewIdleState: !Application.isPlaying,
replayFocusedEffect: Application.isPlaying && replayOnChange.boolValue);
if (editorOverlayPreview)
{
RefreshEditorOverlayPreview(system);
}
return;
}
}
@@ -68,10 +79,107 @@ namespace AibisDream
serializedObject.ApplyModifiedProperties();
}
private void DrawViewportOverlayControls(bool visualChanged)
{
EditorGUILayout.Space(4f);
EditorGUILayout.LabelField("Viewport Overlay 预览", EditorStyles.boldLabel);
EditorGUI.BeginChangeCheck();
editorOverlayPreview = EditorGUILayout.Toggle("Edit 模式预览遮罩", editorOverlayPreview);
if (EditorGUI.EndChangeCheck() || visualChanged)
{
serializedObject.ApplyModifiedProperties();
var system = GetEyeSystem();
if (editorOverlayPreview)
{
RefreshEditorOverlayPreview(system);
}
else
{
EnsureOverlay(system).ExitEditorPreview();
editorBlinkPreview = 1f;
}
}
GUI.enabled = editorOverlayPreview;
EditorGUI.BeginChangeCheck();
editorBlinkPreview = EditorGUILayout.Slider("BlinkOpen 预览(眨眼)", editorBlinkPreview, 0f, 1f);
if (EditorGUI.EndChangeCheck())
{
var overlay = EnsureOverlay(GetEyeSystem());
overlay.SetBlinkOpenPreview(editorBlinkPreview);
}
EditorGUILayout.BeginHorizontal();
if (GUILayout.Button("BlinkOpen = 睁开"))
{
editorBlinkPreview = 1f;
EnsureOverlay(GetEyeSystem()).SetBlinkOpenPreview(1f);
}
if (GUILayout.Button("BlinkOpen = 闭合"))
{
editorBlinkPreview = 0f;
EnsureOverlay(GetEyeSystem()).SetBlinkOpenPreview(0f);
}
EditorGUILayout.EndHorizontal();
GUI.enabled = true;
EditorGUILayout.BeginHorizontal();
GUI.enabled = Application.isPlaying;
if (GUILayout.Button("测试眨眼动画"))
{
serializedObject.ApplyModifiedProperties();
var overlay = EnsureOverlay(GetEyeSystem());
editorBlinkPreview = 1f;
overlay.SetBlinkOpenPreview(1f);
overlay.PlayBlink();
}
GUI.enabled = true;
if (GUILayout.Button("刷新遮罩参数"))
{
serializedObject.ApplyModifiedProperties();
RefreshEditorOverlayPreview(GetEyeSystem());
}
EditorGUILayout.EndHorizontal();
}
private void RefreshEditorOverlayPreview(EyeSystem system)
{
var overlay = EnsureOverlay(system);
overlay.EnterEditorPreview(system.VisualSettings);
overlay.SetBlinkOpenPreview(editorBlinkPreview);
}
private static EyeViewportOverlay EnsureOverlay(EyeSystem system)
{
var overlay = system.GetComponentInChildren<EyeViewportOverlay>(true);
if (overlay == null)
{
overlay = system.gameObject.AddComponent<EyeViewportOverlay>();
}
return overlay;
}
private EyeSystem GetEyeSystem()
{
return (EyeSystem)target;
}
private void OnDisable()
{
if (!editorOverlayPreview || target == null)
{
return;
}
editorOverlayPreview = false;
editorBlinkPreview = 1f;
var system = target as EyeSystem;
system?.GetComponentInChildren<EyeViewportOverlay>(true)?.ExitEditorPreview();
}
}
}
#endif
@@ -36,6 +36,8 @@ namespace AibisDream
[Header("Visual Tuning")]
[SerializeField] private EyeVisualSettings visualSettings = new();
[SerializeField] private EyeViewportOverlay viewportOverlay;
[Tooltip("Play 模式下改 Visual Tuning 后,自动对当前目标重播一次 Focused 效果(如 ColorIn)。")]
[SerializeField] private bool replayFocusedEffectOnSettingsChange = true;
@@ -54,13 +56,17 @@ namespace AibisDream
{
ApplyVisualSettingsNow(
previewIdleState: !Application.isPlaying,
replayFocusedEffect: Application.isPlaying && replayFocusedEffectOnSettingsChange);
replayFocusedEffect: Application.isPlaying && replayFocusedEffectOnSettingsChange,
touchViewportOverlay: false);
}
/// <summary>
/// 将 Visual Tuning 推送到所有 EyeTarget。Edit 模式预览 idle;Play 模式可选重播当前目标效果。
/// </summary>
public void ApplyVisualSettingsNow(bool previewIdleState = false, bool replayFocusedEffect = false)
public void ApplyVisualSettingsNow(
bool previewIdleState = false,
bool replayFocusedEffect = false,
bool touchViewportOverlay = true)
{
if (targets == null)
{
@@ -81,6 +87,11 @@ namespace AibisDream
}
}
if (touchViewportOverlay && Application.isPlaying)
{
EnsureViewportOverlay()?.ApplySettings(visualSettings);
}
if (!replayFocusedEffect || !Application.isPlaying || currentTarget == null || eyeStateEffect == null)
{
return;
@@ -109,6 +120,22 @@ namespace AibisDream
Init();
}
private EyeViewportOverlay EnsureViewportOverlay()
{
if (viewportOverlay != null)
{
return viewportOverlay;
}
viewportOverlay = GetComponentInChildren<EyeViewportOverlay>(true);
if (viewportOverlay == null)
{
viewportOverlay = gameObject.AddComponent<EyeViewportOverlay>();
}
return viewportOverlay;
}
private bool EnsureMouseFollowAndZoom()
{
if (mouseFollowAndZoom != null)
@@ -199,6 +226,7 @@ namespace AibisDream
mouseFollowAndZoom.ResetPanOrigin();
ApplyVisualSettingsToTargets();
InitializeAllTargets();
EnsureViewportOverlay()?.EnterModule(visualSettings);
}
private void InitializeAllTargets()
@@ -220,6 +248,8 @@ namespace AibisDream
public void OnExit()
{
EnsureViewportOverlay()?.ExitModule();
if (currentTarget != null)
{
eyeStateEffect?.CleanupEffect(CurrentTarget);
@@ -256,6 +286,12 @@ namespace AibisDream
StartCoroutine(SetTargetCoroutine(newTarget.gameObject.name));
}
[YarnCommand("EyeBlink")]
public void EyeBlink(float duration = 0f)
{
EnsureViewportOverlay()?.PlayBlink(duration > 0f ? duration : null);
}
[YarnCommand("set_Eyetarget")]
public IEnumerator SetTargetCoroutine(string targetName, bool startDialogue = true)
{
@@ -0,0 +1,371 @@
using System.Collections;
using DG.Tweening;
using UnityEngine;
using UnityEngine.UI;
namespace AibisDream
{
/// <summary>
/// Mobile-friendly fullscreen eye viewport: elliptical sight + vertical ellipse blink.
/// </summary>
[DisallowMultipleComponent]
[ExecuteAlways]
public class EyeViewportOverlay : MonoBehaviour
{
private static readonly int EyeWidthId = Shader.PropertyToID("_EyeWidth");
private static readonly int EyeHeightId = Shader.PropertyToID("_EyeHeight");
private static readonly int EdgeSoftnessId = Shader.PropertyToID("_EdgeSoftness");
private static readonly int BlinkOpenId = Shader.PropertyToID("_BlinkOpen");
[SerializeField] private int sortingOrder = 120;
private Canvas overlayCanvas;
private Image overlayImage;
private Material instanceMaterial;
private EyeVisualSettings activeSettings;
private Coroutine autoBlinkRoutine;
private Tween blinkTween;
private float blinkOpen = 1f;
private bool moduleActive;
private bool editorPreviewActive;
private bool hierarchyReady;
private bool lifecycleReady;
private Coroutine deferPrepareRoutine;
private void Start()
{
lifecycleReady = true;
if (!Application.isPlaying)
{
return;
}
PrepareHierarchyIfNeeded();
SetOverlayActive(false);
}
private void OnDestroy()
{
StopEffects();
if (instanceMaterial != null)
{
Destroy(instanceMaterial);
}
}
public void EnterModule(EyeVisualSettings settings)
{
moduleActive = true;
editorPreviewActive = false;
ShowOverlay(settings);
}
public void ExitModule()
{
moduleActive = false;
if (editorPreviewActive)
{
return;
}
StopEffects();
SetOverlayActive(false);
activeSettings = null;
}
public void EnterEditorPreview(EyeVisualSettings settings)
{
editorPreviewActive = true;
ShowOverlay(settings);
}
public void ExitEditorPreview()
{
editorPreviewActive = false;
if (moduleActive)
{
ShowOverlay(activeSettings);
return;
}
StopEffects();
SetOverlayActive(false);
activeSettings = null;
}
public void ApplySettings(EyeVisualSettings settings)
{
if (settings == null)
{
return;
}
if (!settings.enableViewportOverlay)
{
if (editorPreviewActive)
{
ExitEditorPreview();
}
else if (moduleActive)
{
ExitModule();
}
return;
}
PrepareHierarchyIfNeeded();
if (instanceMaterial == null)
{
return;
}
activeSettings = settings;
PushViewportParams(settings);
SetBlinkOpen(blinkOpen);
if (!overlayCanvas.gameObject.activeInHierarchy)
{
return;
}
if (settings.autoBlink && Application.isPlaying && (moduleActive || editorPreviewActive))
{
StartAutoBlink(settings);
}
else
{
StopAutoBlink();
}
}
public Tween PlayBlink(float? totalDuration = null)
{
if (instanceMaterial == null || activeSettings == null)
{
return null;
}
blinkTween?.Kill();
float closeDuration = activeSettings.blinkCloseDuration;
float openDuration = activeSettings.blinkOpenDuration;
if (totalDuration.HasValue)
{
float total = Mathf.Max(0.05f, totalDuration.Value);
closeDuration = total * 0.42f;
openDuration = total * 0.58f;
}
blinkTween = DOTween.Sequence()
.Append(CreateBlinkTween(blinkOpen, 0f, closeDuration, Ease.InQuad))
.Append(CreateBlinkTween(0f, 1f, openDuration, Ease.OutQuad))
.OnComplete(() => SetBlinkOpen(1f));
return blinkTween;
}
private void ShowOverlay(EyeVisualSettings settings)
{
if (settings == null || !settings.enableViewportOverlay)
{
ExitModule();
return;
}
PrepareHierarchyIfNeeded();
activeSettings = settings;
ApplySettings(settings);
if (!editorPreviewActive)
{
SetBlinkOpen(1f);
}
SetOverlayActive(true);
if (settings.autoBlink && Application.isPlaying)
{
StartAutoBlink(settings);
}
}
private void PushViewportParams(EyeVisualSettings settings)
{
instanceMaterial.SetFloat(EyeWidthId, settings.eyeWidth);
instanceMaterial.SetFloat(EyeHeightId, settings.eyeHeight);
instanceMaterial.SetFloat(EdgeSoftnessId, settings.edgeSoftness);
}
private Tween CreateBlinkTween(float from, float to, float duration, Ease ease)
{
blinkOpen = from;
SetBlinkOpen(from);
return DOTween.To(() => blinkOpen, value =>
{
blinkOpen = value;
SetBlinkOpen(value);
}, to, duration).SetEase(ease);
}
private void StartAutoBlink(EyeVisualSettings settings)
{
StopAutoBlink();
if (!settings.autoBlink)
{
return;
}
autoBlinkRoutine = StartCoroutine(AutoBlinkRoutine(settings));
}
private IEnumerator AutoBlinkRoutine(EyeVisualSettings settings)
{
while (enabled && overlayCanvas != null && overlayCanvas.gameObject.activeInHierarchy)
{
float min = Mathf.Max(1f, settings.autoBlinkInterval.x);
float max = Mathf.Max(min, settings.autoBlinkInterval.y);
yield return new WaitForSeconds(Random.Range(min, max));
Tween blink = PlayBlink();
if (blink != null)
{
yield return blink.WaitForCompletion();
}
}
}
private void StopEffects()
{
blinkTween?.Kill();
blinkTween = null;
StopAutoBlink();
}
private void StopAutoBlink()
{
if (autoBlinkRoutine == null)
{
return;
}
StopCoroutine(autoBlinkRoutine);
autoBlinkRoutine = null;
}
public void SetBlinkOpenPreview(float value)
{
SetBlinkOpen(value);
}
public float GetBlinkOpenPreview() => blinkOpen;
private void SetBlinkOpen(float value)
{
blinkOpen = Mathf.Clamp01(value);
instanceMaterial?.SetFloat(BlinkOpenId, blinkOpen);
}
private void SetOverlayActive(bool active)
{
if (overlayCanvas != null)
{
overlayCanvas.gameObject.SetActive(active);
}
}
private void PrepareHierarchyIfNeeded()
{
if (hierarchyReady && overlayCanvas != null)
{
return;
}
if (Application.isPlaying && !lifecycleReady)
{
ScheduleDeferredPrepare();
return;
}
EnsureOverlayHierarchy();
hierarchyReady = overlayCanvas != null;
}
private void ScheduleDeferredPrepare()
{
if (deferPrepareRoutine != null || !isActiveAndEnabled)
{
return;
}
deferPrepareRoutine = StartCoroutine(DeferredPrepareHierarchy());
}
private IEnumerator DeferredPrepareHierarchy()
{
yield return null;
deferPrepareRoutine = null;
EnsureOverlayHierarchy();
hierarchyReady = overlayCanvas != null;
if (activeSettings != null && instanceMaterial != null)
{
PushViewportParams(activeSettings);
SetBlinkOpen(blinkOpen);
}
}
private void EnsureOverlayHierarchy()
{
if (overlayCanvas != null)
{
return;
}
var canvasGo = new GameObject("EyeViewportOverlayCanvas");
canvasGo.transform.SetParent(transform, false);
overlayCanvas = canvasGo.AddComponent<Canvas>();
overlayCanvas.renderMode = RenderMode.ScreenSpaceOverlay;
overlayCanvas.sortingOrder = sortingOrder;
overlayCanvas.pixelPerfect = false;
var scaler = canvasGo.AddComponent<CanvasScaler>();
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
scaler.referenceResolution = new Vector2(1920f, 1080f);
scaler.matchWidthOrHeight = 0.5f;
canvasGo.AddComponent<GraphicRaycaster>();
var imageGo = new GameObject("Overlay", typeof(RectTransform));
imageGo.transform.SetParent(canvasGo.transform, false);
var rect = (RectTransform)imageGo.transform;
rect.anchorMin = Vector2.zero;
rect.anchorMax = Vector2.one;
rect.offsetMin = Vector2.zero;
rect.offsetMax = Vector2.zero;
overlayImage = imageGo.AddComponent<Image>();
overlayImage.raycastTarget = false;
overlayImage.color = Color.black;
SetupMaterial();
}
private void SetupMaterial()
{
Shader shader = Shader.Find("UI/EyeViewportOverlay");
if (shader == null)
{
Debug.LogError("[EyeViewportOverlay] 找不到 UI/EyeViewportOverlay shader。", this);
return;
}
instanceMaterial = new Material(shader)
{
name = "EyeViewportOverlay (Instance)",
hideFlags = HideFlags.DontSave
};
overlayImage.material = instanceMaterial;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b95035b93d484ef4da0b45fcf2c2a380
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -58,6 +58,25 @@ namespace AibisDream
public float roundWaveOutDuration = 0.5f;
public float glitchOutDuration = 0.5f;
[Header("Viewport Overlay (no post-processing)")]
public bool enableViewportOverlay = true;
[Tooltip("Horizontal half-size of the resting sight almond (left/right darkness).")]
[Range(0.22f, 0.58f)] public float eyeWidth = 0.46f;
[Tooltip("Vertical half-size of the resting sight almond (top/bottom darkness).")]
[Range(0.15f, 0.72f)] public float eyeHeight = 0.28f;
[Tooltip("Softness of the elliptical sight boundary.")]
[Range(0.001f, 0.2f)] public float edgeSoftness = 0.022f;
[Tooltip("Off by default so you can tune the sight without blinking.")]
public bool autoBlink;
public Vector2 autoBlinkInterval = new(8f, 15f);
public float blinkCloseDuration = 0.09f;
public float blinkOpenDuration = 0.14f;
public float GetDurationForState(EyeSystem.EyeColorState state) => state switch
{
EyeSystem.EyeColorState.CanImagineColor => imagineColorDuration,