崩坏:星穹铁道中的“银河战力党”卡牌特效极具视觉冲击力。本文旨在通过复盘的方式,拆解并复刻该闪卡效果,梳理从层次分析到Shader实现的完整思路,为开发者提供技术参考。
效果预览视频:

美术资源来源:米哈游工具站
开始之前
本文并非实时开发记录,而是经过复盘整理后的理想化架构分析。实际开发中,建议采用“边做边分析”的迭代方式。对于复杂的伪3D特效,核心难点在于理清卡牌的层级构成、空间位置关系、各层特效以及最终的渲染顺序。
初步的卡牌层次关系与效果分析
借助现有美术资源,可以将卡牌拆解为以下六个层级,并初步判定各层效果:

- 卡面UI:包含血量、攻击力等,位于最上层,使用AlphaTest。
- 边框:具有镭射亮片效果。
- 背景及卡面亮片层:位于最下层,具备深度感(视差映射)、模版测试裁剪及镭射亮片。
- 模版测试遮罩:用于初始化Stencil Buffer,单独一层。
- 角色层:具有立体感(视差映射)及闪烁纹理效果。
- 渐变层:深蓝渐变,作为半透明物体与后方角色和背景混合,增加通透感。
关于卡面镭射小亮片的归属,经对比测试,将其归入“背景层”并与背景颜色叠加,能获得更贴近原游戏的通透感,而非单独分层或与角色同层。

确定空间位置关系与渲染顺序
空间位置关系初步确定
基于视觉效果观察:
- UI层:未被遮挡,深度最小(最前)。
- 背景&亮片层:深度最大(最后)。
- 角色、边框、渐变:位于UI与背景之间。角色需配合遮罩实现穿插效果,因此角色在边框前方。渐变看似在边框后,实则是通过剔除实现的视觉效果。
关键难点在于渐变的处理。由于渐变图拉伸后与遮罩尺寸不匹配,无法直接使用AlphaTest裁剪,必须引入模版测试(Stencil Test)来实现精确的形状裁剪。
模版测试原理与应用
模版测试通过操作Stencil Buffer中的值来控制像素的保留或丢弃。在本案例中,模版测试遮罩用于初始化Stencil Buffer(Ref=1, Always替换),后续参与测试的物体(角色、渐变、背景)均设置Comp=Equal, Pass=Replace,确保只有在遮罩范围内的像素被渲染。

背景的 depth 感也依赖于此:背景像素范围大于遮罩,但只有模板值为1的区域(遮罩内)能通过测试,配合视差映射产生的UV偏移,裁切掉重复采样部分,从而营造凹陷的深度感。
结合渲染顺序完成空间位置确定
渲染队列的设置至关重要。不参与模版测试的物体(UI、边框)在不透明队列;参与模版测试的物体(遮罩、背景、角色、渐变)在透明队列。
最终空间从前到后:UI组件 > 渐变 > 角色 > 模版测试遮罩 > 边框 > 背景&卡面亮片。
最终渲染顺序(由先到后):
- UI(不透明队列最前)
- 边框(不透明队列,略后于UI)
- 模版测试遮罩(透明队列最前,Ref=1, Always,不写深度,不输出颜色)
- 背景&卡面亮片(透明队列,深度最大,最先渲染以通过深度测试)
- 角色(透明队列,中间深度)
- 渐变(透明队列,深度较小,最后渲染)
注意:模版测试遮罩必须位于边框前方,否则角色超出边框的部分将被错误遮挡。

