art(huoshan): 屏幕glitch改荧光体并支持愤怒红

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-25 17:28:35 +08:00
co-authored by Cursor
parent a1afc021f1
commit cfacff9943
3 changed files with 124 additions and 50 deletions
@@ -32,9 +32,11 @@ Material:
- _BloomBoost: 0.6
- _Chaos: 0.5
- _ColorDamage: 1
- _Emissive: 1.5
- _Ghost: 0.35
- _GlitchIntensity: 0
- _Grit: 1
- _Rage: 0
- _Scale: 1.6
- _Speed: 1
- _SurgeRate: 0.5
@@ -42,4 +44,6 @@ Material:
- _Warp: 1
m_Colors:
- _Color: {r: 1, g: 1, b: 1, a: 1}
- _PhosphorColor: {r: 0.44716978, g: 1, b: 0.74689186, a: 1}
- _RageColor: {r: 1, g: 0.188, b: 0.302, a: 1}
m_BuildTextureStacks: []
+115 -49
View File
@@ -3,16 +3,20 @@
// 火山「屏幕」故障:一台坏掉的电视。
//
// 上一版的教训:把原画面整个换成调色板生成的彩虹信号,出来是"彩虹汤",
// 不是"电视坏了"。这版反过来——底下的画面永远保留,损坏全部叠在它上面,
// 语汇只用真实电视的故障:
// · 场同步失锁:整幅画面向上滚,接缝处一条消隐黑条,条附近画面横向弯折
// · 行同步误差:逐行细抖 + 成带的横向撕裂(带内亮度/色相跟着错)
// · 色度不稳:色相漂移 + 色度增益忽大忽小(那块绿会往黄/青方向晃)
// · 雪花:静噪颗粒、行级雪花条;「掉台」时整屏塌成雪花再闪回
// · 拟真骨架:扫描线、隔行闪烁、亮点划痕、亮度泵、暗角
// 三条原则:
// 1. 屏幕是发光体,不是贴图。贴图固有色被抛弃,只取亮度结构,颜色由
// _PhosphorColor 荧光色给出;哪怕 intensity=0 也有荧光面、刷新暗带、
// 微颤、待机扫描线——屏幕平时就"通着电"。超过 1 的亮度交给场景 Bloom。
// 2. 坏掉丢的是「画面」不是屏幕。信号越差画面内容越暗淡(偶尔挣扎着闪回),
// 顶上来的是电子枪自己画的东西:灰蓝雪花、黑条、回扫亮痕——所以高损坏时
// 屏幕不再是绿色。
// 3. 升级感靠新症状而不是"同样的东西更多"。强度分三段解锁:
// 低档:色相不稳 + 逐行细抖;中档:撕裂带 + 滚屏;高档:掉台(塌成雪花再闪回)。
//
// 仍然只有 6 个宏观旋钮 + 3 个基调,内部系数全部从它们推导。
// 颜色一律不要从色轮 / HSV 里取纯 R/G/B/C/M/Y——纯色块是卡通感的唯一来源,
// 这条踩过两次。偏色只能来自"三个通道对不齐",或 YIQ 上的连续漂移。
//
// 仍然只有 6 个宏观旋钮 + 基调,内部系数全部从它们推导。
// 不要再往外暴露单项参数:可调 ≠ 能调。
// _Scale 效果尺度(屏幕小就调大,扫描线/雪花跟着变粗)
// _ColorDamage 色彩损伤(色相漂移 + 串色 + 彩色镶边)
@@ -21,8 +25,7 @@
// _Grit 雪花与扫描线
// _SurgeRate 情绪节奏
//
// 密度一律按屏幕像素计(fwidth 换算)再乘 _Scale,屏幕物件很小时调大
// _Scale 即可,不会被 UV 空间频率挤成细线。
// 密度一律按屏幕像素计(fwidth 换算)再乘 _Scale
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
struct Attributes
@@ -45,7 +48,10 @@ SAMPLER(sampler_MainTex);
CBUFFER_START(UnityPerMaterial)
float4 _MainTex_ST;
float4 _Color;
float4 _PhosphorColor;
float4 _RageColor;
float _GlitchIntensity;
float _Rage;
float _Speed;
float _Scale;
float _ColorDamage;
@@ -54,6 +60,7 @@ CBUFFER_START(UnityPerMaterial)
float _Grit;
float _SurgeRate;
float _Ghost;
float _Emissive;
float _BloomBoost;
float _Vignette;
CBUFFER_END
@@ -85,6 +92,11 @@ float vnoise(float2 p)
f.y);
}
float Luma(half3 c)
{
return dot(c, half3(0.299, 0.587, 0.114));
}
// NTSC 味道的色彩不稳定都在 YIQ 空间做:Y 是亮度,IQ 是色度平面。
// 色相漂移 = 旋转 IQ,色度增益不稳 = 缩放 IQ,亮度细节始终不动。
float3 RGBToYIQ(float3 c)
@@ -103,13 +115,49 @@ float3 YIQToRGB(float3 c)
c.x - 1.106 * c.y + 1.703 * c.z);
}
// 彩色镶边:R/B 通道横向拉开。串色最便宜也最"模拟信号"的一层
half3 SampleFringed(float2 uv, float fringe)
// 荧光采样:三个通道各取一次横向错开的「亮度」,再乘荧光色
// 固有色被抛弃后,串色靠亮度边缘上的通道分离重新长出彩色镶边。
half3 SamplePhosphorFringed(float2 uv, float fringe, half3 tint)
{
half r = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, uv + float2(fringe, 0)).r;
half g = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, uv).g;
half b = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, uv - float2(fringe, 0)).b;
return half3(r, g, b);
half lr = Luma(SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, uv + float2(fringe, 0)).rgb);
half lg = Luma(SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, uv).rgb);
half lb = Luma(SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, uv - float2(fringe, 0)).rgb);
return tint * half3(lr, lg, lb);
}
// 通电的屏幕:荧光面(中心微亮)+ 刷新暗带 + 亮度微颤 + 扫描线 + 暗角 + 发光增益。
// 待机时它就是全部——没有这层,屏幕就是一张静态贴图。
// scanExtra:损坏时扫描线额外加深的量。
half3 PoweredScreen(half3 col, float2 uv, float2 pixelPos, float scanExtra)
{
float2 vp = uv - 0.5;
float r2 = dot(vp, vp);
// 荧光面:中心微亮,像电子枪打在玻璃后面。
col *= 1.0 + 0.16 * exp(-r2 * 5.0);
// 刷新暗带:一条宽而淡的暗带缓慢下移(摄像机拍 CRT 的拍频感),
// 是"这是一块正在发光的屏幕"最便宜也最强的暗示。
float bandPos = frac(_Time.y * 0.09);
float dBand = uv.y - bandPos;
dBand -= floor(dBand + 0.5);
col *= 1.0 - 0.05 * exp(-dBand * dBand / 0.02);
// 亮度微颤:1~2%,肉眼几乎读不出来但"活着"。
col *= 1.0 + (hash11(floor(_Time.y * 47.0)) - 0.5) * 0.025;
// 扫描线:待机就有一点,损坏时加深;低于约 2 像素分辨不出来只会闪,自动淡出。
float scanPx = 4.0 * max(0.25, _Scale);
float scanFade = saturate((scanPx - 1.6) / 1.4);
float scan = 0.5 + 0.5 * sin(pixelPos.y * 6.2831853 / max(1.0, scanPx));
float scanAmt = saturate(_Grit) * saturate(0.12 + scanExtra) * scanFade;
col *= lerp(1.0, 0.7 + 0.3 * scan, scanAmt);
// 暗角。
col *= 1.0 - _Vignette * saturate(r2 * 2.0);
// 发光:整体抬过 1,超出部分交给场景 Bloom。
return col * _Emissive;
}
Varyings HuoshanScreenGlitchVert(Attributes input)
@@ -131,22 +179,34 @@ half4 HuoshanScreenGlitchFrag(Varyings input) : SV_Target
float spriteAlpha = baseSample.a * input.color.a;
clip(spriteAlpha - 0.001);
float2 texel = float2(max(fwidth(uv.x), 1e-7), max(fwidth(uv.y), 1e-7));
float2 pixelPos = uv / texel;
// 愤怒红:LOG3 攻击时整块屏幕的荧光换成红。
half3 tint = lerp(_PhosphorColor.rgb, _RageColor.rgb, saturate(_Rage));
// ── 待机:无损坏时也要是一块通电的荧光屏。
if (intensity <= 0.0005)
{
half4 clean = baseSample;
clean.rgb *= input.color.rgb;
half3 idle = tint * Luma(baseSample.rgb);
idle = PoweredScreen(idle, uv, pixelPos, 0.0);
half4 clean;
clean.rgb = idle * input.color.rgb;
clean.a = spriteAlpha;
return clean;
}
float scale = max(0.25, _Scale);
float2 texel = float2(max(fwidth(uv.x), 1e-7), max(fwidth(uv.y), 1e-7));
float2 pixelPos = uv / texel;
// 模拟信号按"帧"刷新:雪花、行抖动、撕裂带都在帧粒度上重抽,
// 逐渲染帧刷新会快成一片糊,反而没有电视感。
float frameSeed = floor(_Time.y * 18.0 * max(0.05, _Speed));
// ── 症状分段:升级感来自新的坏法解锁,而不是同一种坏法变多。
float tearStage = smoothstep(0.15, 0.40, intensity); // 撕裂带 / 划痕
float rollStage = smoothstep(0.35, 0.62, intensity); // 滚屏
float dropStage = smoothstep(0.55, 0.80, intensity); // 掉台
// ── 情绪节奏:呼吸式起伏,不给 0,保证 hold 住时画面仍在小幅挣扎。
float rate = max(0.05, _SurgeRate) * lerp(0.7, 1.7, intensity);
float ph = _Time.y * rate;
@@ -156,8 +216,7 @@ half4 HuoshanScreenGlitchFrag(Varyings input) : SV_Target
float drive = intensity * (0.35 + 0.65 * breathe);
// ── 掉台:随机某几拍信号短暂中断——画面塌成雪花,然后闪回。
// 这是"坏掉"最有说服力的一下,频率由 _Chaos 控制。
float dropGate = step(1.0 - saturate(_Chaos * (0.2 + 0.45 * intensity)),
float dropGate = step(1.0 - saturate(_Chaos * 0.65 * dropStage),
hash11(beat * 5.13 + 0.71));
float drop = dropGate
* smoothstep(0.30, 0.34, cyc)
@@ -168,7 +227,7 @@ half4 HuoshanScreenGlitchFrag(Varyings input) : SV_Target
* (1.0 - smoothstep(0.64, 0.80, cyc));
// ── 场同步失锁:某几拍整幅画面向上滚一整圈。
float rollGate = step(1.0 - saturate(_Warp * (0.12 + 0.38 * intensity)),
float rollGate = step(1.0 - saturate(_Warp * 0.5 * rollStage),
hash11(beat * 3.77 + 1.31));
float rollPos = rollGate * cyc;
float vJit = (hash11(frameSeed * 0.713) - 0.5) * 0.006 * _Warp * drive;
@@ -184,13 +243,13 @@ half4 HuoshanScreenGlitchFrag(Varyings input) : SV_Target
float rowPx = 3.0 * scale;
float row = floor(pixelPos.y / max(1.0, rowPx));
float rowJit = (hash21(float2(row, frameSeed)) - 0.5)
* 0.005 * _Warp * (drive * drive + drop * 2.0);
* 0.004 * _Warp * (drive * drive + drop * 2.0);
// ── 撕裂带:带高每帧重抽,带内整段错位。等高带太机械。
float bandH = lerp(0.05, 0.16, hash11(frameSeed * 1.71));
float bandId = floor((uv.y + hash11(frameSeed) * 0.37) / bandH);
float bandRand = hash21(float2(bandId, frameSeed));
float bandOn = step(0.90 - 0.32 * saturate(drive + drop), bandRand);
float bandOn = step(1.0 - (0.10 + 0.30 * saturate(drive + drop)) * tearStage, bandRand);
float tear = bandOn * (hash11(bandId * 13.7 + frameSeed) - 0.5) * 2.0
* 0.09 * _Warp * (0.25 + drive + drop);
@@ -199,22 +258,31 @@ half4 HuoshanScreenGlitchFrag(Varyings input) : SV_Target
+ (hash11(frameSeed * 3.31) - 0.5) * 0.08 * drop;
float2 sampleUV = float2(uv.x + xOff, frac(uv.y + rollPos + vJit));
// ── 采样:彩色镶边随损坏加深,撕裂带内额外拉开。
// ── 采样:荧光化 + 彩色镶边随损坏加深,撕裂带内额外拉开
float fringe = (0.0012 + 0.005 * drive + 0.015 * drop)
* _ColorDamage * (1.0 + bandOn * 1.5);
half3 col = SampleFringed(sampleUV, fringe);
half3 col = SamplePhosphorFringed(sampleUV, fringe, tint);
// 重影:微弱的错位副本(天线反射的鬼影)。
half3 ghost = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex,
sampleUV + float2(0.025 + 0.03 * drive, 0)).rgb;
col += ghost * _Ghost * 0.22 * (0.5 + drive);
half lGhost = Luma(SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex,
sampleUV + float2(0.025 + 0.03 * drive, 0)).rgb);
col += tint * lGhost * _Ghost * 0.22 * (0.5 + drive);
// ── 信号丢失:坏掉丢的是画面。信号越差画面内容越暗淡,
// 只在随机某几拍挣扎着闪回来一下——高损坏时屏幕不再是绿色。
float loss = smoothstep(0.30, 0.95, intensity) * (0.55 + 0.45 * breathe);
float flashGate = step(0.55, hash11(beat * 2.17 + 0.9));
float flash = flashGate
* smoothstep(0.15, 0.25, cyc)
* (1.0 - smoothstep(0.40, 0.55, cyc));
col *= 1.0 - loss * (1.0 - 0.75 * flash);
// ── 撕裂带内亮度错档:带和带之间电平接不上,撕裂在平色区域也看得见。
col *= 1.0 + bandOn * (hash11(bandId * 7.7 + frameSeed) - 0.4)
* 0.6 * saturate(drive + drop);
* 0.45 * saturate(drive + drop);
// ── 色度不稳:色相慢漂 + 撕裂带内色相硬错 + 色度增益忽大忽小。
// 对着那块绿,这层就是"往黄/青方向晃、时浓时淡"
// 这是低档唯一的彩色症状:荧光色往黄/青方向晃、时浓时淡。
float3 yiq = RGBToYIQ(col);
float hueAng = (vnoise(float2(_Time.y * 0.9, row * 0.02)) - 0.5)
* 2.4 * _ColorDamage * drive;
@@ -226,34 +294,33 @@ half4 HuoshanScreenGlitchFrag(Varyings input) : SV_Target
* 1.6 * _ColorDamage * drive;
col = max(0.0, YIQToRGB(yiq));
// ── 雪花:常驻底噪 + 行级雪花条;掉台时雪花吃掉整个画面
// ── 雪花:电子枪自己画的东西,灰蓝色调不带荧光绿
// 底噪压得很低(干净),信号丢失和掉台时才大量顶上来。
float grainPx = 1.6 * scale;
float2 cell = floor(pixelPos / max(1.0, grainPx));
float snowVal = hash21(cell + frameSeed * float2(11.13, 5.71));
float rowSnow = step(0.96 - 0.10 * drive, hash21(float2(row, frameSeed + 3.0)));
float rowSnow = step(0.975 - 0.08 * drive, hash21(float2(row, frameSeed + 3.0)));
float snowAmt = saturate(
saturate(_Grit) * (0.05 + 0.16 * drive + rowSnow * 0.45 * drive)
saturate(_Grit) * (0.025 + 0.09 * drive + rowSnow * 0.30 * drive)
+ loss * 0.30
+ drop * 0.9);
col = lerp(col, snowVal.xxx * lerp(0.75, 1.1, hash21(cell.yx + frameSeed)), snowAmt);
half3 snowCol = snowVal * half3(0.92, 0.97, 1.08)
* lerp(0.7, 1.05, hash21(cell.yx + frameSeed));
col = lerp(col, snowCol, snowAmt);
// ── 亮点划痕:细行上随机的短横白痕,磁带/信号损伤的标志性亮斑
// ── 亮点划痕:细行上偶发的短横白痕(回扫亮痕),别太密,密了就脏
float fineRow = floor(pixelPos.y / max(1.0, 1.5 * scale));
float segPx = 26.0 * scale;
float seg = floor(pixelPos.x / segPx);
float spark = step(0.995 - 0.015 * drive * _Grit - 0.03 * drop,
float spark = step(0.997 - (0.010 * drive * _Grit + 0.02 * drop) * tearStage,
hash21(float2(fineRow * 1.7 + seg * 0.31, frameSeed)));
col += spark * (1.0 - frac(pixelPos.x / segPx)) * 1.3 * saturate(drive + drop);
col += spark * (1.0 - frac(pixelPos.x / segPx)) * saturate(drive + drop);
// ── 消隐黑条压暗。
col *= 1.0 - barMask * 0.8;
// ── 扫描线:低于约 2 像素分辨不出来只会闪,自动淡出
// ── 隔行闪烁:奇偶场亮度轮流差一点
float scanPx = 4.0 * scale;
float scanFade = saturate((scanPx - 1.6) / 1.4);
float scan = 0.5 + 0.5 * sin(pixelPos.y * 6.2831853 / max(1.0, scanPx));
col *= lerp(1.0, 0.78 + 0.30 * scan, saturate(_Grit * 0.6) * scanFade);
// 隔行闪烁:奇偶场亮度轮流差一点。
float parity = fmod(floor(_Time.y * 29.97), 2.0);
float field = fmod(floor(pixelPos.y / max(1.0, scanPx * 0.5)), 2.0);
col *= 1.0 - abs(field - parity) * 0.08 * saturate(_Grit) * drive;
@@ -263,9 +330,8 @@ half4 HuoshanScreenGlitchFrag(Varyings input) : SV_Target
col *= 1.0 - drop * 0.25;
col *= 1.0 + _BloomBoost * recover;
// ── 暗角:照片感
float2 vp = uv - 0.5;
col *= 1.0 - _Vignette * saturate(dot(vp, vp) * 2.0);
// ── 通电质感 + 发光收尾(损坏时扫描线额外加深)
col = PoweredScreen(col, uv, pixelPos, 0.30 * drive);
half4 result;
result.rgb = max(0.0, col) * input.color.rgb;
+5 -1
View File
@@ -1,6 +1,6 @@
Shader "AibisDream/HuoshanScreenGlitch"
{
// 只暴露 6 个宏观旋钮 + 3 个基调,内部十几个系数全部从它们推导。
// 只暴露 6 个宏观旋钮 + 5 个基调,内部十几个系数全部从它们推导。
// 想加新参数前先想清楚:上一版 40 个属性根本没法调。
Properties
{
@@ -9,6 +9,7 @@ Shader "AibisDream/HuoshanScreenGlitch"
[Header(Drive)]
_GlitchIntensity("Glitch Intensity (driven by Yarn)", Range(0, 1)) = 0
_Rage("Rage (driven by Yarn, phosphor to red)", Range(0, 1)) = 0
_Speed("Signal Refresh Speed", Range(0.05, 4)) = 1
[Header(Main Knobs)]
@@ -20,6 +21,9 @@ Shader "AibisDream/HuoshanScreenGlitch"
_SurgeRate("Surge Rate (emotional pacing)", Range(0.05, 3)) = 0.5
[Header(Tone)]
_PhosphorColor("Phosphor Color (screen self color)", Color) = (0.72, 1, 0.82, 1)
_RageColor("Rage Phosphor (LOG3 attack)", Color) = (1, 0.188, 0.302, 1)
_Emissive("Emissive Gain (above 1 feeds Bloom)", Range(0.5, 3)) = 1.5
_Ghost("Ghost Image", Range(0, 1)) = 0.35
_BloomBoost("Recover Bloom Boost", Range(0, 3)) = 0.6
_Vignette("Vignette", Range(0, 1)) = 0.28