大数跨境

【Unity】崩铁银河战力党闪卡复刻记录

【Unity】崩铁银河战力党闪卡复刻记录 游戏开发技术教程
2026-10-20
1

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

效果预览视频:

美术资源来源:米哈游工具站

开始之前

本文并非实时开发记录,而是经过复盘整理后的理想化架构分析。实际开发中,建议采用“边做边分析”的迭代方式。对于复杂的伪3D特效,核心难点在于理清卡牌的层级构成、空间位置关系、各层特效以及最终的渲染顺序。

初步的卡牌层次关系与效果分析

借助现有美术资源,可以将卡牌拆解为以下六个层级,并初步判定各层效果:

  1. 卡面UI:包含血量、攻击力等,位于最上层,使用AlphaTest。
  2. 边框:具有镭射亮片效果。
  3. 背景及卡面亮片层:位于最下层,具备深度感(视差映射)、模版测试裁剪及镭射亮片。
  4. 模版测试遮罩:用于初始化Stencil Buffer,单独一层。
  5. 角色层:具有立体感(视差映射)及闪烁纹理效果。
  6. 渐变层:深蓝渐变,作为半透明物体与后方角色和背景混合,增加通透感。

关于卡面镭射小亮片的归属,经对比测试,将其归入“背景层”并与背景颜色叠加,能获得更贴近原游戏的通透感,而非单独分层或与角色同层。

确定空间位置关系与渲染顺序

空间位置关系初步确定

基于视觉效果观察:

  • UI层:未被遮挡,深度最小(最前)。
  • 背景&亮片层:深度最大(最后)。
  • 角色、边框、渐变:位于UI与背景之间。角色需配合遮罩实现穿插效果,因此角色在边框前方。渐变看似在边框后,实则是通过剔除实现的视觉效果。

关键难点在于渐变的处理。由于渐变图拉伸后与遮罩尺寸不匹配,无法直接使用AlphaTest裁剪,必须引入模版测试(Stencil Test)来实现精确的形状裁剪。

模版测试原理与应用

模版测试通过操作Stencil Buffer中的值来控制像素的保留或丢弃。在本案例中,模版测试遮罩用于初始化Stencil Buffer(Ref=1, Always替换),后续参与测试的物体(角色、渐变、背景)均设置Comp=Equal, Pass=Replace,确保只有在遮罩范围内的像素被渲染。

背景的 depth 感也依赖于此:背景像素范围大于遮罩,但只有模板值为1的区域(遮罩内)能通过测试,配合视差映射产生的UV偏移,裁切掉重复采样部分,从而营造凹陷的深度感。

结合渲染顺序完成空间位置确定

渲染队列的设置至关重要。不参与模版测试的物体(UI、边框)在不透明队列;参与模版测试的物体(遮罩、背景、角色、渐变)在透明队列。

最终空间从前到后:UI组件 > 渐变 > 角色 > 模版测试遮罩 > 边框 > 背景&卡面亮片。

最终渲染顺序(由先到后):

  1. UI(不透明队列最前)
  2. 边框(不透明队列,略后于UI)
  3. 模版测试遮罩(透明队列最前,Ref=1, Always,不写深度,不输出颜色)
  4. 背景&卡面亮片(透明队列,深度最大,最先渲染以通过深度测试)
  5. 角色(透明队列,中间深度)
  6. 渐变(透明队列,深度较小,最后渲染)

注意:模版测试遮罩必须位于边框前方,否则角色超出边框的部分将被错误遮挡。

效果实现

卡牌特效可拆分为四大基础模块:各向异性高光、视差映射、镭射效果、闪烁效果。以下分别介绍其实现逻辑。

各向异性高光

主要用于边框特效,呈现对角线方向的粗条纹高光。采用经典的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
原文来源:知乎专栏

【声明】内容源于网络
0
0
游戏开发技术教程
各类跨境出海行业相关资讯
内容 1646
粉丝 0
游戏开发技术教程 各类跨境出海行业相关资讯
总阅读51.3k
粉丝0
内容1.6k