效果实现
卡牌特效可拆分为四大基础模块:各向异性高光、视差映射、镭射效果、闪烁效果。以下分别介绍其实现逻辑。
各向异性高光
主要用于边框特效,呈现对角线方向的粗条纹高光。采用经典的Kajiya-Kay模型,通过对角线方向的切线向量计算高光遮罩。
//各向异性高光
half Anisotropy(half3 tDir, half3 hDir,half AnisotropyPow)
{
half tdoth = dot(tDir, hDir);
half dirAtten = smoothstep(-1, 0, tdoth);
half sinth = sqrt(1 - tdoth * tdoth);
half AnisoSpec = dirAtten * pow(sinth, AnisotropyPow);
return AnisoSpec;
}
视差映射
采用基础视差映射(Parallax Mapping)。角色层表现为凸起,背景层表现为凹陷。通过反转高度图并允许强度参数为正负值,统一控制凹凸效果。
//视差
half4 ParallaxTex(sampler2D HeightMap, sampler2D MainTex, half2 uv, half3 vDirTS, half ParallaxStrength)
{
half h = 1 - tex2D(HeightMap, uv).r;//反转高度
half2 offset = -vDirTS.xy * (h/vDirTS.z) * ParallaxStrength;
half4 col = tex2D(MainTex, uv + offset);
return col;
}
镭射效果
通过将RGB转换至HSV空间,对色相(Hue)根据观察方向进行偏移,再转回RGB,形成随视角变化的冷暖色带。
//色相偏移
half3 HueOffset(half3 In, half Offset)
{
// RGB to HSV ...
// Hue Shift
half hue = hsv.x + Offset;
hsv.x = (hue < 0) ? hue + 1 : (hue > 1) ? hue - 1 : hue;
// HSV to RGB ...
return color;
}
闪烁效果
利用三角函数生成黑白条纹遮罩,结合切线空间观察方向的分量作为变量,实现随视角移动的明暗变化。边框和卡面亮片还需叠加格子状随机相位,营造星星点点的闪烁感。
//条纹闪烁
half2 StripeGlitter(half vDirTS, half GlitterCount, half GlitterSpeed, half GlitterArea, half GlitterStart)
{
half2 angleCycle = vDirTS * GlitterCount * UNITY_PI * 2;
half glitterWave = sin(angleCycle * GlitterSpeed + GlitterStart * 3.14);
half glitter = smoothstep(1.0 - GlitterArea, 1.0, glitterWave);
return glitter;
}
//格子闪烁
half2 GridGlitter(half2 uv, half vDirTS, half GridSize, half GridGlitterCount, half GridGlitterSpeed, half GridGlitterArea)
{
half2 gridUV = uv * GridSize;
half2 gridId = floor(gridUV);
half randomPhase = frac(sin(dot(gridId, half2(12.9898, 78.233))) * 43758.5453);
half2 angleCycle = vDirTS * GridGlitterCount * UNITY_PI * 2;
half glitterWave = sin(angleCycle * GridGlitterSpeed + randomPhase * 6.28);
half glitter = smoothstep(1 - GridGlitterArea, 1.0, glitterWave);
return glitter;
}
角色层实现
角色层结合视差映射与闪烁纹理。为保证通透感,采用加法混合(Saturate(Character + Blink))。闪烁遮罩需限制在角色Alpha范围内。
Shader "Ashader/StarRailCard/ParallaxCard"
{
Properties
{
_MainTex ("MainTex", 2D) = "white" {}
_MainColor("Color", Color) = (1,1,1,1)
_MaskTex("Mask", 2D) = "white" {}
[Header(Stencial)]
_ID("Mask ID", Int) = 1
[Enum(UnityEngine.Rendering.CompareFunction)] _Scomp ("StencilComp",Float) = 8
[Enum(UnityEngine.Rendering.StencilOp)] _Sop ("Stencil Op",Float) = 2
[Header(Parallax)]
_HeightMap("HeightMap", 2D) = "white" {}
_ParallaxStrength("ParallaxStrength", Range(-0.5, 0.5)) = 0.01
[Header(Blink)]
_BlinkTex("BlinkTex", 2D) = "white" {}
[HDR]_BlinkCol("BlinkColor", Color) = (1,1,1,1)
_GlitterArea ("BlinkArea", Range(0.01, 0.5)) = 0.1
_GlitterCount ("BlinkCount", Float) = 3
_GlitterSpeed ("BlinkSpeed", Float) = 1
_GlitterStart("BlinkStart", Range(0,2)) = 0
}
SubShader
{
Tags { "RenderType"="Transparent" "Queue" = "Transparent+3" }
Blend SrcAlpha OneMinusSrcAlpha
Stencil
{
Ref [_ID]
Comp [_Scomp]
Pass[_Sop]
}
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
#include "SRCShaderFunction.cginc"
sampler2D _MainTex;
half4 _MainColor;
sampler2D _MaskTex;
sampler2D _HeightMap;
half _ParallaxStrength;
sampler2D _BlinkTex;float4 _BlinkTex_ST;
float4 _BlinkCol;
half _GlitterArea;
half _GlitterCount;
half _GlitterSpeed;
half _GlitterStart;
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
float3 normal : NORMAL;
float4 tangent : TANGENT;
};
struct v2f
{
half2 uv : TEXCOORD0;
float4 vertex : SV_POSITION;
half3 nDir : TEXCOORD1;
half3 posWS : TEXCOORD2;
half3 tDir : TEXCOORD3;
half3 bDir : TEXCOORD4;
half2 uv2 : TEXCOORD5;
};
v2f vert (appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv2 = TRANSFORM_TEX(v.uv, _BlinkTex);
o.uv = v.uv;
o.nDir = UnityObjectToWorldNormal(v.normal);
o.posWS = mul(unity_ObjectToWorld, v.vertex);
o.tDir = UnityObjectToWorldDir(v.tangent);
o.bDir = cross(o.nDir, o.tDir) * v.tangent.w;
return o;
}
half4 frag (v2f i) : SV_Target
{
half3 vDir = UnityWorldSpaceViewDir(i.posWS);
float3x3 TBN = float3x3(i.tDir, i.bDir, i.nDir);
half3 vDirTS = normalize(mul(TBN, vDir));
//视差
half4 Character = ParallaxTex(_HeightMap, _MainTex, i.uv, vDirTS, _ParallaxStrength) * _MainColor;
//闪烁
half4 BlinkTex = tex2D(_BlinkTex, i.uv2);
// 对角线方向条纹
half StripeMask = StripeGlitter((vDirTS.x - 0.5 * vDirTS.y),_GlitterCount, _GlitterSpeed, _GlitterArea, _GlitterStart);
half BlinkMask = min(StripeMask, BlinkTex.a);
// 限制在角色区域内
BlinkMask = lerp(Character.a,BlinkMask,Character.a);
half3 BlinkMain = lerp(0, BlinkTex.rgb, BlinkMask) * _BlinkCol;
half4 Blink = half4(BlinkMain,BlinkMask);
// 卡面遮罩
half CardMask = tex2D(_MaskTex, i.uv).a;
Character = lerp(0,Character,CardMask);
return half4(saturate(Character + Blink));
}
ENDCG
}
}
}
背景&卡面亮片层实现
逻辑类似角色层,但闪烁纹理替换为“镭射颜色+格子遮罩”。同样采用加法混合以保留通透感。
// 片段着色器核心逻辑示例
half3 LaserCol = HueOffset(_LaserColBase, _RepeatAmount * (vDirTS.y - vDirTS.x));
half GridMask = GridGlitter(i.uv, vDirTS.x - vDirTS.y, _GridSize, _GridGlitterCount, _GridGlitterSpeed, _GridGlitterArea);
half BlinkMask = min(StripeMask, GridMask); // 简化示意
half4 BlinkCol = half4(LaserCol, BlinkMask);
BlinkCol.rgb = lerp(0, BlinkCol.rgb, BlinkMask);
return saturate(Background + BlinkCol);
边框层实现
边框由“各向异性高光 + 镭射 + 格子闪烁”组成。与透明层不同,边框不透明,采用Lerp混合。高光作为遮罩,控制镭射和格子颜色的显示区域。
Shader "Ashader/StarRailCard/CardFrame"
{
Properties
{
_MainTex ("CardFrame", 2D) = "white" {}
[HDR]_LaserCol ("LaserCol", Color) = (1,1,1,1)
_AnisoPow("AnisotropyPower", Range(0,100)) = 30
_CutOff("CutOff", Range(0,1)) = 0
_RepeatAmount("RepeatAmount", Range(0,5)) = 1
_GridSize ("GridSize", Float) = 50
_GridGlitterArea ("GridGlitterArea", Range(0, 0.5)) = 0.1
_GridGlitterCount ("GridGlitterCount", Float) = 3
_GridGlitterSpeed ("GridGlitterSpeed", Float) = 1
[HDR]_GridCol("GridColor", Color) = (1,1,1,1)
}
SubShader
{
Tags { "RenderType"="Opaque" "Queue" = "Geometry+1" }
LOD 100
Pass
{
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
#include "SRCShaderFunction.cginc"
sampler2D _MainTex;float4 _MainTex_ST;
half4 _LaserCol;
half _AnisoPow;
half _CutOff;
half _RepeatAmount;
half _GridSize;
half _GridGlitterArea;
half _GridGlitterCount;
half _GridGlitterSpeed;
half4 _GridCol;
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
float3 normal : NORMAL;
float4 tangent : TANGENT;
};
struct v2f
{
half2 uv : TEXCOORD0;
float4 vertex : SV_POSITION;
half3 nDir : TEXCOORD1;
float3 posWS : TEXCOORD2;
half3 tDir : TEXCOORD3;
half3 bDir : TEXCOORD4;
};
v2f vert (appdata v)
{
v2f o;
o.vertex = UnityObjectToClipPos(v.vertex);
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
o.nDir = UnityObjectToWorldNormal(v.normal);
o.posWS = mul(unity_ObjectToWorld, v.vertex);
o.tDir = UnityObjectToWorldDir(v.tangent);
o.bDir = cross(o.nDir, o.tDir) * v.tangent.w;
return o;
}
half4 frag (v2f i) : SV_Target
{
half3 vDir = UnityWorldSpaceViewDir(i.posWS);
float3x3 TBN = float3x3(i.tDir, i.bDir, i.nDir);
half3 vDirTS = normalize(mul(TBN, vDir));
half3 lDir = normalize(UnityWorldSpaceLightDir(i.posWS));
half3 hDir = normalize(lDir + vDir);
half3 diagonalDir = normalize(i.tDir - i.bDir);
// 各向异性高光
half AnisoSpec = Anisotropy(diagonalDir, hDir, _AnisoPow);
// 镭射
half3 LaserCol = HueOffset(_LaserCol, _RepeatAmount * (vDirTS.y - vDirTS.x));
// 格子遮罩 (横向渐隐)
half GridMask = GridGlitter(i.uv, vDirTS.x, _GridSize, _GridGlitterCount, _GridGlitterSpeed, _GridGlitterArea);
half3 GridCol = lerp(0,_GridCol,GridMask);
half4 Cardframe = tex2D(_MainTex, i.uv);
Cardframe.rgb = lerp(Cardframe.rgb, GridCol + LaserCol , AnisoSpec);
clip(Cardframe.a - _CutOff);
return half4(Cardframe.rgb,1.0);
}
ENDCG
}
}
}
公共函数库 (cginc)
#ifndef SRC_SHADER_FUNCTIONS_INCLUDED
#define SRC_SHADER_FUNCTIONS_INCLUDED
//视差
half4 ParallaxTex(sampler2D HeightMap, sampler2D MainTex, half2 uv, half3 vDirTS, half ParallaxStrength)
{
half h = 1 - tex2D(HeightMap, uv).r;
half2 offset = -vDirTS.xy * (h/vDirTS.z) * ParallaxStrength;
half4 col = tex2D(MainTex, uv + offset);
return col;
}
//各向异性高光
half Anisotropy(half3 tDir, half3 hDir,half AnisotropyPow)
{
half tdoth = dot(tDir, hDir);
half dirAtten = smoothstep(-1, 0, tdoth);
half sinth = sqrt(1 - tdoth * tdoth);
half AnisoSpec = dirAtten * pow(sinth, AnisotropyPow);
return AnisoSpec;
}
//色相偏移
half3 HueOffset(half3 In, half Offset)
{
half4 K = half4(0.0, -1.0 / 3.0, 2.0 / 3.0, -1.0);
half4 P = lerp(half4(In.bg, K.wz), half4(In.gb, K.xy), step(In.b, In.g));
half4 Q = lerp(half4(P.xyw, In.r), half4(In.r, P.yzx), step(P.x, In.r));
half D = Q.x - min(Q.w, Q.y);
half E = 1e-10;
half3 hsv = half3(abs(Q.z + (Q.w - Q.y)/(6.0 * D + E)), D / (Q.x + E), Q.x);
half hue = hsv.x + Offset;
hsv.x = (hue < 0) ? hue + 1 : (hue > 1) ? hue - 1 : hue;
half4 K2 = half4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0);
half3 P2 = abs(frac(hsv.xxx + K2.xyz) * 6.0 - K2.www);
half3 color = hsv.z * lerp(K2.xxx, saturate(P2 - K2.xxx), hsv.y);
return color;
}
//条纹闪烁
half2 StripeGlitter(half vDirTS, half GlitterCount, half GlitterSpeed, half GlitterArea, half GlitterStart)
{
half2 angleCycle = vDirTS * GlitterCount * UNITY_PI * 2;
half glitterWave = sin(angleCycle * GlitterSpeed + GlitterStart * 3.14);
half glitter = smoothstep(1.0 - GlitterArea, 1.0, glitterWave);
return glitter;
}
//格子闪烁
half2 GridGlitter(half2 uv, half vDirTS, half GridSize, half GridGlitterCount, half GridGlitterSpeed, half GridGlitterArea)
{
half2 gridUV = uv * GridSize;
half2 gridId = floor(gridUV);
half randomPhase = frac(sin(dot(gridId, half2(12.9898, 78.233))) * 43758.5453);
half2 angleCycle = vDirTS * GridGlitterCount * UNITY_PI * 2;
half glitterWave = sin(angleCycle * GridGlitterSpeed + randomPhase * 6.28);
half glitter = smoothstep(1 - GridGlitterArea, 1.0, glitterWave);
return glitter;
}
#endif
总结与建议
1. 渲染顺序是关键:务必使用FrameDebugger调试渲染队列和深度测试情况。透明物体需从后往前渲染,且需注意Stencil Buffer的初始化时机。
2. 通透感的实现:对于亮片、角色等高光部分,尝试使用加法混合(Additive/Saturate)而非线性插值(Lerp),能更好地模拟光线叠加效果。
3. 模块化思维:将视差、高光、镭射、闪烁等基础效果封装为通用函数,便于在不同层级间复用和组合。
作者:LoseT
原文来源:知乎专栏

