Live: fix six silently-failing track paths, add PostFilm data layer

Every fix here is the same shape: the keyframe data was already parsed
correctly and then dropped somewhere downstream, with no error logged.
Found by dumping the authoritative schema with tools/ and comparing against
what the handlers actually consume.

BgColor1 (8) — the largest track in the corpus, 43617 keys / 59 songs
  * AlterUpdate_BgColor1 filtered on a `validBgColorNames` whitelist of four
    character group names, discarding the other 16 stage groups outright.
    On song 1177 that is ~92% of the track's keys. Filter removed; Director
    now routes by group name (characters vs stage).
  * Stage branch resolves renderers by walking the whole stage hierarchy.
    StageObjectMap alone is not enough: it dedupes by name, and crowd groups
    have 66-570 same-named objects, so only the first was ever coloured.
  * Writes _MulColor0, confirmed by enumerating the shaders' property tables.
    _AmbientColor belongs to BgColor2 — they are separate channels on the
    same shader and must not be written by both.
  * Populates fields the dispatcher had always left at default: colorPower,
    scale, vertexColorToonPower, outlineWidthPower, LightBlendMode,
    IsSilhouette, IsProjector, TimelineName.

Object (33) — the neon sign never descended from the ceiling
  * The group name `neonsign` is a StageController._stageObjectUnits *unit*
    name; no GameObject of that name exists, so the whole track never fired.
    UpdateObject now falls back to StageObjectUnitMap and applies to each
    child of the unit.
  * ObjectUpdateInfo.OffsetType was populated by the dispatcher but never
    read, so Add-relative positions were written as absolute and objects
    snapped to their parent's origin. Now honoured against a lazily captured
    base local TRS.

BlinkLight (45) — 19420 keys / 57 songs, the biggest lighting payload
  * Wrote _Color, which does not exist on Gallop/3D/Live/Stage/LightBlinkBlend
    (its colour channel is _BlinkLightColor), so the write was a no-op and the
    lights rendered black. Now probes the shader for the real property.
  * Brightness goes to _ColorPower instead of being folded into the colour,
    falling back to colour*power only where that property is absent.
  * Uses MaterialPropertyBlock; the old path hit Renderer.materials every
    frame, allocating and breaking batching.

PostFilm (39) — ~20200 keys / 59 songs, previously recorded as empty
  * Data class fields reordered to the bundle's TypeTree order and the five
    missing BlinkLight* fields added; LiveTimelineKeyPostFilmDataList created;
    postFilmKeys / postFilm2Keys / postFilm3Keys declared on the worksheet.
    Runtime now deserializes 100 / 161 / 87 keys on song 1177.
  * AlterUpdate_PostFilm dispatch with interpolation, PostFilmUpdateInfo,
    a URP ScriptableRendererFeature and a full-screen blit shader.
  * Rendering is disabled by default: filmOptionParam's geometry is undefined
    anywhere we can read. All 100 keys on song 1177 are Vignette variants, so
    that parameter decides the whole look, and the shader that defines it is
    not in the `shader` bundle (all 499 searched by name and by property
    signature) — it ships inside the app binary.

Diagnostics
  * LiveTimelineWorksheetDiag dumps every track field with group names and key
    counts, and Probe() tests whether a candidate field name matches the
    bundle TypeTree. Confirmed worksheetList.Count == 1 (SheetType MainLive),
    and that WashLightList really is spelled with a capital W — upstream
    katboi01 uses washLightList, which never populates.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
ritenseki
2026-08-05 01:11:57 +08:00
parent fbdc97c164
commit a3a7b83236
16 changed files with 1244 additions and 54 deletions
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 7e487cf3ec7419243a47a197ca9f7062
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
+130
View File
@@ -0,0 +1,130 @@
// PostFilm (轨道 39) 的全屏叠加 —— MVP 版本。
//
// 已实现:filmMode 的 None/Lerp/Add/Mul/Monochrome,colorType 全部 4 种布局,
// Vignette* 三种按「基础混合 × 径向衰减」近似。
// 未实现:depthPower/DepthClip(深度混合)、layerMode=UVMovie(图层贴图)。
//
// 参数由 Director.OnPostFilmUpdate 每帧写入,见 PostFilmRendererFeature.cs。
Shader "Hidden/Gallop/PostFilm"
{
SubShader
{
Tags { "RenderType" = "Opaque" "RenderPipeline" = "UniversalPipeline" }
ZWrite Off Cull Off ZTest Always
Pass
{
Name "PostFilm"
HLSLPROGRAM
#pragma vertex Vert
#pragma fragment Frag
// 两个 include 缺一不可,且顺序不能反:
// universal/ShaderLibrary/Core.hlsl —— 定义 TEXTURE2D_X / SAMPLE_TEXTURE2D_X 等 XR 宏
// core/Runtime/Utilities/Blit.hlsl —— 声明 TEXTURE2D_X(_BlitTexture)、全屏三角形 Vert()、
// 并带进 GlobalSamplers.hlsl(sampler_LinearClamp)
// 注意 Blit.hlsl 在 **core** 包的 Runtime/Utilities/ 下,不在 universal 包的 ShaderLibrary/。
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
#include "Packages/com.unity.render-pipelines.core/Runtime/Utilities/Blit.hlsl"
// filmMode: None=0 Lerp=1 Add=2 Mul=3 VignetteLerp=4 VignetteAdd=5 VignetteMul=6 Monochrome=7
// colorType: ColorAll=0 Color2TopBottom=1 Color2LeftRight=2 Color4=3
int _FilmMode;
int _ColorType;
float _FilmPower;
float4 _Color0;
float4 _Color1;
float4 _Color2;
float4 _Color3;
float2 _FilmOffset;
float2 _FilmScale;
float _RollAngle;
float4 _FilmOption;
// Vignette 几何参数的标定系数,由 RendererFeature 暴露到 Inspector 供实时调。
// 原始 filmOptionParam 的单位未知,只能靠观感标定。
float _VignetteWidth; // 缩放作用宽度
float _VignetteStrength; // 缩放整体强度
// 按 colorType 在屏幕上铺开颜色
float4 SampleFilmColor(float2 uv)
{
if (_ColorType == 1) // Color2TopBottom
return lerp(_Color1, _Color0, saturate(uv.y));
if (_ColorType == 2) // Color2LeftRight
return lerp(_Color0, _Color1, saturate(uv.x));
if (_ColorType == 3) // Color4:双线性四角插值
{
float4 bottom = lerp(_Color2, _Color3, saturate(uv.x));
float4 top = lerp(_Color0, _Color1, saturate(uv.x));
return lerp(bottom, top, saturate(uv.y));
}
return _Color0; // ColorAll
}
float4 Frag(Varyings input) : SV_Target
{
UNITY_SETUP_STEREO_EYE_INDEX_POST_VERTEX(input);
float2 uv = input.texcoord;
// _BlitTexture 是 TEXTURE2D_X,必须用 _X 版本采样
float4 src = SAMPLE_TEXTURE2D_X(_BlitTexture, sampler_LinearClamp, uv);
if (_FilmMode == 0 || _FilmPower <= 0.0)
return src;
// 颜色采样用的 UV 支持缩放/偏移/旋转(roll)
float2 cuv = uv - 0.5;
float s, c;
sincos(_RollAngle, s, c);
cuv = float2(cuv.x * c - cuv.y * s, cuv.x * s + cuv.y * c);
float2 scale = (abs(_FilmScale.x) < 1e-5 || abs(_FilmScale.y) < 1e-5)
? float2(1.0, 1.0) : _FilmScale;
cuv = cuv / scale + 0.5 + _FilmOffset;
float4 film = SampleFilmColor(cuv);
float power = _FilmPower;
// Vignette 变体:从画面边缘往内衰减。
// _FilmOption.x = 作用宽度(离边缘多远内为满强度),.y = 过渡柔和度。
// 依据:song 1177 的实际取值为 (0,0.05) (0.05,0.05) (0.08,0.05) (0.2,0.05)
// (0.04,0.17) (0.1,0.17) (0.2,0.25)。y<x 的组合排除了「内径/外径」的读法;
// 而 x=0 在边缘解释下是「只有最外圈 5%」,在径向解释下会变成全屏,后者显然不对。
// 注意这仍是推断,若观感不符先调这里。
if (_FilmMode >= 4 && _FilmMode <= 6)
{
// m 归一化到 0(边缘)~1(中心),这样 _FilmOption.x 才是「占半屏的比例」。
// 之前漏了这一步:m 的原始范围是 0~0.5,等于把作用宽度放大了一倍。
float2 e = min(uv, 1.0 - uv);
float m = min(e.x, e.y) * 2.0;
float start = max(_FilmOption.x, 0.0) * _VignetteWidth;
float soft = max(_FilmOption.y * _VignetteWidth, 1e-4);
power *= (1.0 - smoothstep(start, start + soft, m)) * _VignetteStrength;
}
float3 rgb;
int mode = _FilmMode;
if (mode == 7) // Monochrome
{
float lum = dot(src.rgb, float3(0.299, 0.587, 0.114));
rgb = lerp(src.rgb, lum * film.rgb, power);
}
else if (mode == 2 || mode == 5) // Add / VignetteAdd
{
rgb = src.rgb + film.rgb * film.a * power;
}
else if (mode == 3 || mode == 6) // Mul / VignetteMul
{
rgb = lerp(src.rgb, src.rgb * film.rgb, film.a * power);
}
else // Lerp / VignetteLerp
{
rgb = lerp(src.rgb, film.rgb, film.a * power);
}
return float4(rgb, src.a);
}
ENDHLSL
}
}
Fallback Off
}
@@ -0,0 +1,9 @@
fileFormatVersion: 2
guid: 38174debbaebae343a07ad1ae46e1038
ShaderImporter:
externalObjects: {}
defaultTextures: []
nonModifiableTextures: []
userData:
assetBundleName:
assetBundleVariant:
@@ -58,6 +58,8 @@ namespace Gallop.Live.Cutt
public event BgColor2UpdateInfoDelegate OnUpdateBgColor2;
public event PostFilmUpdateInfoDelegate OnUpdatePostFilm;
public event TransformUpdateInfoDelegate OnUpdateTransform;
public event ObjectUpdateInfoDelegate OnUpdateObject;
@@ -124,6 +126,13 @@ namespace Gallop.Live.Cutt
public Dictionary<string, GameObject> StageObjectMap = new Dictionary<string, GameObject>();
/// <summary>
/// Object/Transform 轨道的组名不一定是 GameObject 名,也可能是 StageController
/// 的 _stageObjectUnits 单元名。例如 live10149 的 'neonsign' 就是一个 unit,
/// 底下挂着 glow_neonsign / wash_neonsign_a / wash_neonsign_b 三个子对象。
/// </summary>
public Dictionary<string, StageObjectUnit> StageObjectUnitMap = new Dictionary<string, StageObjectUnit>();
public float currentLiveTime
{
get
@@ -371,6 +380,9 @@ namespace Gallop.Live.Cutt
AlterUpdate_SimpleListControl(workSheet.chromaticAberrationList, d => d.keys, OnUpdateChromaticAberration, _currentFrame);
AlterUpdate_SimpleListControl(workSheet.hdrBloomKeys, d => d.keys, OnUpdateHdrBloom, _currentFrame);
AlterUpdate_SimpleListControl(workSheet.colorCorrectionDataLists, d => d.keys, OnUpdateColorCorrection, _currentFrame);
AlterUpdate_PostFilm(workSheet.postFilmKeys, 0, _currentFrame);
AlterUpdate_PostFilm(workSheet.postFilm2Keys, 1, _currentFrame);
AlterUpdate_PostFilm(workSheet.postFilm3Keys, 2, _currentFrame);
_isNowAlterUpdate = false;
@@ -1721,22 +1733,24 @@ namespace Gallop.Live.Cutt
}
}
private HashSet<string> validBgColorNames = new HashSet<string> { "CharaCenter", "CharaLeft", "CharaRight", "CharaColor" };
/// <summary>BgColor1 里指向角色的组名;其余组名都是舞台物件,由 StageObjectMap 解析。</summary>
public static readonly HashSet<string> CharaBgColorNames =
new HashSet<string> { "CharaCenter", "CharaLeft", "CharaRight", "CharaColor" };
private void AlterUpdate_BgColor1(LiveTimelineWorkSheet sheet, float currentFrame) {
int count = sheet.bgColor1List.Count;
for (int i = 0; i < count; i++)
{
LiveTimelineKeyBgColor1DataList keys = sheet.bgColor1List[i].keys;
var groupData = sheet.bgColor1List[i];
LiveTimelineKeyBgColor1DataList keys = groupData.keys;
if (keys.HasAttribute(LiveTimelineKeyDataListAttr.Disable) || !keys.EnablePlayModeTimeline(_playMode))
{
continue;
}
else if (!validBgColorNames.Contains(sheet.bgColor1List[i].name))
{
continue;
}
// 这里原本只放行 validBgColorNames(4 个角色组),把舞台物件组全丢了。
// 实测一首歌 20 组 / 1239 keys 里有 17 组是舞台物件,约 92% 的数据被丢弃。
// 现在全部下发,由 Director 按 TimelineName 分流到角色 or 舞台。
FindTimelineKey(out var curKey, out var nextKey, keys, currentFrame);
if (curKey == null)
{
@@ -1745,6 +1759,24 @@ namespace Gallop.Live.Cutt
BgColor1UpdateInfo updateInfo = default;
LiveTimelineKeyBgColor1Data bgColorData = curKey as LiveTimelineKeyBgColor1Data;
LiveTimelineKeyBgColor1Data bgColorData2 = nextKey as LiveTimelineKeyBgColor1Data;
updateInfo.TimelineName = groupData.name;
updateInfo.TimelineNameHash = string.IsNullOrEmpty(groupData.name) ? 0 : Animator.StringToHash(groupData.name);
updateInfo.TargetCharaIdArray = groupData.TargetCharaIdArray;
updateInfo.TargetDressIdArray = groupData.TargetDressIdArray;
// 以下字段此前从未填充,handler 拿到的一直是 default。
updateInfo.colorPower = bgColorData.power;
updateInfo.scale = bgColorData.scale;
updateInfo.vertexColorToonPower = bgColorData.vertexColorToonPower;
updateInfo.outlineWidthPower = bgColorData.outlineWidthPower;
updateInfo.LightBlendMode = bgColorData.LightBlendMode;
updateInfo.IsSilhouette = bgColorData.IsSilhouette;
updateInfo.IsProjector = bgColorData.IsProjector;
updateInfo.CurrentColorType = bgColorData.ColorType;
updateInfo.CurrentColor = bgColorData.color;
updateInfo.CurrentFlags = bgColorData.flags;
if (bgColorData2 != null && bgColorData2.interpolateType != 0)
{
float t = CalculateInterpolationValue(bgColorData, bgColorData2, currentFrame);
@@ -1770,6 +1802,67 @@ namespace Gallop.Live.Cutt
}
}
/// <summary>
/// PostFilm (39)。postFilmKeys 是直接的 key 列表(不是分组列表),
/// 所以不能走 AlterUpdate_SimpleListControl,得自己找 key 和插值。
/// </summary>
private void AlterUpdate_PostFilm(LiveTimelineKeyPostFilmDataList keys, int layerIndex, float currentFrame)
{
if (OnUpdatePostFilm == null || keys == null || keys.Count == 0) return;
if (keys.HasAttribute(LiveTimelineKeyDataListAttr.Disable) || !keys.EnablePlayModeTimeline(_playMode))
return;
FindTimelineKey(out var curKey, out var nextKey, keys, currentFrame);
var cur = curKey as LiveTimelineKeyPostFilmData;
if (cur == null) return;
var next = nextKey as LiveTimelineKeyPostFilmData;
PostFilmUpdateInfo info = default;
info.layerIndex = layerIndex;
info.filmMode = cur.filmMode;
info.colorType = cur.colorType;
info.layerMode = cur.layerMode;
info.colorBlend = cur.colorBlend;
info.movieResId = cur.movieResId;
info.movieFrameOffset = cur.movieFrameOffset;
info.movieSpeed = cur.movieSpeed;
info.enable = cur.filmMode != PostFilmMode.None;
if (next != null && next.interpolateType != 0)
{
float t = CalculateInterpolationValue(cur, next, currentFrame);
info.filmPower = Mathf.Lerp(cur.filmPower, next.filmPower, t);
info.color0 = Color.Lerp(cur.color0, next.color0, t);
info.color1 = Color.Lerp(cur.color1, next.color1, t);
info.color2 = Color.Lerp(cur.color2, next.color2, t);
info.color3 = Color.Lerp(cur.color3, next.color3, t);
info.filmOffsetParam = Vector2.Lerp(cur.filmOffsetParam, next.filmOffsetParam, t);
info.filmOptionParam = Vector4.Lerp(cur.filmOptionParam, next.filmOptionParam, t);
info.filmScale = Vector2.Lerp(cur.FilmScale, next.FilmScale, t);
info.rollAngle = Mathf.Lerp(cur.RollAngle, next.RollAngle, t);
info.depthPower = Mathf.Lerp(cur.depthPower, next.depthPower, t);
info.depthClip = Mathf.Lerp(cur.DepthClip, next.DepthClip, t);
info.colorBlendFactor = Mathf.Lerp(cur.colorBlendFactor, next.colorBlendFactor, t);
}
else
{
info.filmPower = cur.filmPower;
info.color0 = cur.color0;
info.color1 = cur.color1;
info.color2 = cur.color2;
info.color3 = cur.color3;
info.filmOffsetParam = cur.filmOffsetParam;
info.filmOptionParam = cur.filmOptionParam;
info.filmScale = cur.FilmScale;
info.rollAngle = cur.RollAngle;
info.depthPower = cur.depthPower;
info.depthClip = cur.DepthClip;
info.colorBlendFactor = cur.colorBlendFactor;
}
OnUpdatePostFilm.Invoke(ref info);
}
private void AlterUpdate_BgColor2(LiveTimelineWorkSheet sheet, float currentFrame)
{
if (OnUpdateBgColor2 == null) return;
@@ -1847,7 +1940,8 @@ namespace Gallop.Live.Cutt
{
continue;
}
else if (!StageObjectMap.ContainsKey(sheet.objectList[i].name))
else if (!StageObjectMap.ContainsKey(sheet.objectList[i].name) &&
!StageObjectUnitMap.ContainsKey(sheet.objectList[i].name))
{
continue;
}
@@ -53,17 +53,33 @@ namespace Gallop.Live.Cutt
public float DepthClip; // 0x98
public float RollAngle; // 0x9C
public Vector2 FilmScale; // 0xA0
public LiveTimelineKeyLoopType loopType; // 0xA8
public int loopCount; // 0xAC
public int loopExecutedCount; // 0xB0
public int loopIntervalFrame; // 0xB4
public bool isPasteLoopUnit; // 0xB8
public bool isChangeLoopInterpolate; // 0xB9
public LiveTimelineKeyPostFilmData.LayerMode layerMode; // 0xBC
public int movieResId; // 0xC0
public int movieFrameOffset; // 0xC4
public float movieSpeed; // 0xC8
public LiveTimelineKeyPostFilmData.ColorBlend colorBlend; // 0xCC
public float colorBlendFactor; // 0xD0
// 以下顺序按 bundle TypeTree 的权威顺序排列
// (tools/dump_cutt_typetree.py --tree postFilmKeys 1177)。
// 原来的声明把 loop* 组提到了 layerMode 之前,且缺 BlinkLight* 五个字段。
public LiveTimelineKeyPostFilmData.LayerMode layerMode;
public int movieResId;
public int movieFrameOffset;
public float movieSpeed;
public LiveTimelineKeyPostFilmData.ColorBlend colorBlend;
public float colorBlendFactor;
public string BlinkLightName;
public int BlinkLightNameHash;
public int BlinkLightContainerIndex;
public float BlinkLightBrightnessPower;
public bool IsAdjustedBlinkLightColor;
public LiveTimelineKeyLoopType loopType;
public int loopCount;
public int loopExecutedCount;
public int loopIntervalFrame;
public bool isPasteLoopUnit;
public bool isChangeLoopInterpolate;
}
[System.Serializable]
public class LiveTimelineKeyPostFilmDataList : LiveTimelineKeyDataListTemplate<LiveTimelineKeyPostFilmData>
{
}
}
@@ -944,6 +944,7 @@ namespace Gallop.Live.Cutt
[SerializeField] public List<LiveTimelineLightShaftsData> lightShaftsKeysLine;
[SerializeField] public List<LiveTimelineParticleData> particleList;
[SerializeField] public List<LiveTimelineParticleGroupData> particleGroupList;
// 字段名已用 A/B 探针验证过命中 bundle TypeTree(大写 W 正确,小写不填充)。
[SerializeField] public List<LiveTimelineWashLightData> WashLightList;
[SerializeField] public List<LiveTimelineLaserData> laserList;
[SerializeField] public List<LiveTimelineBlinkLightData> blinkLightList;
@@ -951,6 +952,13 @@ namespace Gallop.Live.Cutt
[SerializeField] public List<LiveTimelineHdrBloomData> hdrBloomKeys;
[SerializeField] public List<LiveTimelineColorCorrectionData> colorCorrectionDataLists;
// PostFilm (39):全语料 ~20200 keys / 59 首,是未实现轨道里数据量最大的一块。
// 旧扫描把它记成「58/58 首存在但全部 0 keyframe(空占位)」,实测是错的
// (song 1177 分别有 100 / 161 / 87 keys)。字段名取自 bundle TypeTree。
[SerializeField] public LiveTimelineKeyPostFilmDataList postFilmKeys;
[SerializeField] public LiveTimelineKeyPostFilmDataList postFilm2Keys;
[SerializeField] public LiveTimelineKeyPostFilmDataList postFilm3Keys;
/*
//���ڿ��Ե���AB���ˣ���Ȼ���淢��ûʲô��...˵����ʲôʱ�����õ�
private void Start()
@@ -0,0 +1,211 @@
using System.Collections;
using System.Collections.Generic;
using System.Reflection;
using System.Text;
using UnityEngine;
namespace Gallop.Live.Cutt
{
/// <summary>
/// 临时诊断。当前用途:核对 WorkSheet 字段名是否命中 bundle 的 TypeTree。
///
/// Unity 从 AssetBundle 反序列化 ScriptableObject 时按字段名严格匹配(区分大小写)。
/// 名字没命中的字段不会被赋值,保持 C# 默认值 null;名字命中但本曲没数据的,
/// 会被填成空列表。所以 null 与「空」是两件事:
/// NULL -> 字段名很可能拼错了,该轨道在所有歌里都收不到数据
/// 空 -> 名字是对的,只是这首歌没有这条轨道
///
/// 结论出来后整个文件可删,调用点只有 Director.InitializeTimeline 一处。
/// </summary>
public static class LiveTimelineWorksheetDiag
{
private const BindingFlags kPublicInstance = BindingFlags.Public | BindingFlags.Instance;
public static void Dump(LiveTimelineData data)
{
if (data == null)
{
Debug.LogWarning("[WorksheetDiag] data == null");
return;
}
if (data.worksheetList == null)
{
Debug.LogWarning("[WorksheetDiag] data.worksheetList == null");
return;
}
int sheetCount = data.worksheetList.Count;
Debug.Log($"[WorksheetDiag] ===== worksheetList.Count = {sheetCount} =====");
FieldInfo[] sheetFields = typeof(LiveTimelineWorkSheet).GetFields(kPublicInstance);
var nullFieldsAllSheets = new List<string>();
for (int i = 0; i < sheetCount; i++)
{
LiveTimelineWorkSheet sheet = data.worksheetList[i];
if (sheet == null)
{
Debug.Log($"[WorksheetDiag] [{i}] <null>");
continue;
}
var populated = new List<string>();
var empty = new List<string>();
var nulls = new List<string>();
foreach (FieldInfo f in sheetFields)
{
if (!IsTrackField(f.FieldType)) continue;
object value;
try { value = f.GetValue(sheet); }
catch { continue; }
if (value == null)
{
nulls.Add(f.Name);
nullFieldsAllSheets.Add($"[{i}].{f.Name}");
continue;
}
string detail = Describe(value);
if (detail == null) empty.Add(f.Name);
else populated.Add($"{f.Name}: {detail}");
}
var sb = new StringBuilder();
sb.AppendLine($"[WorksheetDiag] [{i}] SheetType={sheet.SheetType} version='{sheet.version}' " +
$"targetCameraIndex={sheet.targetCameraIndex} TotalTimeLength={sheet.TotalTimeLength}");
sb.AppendLine($" --- 有数据 ({populated.Count}) ---");
foreach (string s in populated) sb.AppendLine($" {s}");
sb.AppendLine($" --- 空,名字对但本曲无此轨道 ({empty.Count}) ---");
sb.AppendLine($" {string.Join(", ", empty)}");
sb.AppendLine($" --- NULL,字段名未命中 TypeTree ({nulls.Count}) ---");
sb.AppendLine($" {string.Join(", ", nulls)}");
Debug.Log(sb.ToString());
}
if (nullFieldsAllSheets.Count > 0)
{
Debug.LogWarning("[WorksheetDiag] 以下字段为 NULL,字段名很可能与 bundle 不匹配 -> " +
string.Join(", ", nullFieldsAllSheets));
}
else
{
// 注意:Unity 加载 bundle 时会把所有 List 字段初始化为空列表,
// 所以「没有 NULL」并不能证明字段名都正确,只能靠 A/B 段判断。
Debug.Log("[WorksheetDiag] 无 NULL 字段(但这不能证明名字都对,见 WashLight A/B 段)。");
}
}
/// <summary>
/// 字段名探针。新增一个轨道字段后,用它确认名字是否命中 bundle 的 TypeTree:
/// 名字对且本曲有数据 -> 「★ 有数据」;名字错 -> 恒为「空」。
/// 拿不准大小写时,并列声明两个拼写各探一次即可(WashLightList 就是这么定下来的)。
/// </summary>
public static void Probe(LiveTimelineWorkSheet sheet, string fieldName)
{
if (sheet == null) { Debug.LogWarning($"[WorksheetDiag] Probe({fieldName}): sheet == null"); return; }
FieldInfo f = typeof(LiveTimelineWorkSheet).GetField(fieldName, kPublicInstance);
if (f == null) { Debug.LogWarning($"[WorksheetDiag] Probe: C# 里没有字段 '{fieldName}'"); return; }
Debug.Log($"[WorksheetDiag] Probe {fieldName}: {DescribeOrEmpty(f.GetValue(sheet))}");
}
/// <summary>区分 null、空列表、有数据三种状态。</summary>
private static string DescribeOrEmpty(object value)
{
if (value == null) return "null";
string detail = Describe(value);
if (detail != null) return "★ 有数据 -> " + detail;
return (value is IList list) ? $"空({list.Count} 组)" : "空";
}
/// <summary>只关心时间轴轨道字段,跳过 version / targetCameraIndex 之类的普通字段。</summary>
private static bool IsTrackField(System.Type type)
{
if (typeof(ILiveTimelineKeyDataList).IsAssignableFrom(type)) return true;
if (type.IsArray) return type.GetElementType().Name.StartsWith("LiveTimeline");
if (type.IsGenericType)
{
System.Type[] args = type.GetGenericArguments();
return args.Length == 1 && args[0].Name.StartsWith("LiveTimeline");
}
return type.Name.StartsWith("LiveTimeline");
}
/// <summary>返回该字段的关键帧摘要;没有任何关键帧时返回 null。</summary>
private static string Describe(object value)
{
if (value == null) return null;
if (value is ILiveTimelineKeyDataList keyList)
return keyList.Count > 0 ? $"{keyList.Count} keys" : null;
if (value is IList list && !(value is string))
{
int totalKeys = 0;
var entries = new List<string>();
foreach (object element in list)
{
if (element == null) continue;
int keys = CountKeys(element, 0);
if (keys <= 0) continue;
totalKeys += keys;
string name = (element as ILiveTimelineGroupDataWithName)?.name;
entries.Add(string.IsNullOrEmpty(name) ? $"?({keys})" : $"{name}({keys})");
}
if (totalKeys == 0) return null;
return $"{entries.Count} 组 / {totalKeys} keys -> {string.Join(", ", entries)}";
}
int nested = CountKeys(value, 0);
return nested > 0 ? $"{nested} keys" : null;
}
/// <summary>递归统计对象里所有 ILiveTimelineKeyDataList 的关键帧总数。</summary>
private static int CountKeys(object obj, int depth)
{
if (obj == null || depth > 2) return 0;
if (obj is ILiveTimelineKeyDataList keyList)
return keyList.Count;
System.Type type = obj.GetType();
if (type.IsPrimitive || type.IsEnum || obj is string) return 0;
int total = 0;
foreach (FieldInfo f in type.GetFields(kPublicInstance))
{
object value;
try { value = f.GetValue(obj); }
catch { continue; }
if (value == null) continue;
if (value is ILiveTimelineKeyDataList nestedList)
{
total += nestedList.Count;
}
else if (value is IList inner && !(value is string))
{
foreach (object element in inner)
total += CountKeys(element, depth + 1);
}
else if (!f.FieldType.IsPrimitive && !f.FieldType.IsEnum && f.FieldType != typeof(string))
{
total += CountKeys(value, depth + 1);
}
}
return total;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: f7c7166ff241f1c4197a58591a0ef069
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -9,6 +9,8 @@ namespace Gallop.Live.Cutt
public struct BgColor1UpdateInfo
{
// Fields
// TimelineName 不在原版结构里,是为了按名字路由到舞台物件而加的(原版只传 hash)。
public string TimelineName;
public int TimelineNameHash; // 0x10
public int[] TargetCharaIdArray; // 0x18
public int[] TargetDressIdArray; // 0x20
@@ -0,0 +1,43 @@
using UnityEngine;
namespace Gallop.Live.Cutt
{
/// <summary>
/// PostFilm (39) 的每帧结果。三条轨道 postFilmKeys / postFilm2Keys / postFilm3Keys
/// 各自独立叠加,用 layerIndex 区分(0/1/2)。
/// </summary>
public struct PostFilmUpdateInfo
{
public int layerIndex;
public bool enable;
public PostFilmMode filmMode;
public PostColorType colorType;
public float filmPower;
public Color color0;
public Color color1;
public Color color2;
public Color color3;
public Vector2 filmOffsetParam;
public Vector4 filmOptionParam;
public Vector2 filmScale;
public float rollAngle;
public float depthPower;
public float depthClip;
public LiveTimelineKeyPostFilmData.LayerMode layerMode;
public LiveTimelineKeyPostFilmData.ColorBlend colorBlend;
public float colorBlendFactor;
// layerMode == UVMovie 时用;内容资源是 live/uvmovie/gal_uvmovie_<songid>_<NNN>。
// 寻址规则尚未确定,先透传。
public int movieResId;
public int movieFrameOffset;
public float movieSpeed;
}
public delegate void PostFilmUpdateInfoDelegate(ref PostFilmUpdateInfo updateInfo);
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: b7dcf9b3ef7cb374fa8054e51711a9e3
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+385 -23
View File
@@ -118,6 +118,7 @@ namespace Gallop.Live
Debug.Log(live.BackGroundId);
Builder.LoadAssetPath(string.Format(STAGE_PATH, live.BackGroundId), transform);
_liveTimelineControl.StageObjectMap = _stageController.StageObjectMap;
_liveTimelineControl.StageObjectUnitMap = _stageController.StageObjectUnitMap;
}
//Make CharacterObject
@@ -172,6 +173,9 @@ namespace Gallop.Live
_uvScrollAccum.Clear();
totalTime = _liveTimelineControl.data.timeLength;
// 临时诊断:确认是否有 worksheet[1..] 携带轨道数据。结论出来后连同 LiveTimelineWorksheetDiag.cs 一起删。
LiveTimelineWorksheetDiag.Dump(_liveTimelineControl.data);
liveMode = mode;
allowCount = characters.Count;
@@ -225,6 +229,8 @@ namespace Gallop.Live
_liveTimelineControl.OnUpdateChromaticAberration += OnChromaticAberrationUpdate;
_liveTimelineControl.OnUpdateHdrBloom += OnHdrBloomUpdate;
_liveTimelineControl.OnUpdateColorCorrection += OnColorCorrectionUpdate;
_liveTimelineControl.OnUpdatePostFilm += OnPostFilmUpdate;
PostFilmRendererFeature.ResetLayers();
// 获取或创建摄像机上的 Volume 组件,供后处理 handler 使用
var mainCam = Camera.main;
@@ -365,8 +371,39 @@ namespace Gallop.Live
}
}
// BgColor1 在舞台物件上对应哪个 shader 属性尚未确认,按存在性依次尝试。
// 首次解析每个轨道组时会打一条日志,说明命中了什么以及材质上有哪些候选属性。
// 舞台 shader 的染色通道。运行时枚举 shader 属性表实测:
// Gallop/3D/Live/Stage/DefaultNoAmbient -> _MulColor0
// Gallop/3D/Live/Stage/DefaultEnvMapNoAmbient -> _MulColor0, _AddColor
// Gallop/3D/Live/Stage/LightBlinkBlend -> _BlinkLightColor(BlinkLight 轨道的,不归 BgColor1)
// Gallop/3D/Live/Stage/StageTransmittedLightMask -> 无
// 和 WashLight 的 MulColor0 / UVScrollLight 的 mulColor1 是同一套命名体系。
// 刻意不含 _AmbientColor:那个通道归 BgColor2(OnBgColor2Update),两条轨道不该抢同一个属性。
private static readonly string[] kStageBgColor1Props = { "_MulColor0" };
/// <summary>解析结果:目标 Renderer + 它实际拥有的那个颜色属性。</summary>
private struct StageBgColorTarget
{
public Renderer renderer;
public string prop;
public bool hasColorPower;
}
private readonly Dictionary<string, List<StageBgColorTarget>> _bgColor1StageCache =
new Dictionary<string, List<StageBgColorTarget>>();
private MaterialPropertyBlock _bgColor1Block;
private void OnBgColor1Update(ref BgColor1UpdateInfo updateInfo)
{
if (!string.IsNullOrEmpty(updateInfo.TimelineName) &&
!LiveTimelineControl.CharaBgColorNames.Contains(updateInfo.TimelineName))
{
ApplyBgColor1ToStage(ref updateInfo);
return;
}
foreach (var locator in _liveTimelineControl.liveCharactorLocators)
{
var EFlags = (LiveCharaPositionFlag)updateInfo.flags;
@@ -384,6 +421,238 @@ namespace Gallop.Live
}
}
/// <summary>
/// BgColor1 的舞台物件分支。轨道组名可能指向 GameObject,也可能指向材质名
/// (uvScrollLightList 用的就是材质名),所以两种都试。
/// </summary>
private void ApplyBgColor1ToStage(ref BgColor1UpdateInfo updateInfo)
{
if (_stageController == null) return;
string key = updateInfo.TimelineName;
if (!_bgColor1StageCache.TryGetValue(key, out var targets))
{
targets = ResolveStageTargets(key);
_bgColor1StageCache[key] = targets;
LogBgColor1Resolution(key, targets);
}
if (targets.Count == 0) return;
_bgColor1Block ??= new MaterialPropertyBlock();
foreach (var t in targets)
{
if (t.renderer == null) continue;
t.renderer.GetPropertyBlock(_bgColor1Block);
_bgColor1Block.SetColor(t.prop, updateInfo.color);
// ⚠ 不要在这里写 _ColorPower —— 缺 ground truth。
// 已核实的只有「这些 shader 上 _ColorPower 这个 Float 属性存在」;
// **没有任何证据表明 BgColor1 的 power 字段映射到它**,它也可能是乘在
// 材质原值上、或者对应完全不同的东西。2026-08-05 试写过一版,无法判断对错,
// 遂按「缺依据即不做」撤回。t.hasColorPower 保留,供将来确认后启用。
//
// (注:舞台地板 plane_000 / stage_object_001 / specular_002 长期偏亮发白
// 是**早于本改动就存在**的问题,与 _ColorPower 无关,另行排查。)
t.renderer.SetPropertyBlock(_bgColor1Block);
}
}
/// <summary>
/// 枚举 shader 真正声明的 Color 属性。
/// 注意:材质在 bundle 里的 m_SavedProperties 会保留历史属性,和当前 shader 声明的不是一回事,
/// 判断能写什么必须问 shader,不能看材质存档表。
/// </summary>
private static string DescribeShaderColors(Shader sh)
{
if (sh == null) return "<null shader>";
var colors = new List<string>();
int count = sh.GetPropertyCount();
for (int i = 0; i < count; i++)
{
if (sh.GetPropertyType(i) == ShaderPropertyType.Color)
colors.Add(sh.GetPropertyName(i));
}
return $"{sh.name} 颜色属性: {(colors.Count > 0 ? string.Join(",", colors) : "<无>")}";
}
/// <summary>用 sharedMaterials 探测(不会实例化材质),只保留确实有候选属性的 Renderer。</summary>
private List<StageBgColorTarget> ResolveStageTargets(string timelineName)
{
var result = new List<StageBgColorTarget>();
_bgColor1FoundObject = false;
_bgColor1ObjCount = 0;
_bgColor1RendererCount = 0;
_bgColor1MatInfo.Clear();
// 按 Transform 名遍历整个舞台层级。
// 不能只查 StageObjectMap:它按名字去重,而观众群里同名对象有几十上百个
// (mob_a000 实测 66 个),只取第一个会漏掉绝大多数。
// StageObjectMap 里的对象本身也在这个层级里,所以这一趟已经覆盖它。
var seen = new HashSet<Renderer>();
var byName = new List<Renderer>();
foreach (var tr in _stageController.GetComponentsInChildren<Transform>(true))
{
if (!string.Equals(tr.name.Replace("(Clone)", ""), timelineName, StringComparison.OrdinalIgnoreCase))
continue;
_bgColor1FoundObject = true;
_bgColor1ObjCount++;
foreach (var r in tr.GetComponentsInChildren<Renderer>(true))
if (r != null && seen.Add(r)) byName.Add(r);
}
// StageObjectMap 兜底:万一有对象不在 _stageController 层级下。
if (_stageController.StageObjectMap.TryGetValue(timelineName, out var go) && go != null)
{
_bgColor1FoundObject = true;
foreach (var r in go.GetComponentsInChildren<Renderer>(true))
if (r != null && seen.Add(r)) byName.Add(r);
}
_bgColor1RendererCount = byName.Count;
foreach (var r in byName)
{
if (r == null) continue;
foreach (var mat in r.sharedMaterials)
{
if (mat == null) { _bgColor1MatInfo.Add("<null mat>"); continue; }
string info = $"{mat.name}[{DescribeShaderColors(mat.shader)}]";
if (!_bgColor1MatInfo.Contains(info)) _bgColor1MatInfo.Add(info);
}
}
if (byName.Count > 0)
{
CollectTargets(byName, result);
if (result.Count > 0) return result;
}
// 退回按材质名匹配(uvScrollLightList 用的就是材质名)
var matched = new List<Renderer>();
foreach (var r in _stageController.GetComponentsInChildren<Renderer>(true))
{
foreach (var mat in r.sharedMaterials)
{
if (mat == null) continue;
if (mat.name.IndexOf(timelineName, StringComparison.OrdinalIgnoreCase) >= 0)
{
_bgColor1FoundObject = true;
matched.Add(r);
break;
}
}
}
CollectTargets(matched, result);
return result;
}
// ResolveStageTargets 的副产物,用于把失败原因拆干净:
// 找到几个同名对象 / 它们下面有几个 Renderer / 这些 Renderer 用的材质和 shader。
private bool _bgColor1FoundObject;
private int _bgColor1ObjCount;
private int _bgColor1RendererCount;
private readonly List<string> _bgColor1MatInfo = new List<string>();
private static void CollectTargets(IEnumerable<Renderer> renderers, List<StageBgColorTarget> result)
{
foreach (var r in renderers)
{
if (r == null) continue;
foreach (var mat in r.sharedMaterials)
{
if (mat == null) continue;
string hit = null;
foreach (string p in kStageBgColor1Props)
{
if (mat.HasProperty(p)) { hit = p; break; }
}
if (hit != null)
{
result.Add(new StageBgColorTarget
{
renderer = r,
prop = hit,
hasColorPower = mat.HasProperty(kColorPowerProp),
});
break;
}
}
}
}
private void LogBgColor1Resolution(string timelineName, List<StageBgColorTarget> targets)
{
if (targets.Count == 0)
{
if (!_bgColor1FoundObject)
Debug.LogWarning($"[BgColor1] '{timelineName}':整个舞台层级里找不到同名对象,材质名也匹配不上");
else if (_bgColor1RendererCount == 0)
Debug.LogWarning($"[BgColor1] '{timelineName}':找到 {_bgColor1ObjCount} 个同名对象,但它们下面没有任何 Renderer");
else
Debug.LogWarning($"[BgColor1] '{timelineName}':找到 {_bgColor1ObjCount} 个对象 / {_bgColor1RendererCount} 个 Renderer," +
$"但材质无候选属性。材质[shader]: {string.Join(" | ", _bgColor1MatInfo)}");
return;
}
var props = new HashSet<string>();
foreach (var t in targets) props.Add(t.prop);
// 成功分支也打印 shader 真实声明的颜色属性,便于核对写对了通道。
var shaders = new List<string>();
foreach (var t in targets)
foreach (var mat in t.renderer.sharedMaterials)
{
if (mat == null) continue;
string info = DescribeShaderColors(mat.shader);
if (!shaders.Contains(info)) shaders.Add(info);
}
Debug.Log($"[BgColor1] '{timelineName}' -> {targets.Count} 个 Renderer,写入 {string.Join(",", props)};" +
$"{string.Join(" | ", shaders)}");
}
/// <summary>
/// PostFilm (39)。三条轨道各占一层,参数写进 PostFilmRendererFeature.Layers,
/// 由 RendererFeature 在 AfterRenderingPostProcessing 做全屏叠加。
/// 该 Feature 必须先加进 UMAUniversalRenderPipelineAsset_Renderer.asset 才会生效。
/// </summary>
private void OnPostFilmUpdate(ref PostFilmUpdateInfo info)
{
int i = info.layerIndex;
if (i < 0 || i >= PostFilmRendererFeature.kLayerCount) return;
PostFilmRendererFeature.Layers[i] = new PostFilmRendererFeature.LayerState
{
enable = info.enable,
filmMode = info.filmMode,
colorType = info.colorType,
filmPower = info.filmPower,
color0 = info.color0,
color1 = info.color1,
color2 = info.color2,
color3 = info.color3,
filmOffset = info.filmOffsetParam,
filmScale = info.filmScale,
rollAngle = info.rollAngle,
filmOption = info.filmOptionParam,
};
LogPostFilmOnce(i, ref info);
}
private readonly HashSet<int> _postFilmLogged = new HashSet<int>();
private void LogPostFilmOnce(int layer, ref PostFilmUpdateInfo info)
{
if (!_postFilmLogged.Add(layer)) return;
Debug.Log($"[PostFilm] layer{layer} filmMode={info.filmMode} colorType={info.colorType} " +
$"power={info.filmPower:F3} layerMode={info.layerMode} colorBlend={info.colorBlend} " +
$"movieResId={info.movieResId} color0={info.color0} scale={info.filmScale} " +
$"offset={info.filmOffsetParam} roll={info.rollAngle:F3} option={info.filmOptionParam}");
}
private void OnCameraSwitcherUpdate(int cameraIndex_)
{
if (cameraIndex_ < 0)
@@ -515,35 +784,128 @@ namespace Gallop.Live
var renderers = go.GetComponentsInChildren<Renderer>(true);
if (renderers.Length == 0) return;
if (keyData.pattern == 0)
{
for (int i = 0; i < renderers.Length; i++)
{
int idx = i < keyData.powerArray?.Length ? i : 0;
float power = keyData.powerArray != null && keyData.powerArray.Length > idx ? keyData.powerArray[idx] : 1f;
Color col = keyData.color0Array != null && keyData.color0Array.Length > idx ? keyData.color0Array[idx] : Color.white;
foreach (var mat in renderers[i].materials)
{
mat.SetColor("_Color", col);
mat.SetFloat("_ColorPower", power);
}
}
return;
}
_blinkLightBlock ??= new MaterialPropertyBlock();
int noProp = 0;
// color0Array/powerArray 恒为 10 项,是「调色板」而不是每盏灯一项
// (实测:renderer 数可达 570,数组仍是 10;wash_truss_a 的前 4 项正好等于本曲成员数)。
// 每盏灯到调色板槽位的映射规则尚未逆出来,暂统一取第 0 槽 —— 对灯牌是正确的
// (它 6 个有效槽同色),对多色组(wash/mob)是简化。
Color baseCol = (keyData.color0Array != null && keyData.color0Array.Length > 0)
? keyData.color0Array[0] : Color.white;
float basePower = (keyData.powerArray != null && keyData.powerArray.Length > 0)
? keyData.powerArray[0] : 1f;
float baseElapsed = _liveTimelineControl.currentLiveTime - keyData.frame / 60f - keyData.waitTime;
float cycle = keyData.turnOnTime + keyData.keepTime + keyData.turnOffTime + keyData.intervalTime;
// Blink: 按时间周期计算强度
float elapsed = _liveTimelineControl.currentLiveTime - keyData.frame / 60f - keyData.waitTime;
float intensity = ComputeBlinkIntensity(keyData, elapsed);
for (int i = 0; i < renderers.Length; i++)
{
Color col = keyData.color0Array != null && i < keyData.color0Array.Length ? keyData.color0Array[i] : Color.white;
foreach (var mat in renderers[i].materials)
var r = renderers[i];
if (r == null) continue;
Color col = baseCol;
float power = basePower;
if (keyData.pattern != 0)
{
mat.SetColor("_Color", col * intensity);
mat.SetFloat("_ColorPower", intensity);
// pattern != 0 时逐灯错开相位,做出滚动闪烁(U 闪 → M 闪 → A 闪)。
// pattern 的确切语义还没逆出来,这里只区分「同步」与「滚动」两种;
// 若方向或速度不对,调这里的相位公式即可。
float phase = (cycle > 0f && renderers.Length > 1)
? cycle * i / renderers.Length
: 0f;
power *= ComputeBlinkIntensity(keyData, baseElapsed - phase);
}
// 这些灯的 shader 是 Gallop/3D/Live/Stage/LightBlinkBlend,唯一的 Color 属性是
// _BlinkLightColor。之前写的是 _Color —— 该 shader 上没有这个属性,写入是空操作,
// 于是 _BlinkLightColor 一直是默认值,灯全渲染成黑块。
BlinkTarget t = ResolveBlinkTarget(r);
if (t.colorProp == null) { noProp++; continue; }
r.GetPropertyBlock(_blinkLightBlock);
if (t.hasColorPower)
{
// shader 自带独立的亮度通道,颜色和强度分开写,合成交给 shader。
_blinkLightBlock.SetColor(t.colorProp, col);
_blinkLightBlock.SetFloat(kColorPowerProp, power);
}
else
{
// BgMirrorBall 这类只有 _MulColor0、没有 _ColorPower,只能把强度乘进颜色。
_blinkLightBlock.SetColor(t.colorProp, col * power);
}
r.SetPropertyBlock(_blinkLightBlock);
}
LogBlinkLightOnce(data.name, keyData, renderers, noProp);
// TODO: 调色板槽位映射、color1Array、LightBlendMode、isReverseHueArray 尚未实现。
}
private MaterialPropertyBlock _blinkLightBlock;
// 舞台灯光 shader 的通道(枚举 shader 属性表实测,见 CLAUDE.md):
// LightBlinkBlend _BlinkLightColor + _ColorPower
// DefaultNoAmbient _MulColor0 + _ColorPower
// DefaultEnvMapNoAmbient _MulColor0 + _ColorPower (+_AddColor)
// DefaultTransparentNoAmbient _MulColor0 + _ColorPower (+_AmbientColor)
// BgMirrorBall _MulColor0 (无 _ColorPower)
// StageMirrorBallShine / StageTransmittedLightMask 完全没有颜色属性
private static readonly string[] kBlinkColorProps = { "_BlinkLightColor", "_MulColor0", "_Color" };
private const string kColorPowerProp = "_ColorPower";
private struct BlinkTarget
{
public string colorProp;
public bool hasColorPower;
}
private readonly Dictionary<Renderer, BlinkTarget> _blinkPropCache = new Dictionary<Renderer, BlinkTarget>();
private readonly HashSet<string> _blinkLoggedGroups = new HashSet<string>();
private BlinkTarget ResolveBlinkTarget(Renderer r)
{
if (_blinkPropCache.TryGetValue(r, out BlinkTarget cached)) return cached;
BlinkTarget t = default;
foreach (var mat in r.sharedMaterials)
{
if (mat == null) continue;
foreach (string p in kBlinkColorProps)
{
if (mat.HasProperty(p)) { t.colorProp = p; break; }
}
if (t.colorProp != null)
{
t.hasColorPower = mat.HasProperty(kColorPowerProp);
break;
}
}
// TODO: color1Array, LightBlendMode, isReverseHueArray, color blend fields unused
_blinkPropCache[r] = t;
return t;
}
/// <summary>每个 BlinkLight 组只打一次:pattern / 数组长度 / renderer 数 / shader 属性,用于设计逐灯相位。</summary>
private void LogBlinkLightOnce(string groupName, LiveTimelineKeyBlinkLightData k, Renderer[] renderers, int noProp)
{
if (!_blinkLoggedGroups.Add(groupName)) return;
var shaders = new List<string>();
foreach (var r in renderers)
foreach (var mat in r.sharedMaterials)
{
if (mat == null) continue;
string info = DescribeShaderColors(mat.shader);
if (!shaders.Contains(info)) shaders.Add(info);
}
Debug.Log($"[BlinkLight] '{groupName}' renderers={renderers.Length} 无可写颜色属性={noProp} " +
$"pattern={k.pattern} colorType={k.colorType} blendMode={k.LightBlendMode} " +
$"color0Array={k.color0Array?.Length ?? -1} color1Array={k.color1Array?.Length ?? -1} " +
$"powerArray={k.powerArray?.Length ?? -1} reverseHue={k.isReverseHueArray?.Length ?? -1} " +
$"power={k.powerMin}~{k.powerMax} loop={k.loopCount} " +
$"wait={k.waitTime} on={k.turnOnTime} keep={k.keepTime} off={k.turnOffTime} interval={k.intervalTime}; " +
$"{string.Join(" | ", shaders)}");
}
private static float ComputeBlinkIntensity(LiveTimelineKeyBlinkLightData keyData, float elapsed)
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: ecfe64f6a78444342a929112bd228b60
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,216 @@
using System;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;
using Gallop.Live.Cutt;
namespace Gallop.Live
{
/// <summary>
/// PostFilm (39) 的 URP 实现。全语料 ~20200 keys / 59 首,是数据量最大的未实现轨道。
///
/// 用法:把本 Feature 加到 Assets/Resources/RenderPipeline/UMAUniversalRenderPipelineAsset_Renderer.asset
/// 的 Renderer Features 列表里(一次性操作)。shader 从 Resources 载入,不需要在 Inspector 指派。
///
/// Director 每帧通过静态的 Layers 写入参数;三条轨道
/// postFilmKeys / postFilm2Keys / postFilm3Keys 各占一层,按 0→1→2 顺序叠加。
/// </summary>
public class PostFilmRendererFeature : ScriptableRendererFeature
{
public const int kLayerCount = 3;
[Serializable]
public struct LayerState
{
public bool enable;
public PostFilmMode filmMode;
public PostColorType colorType;
public float filmPower;
public Color color0, color1, color2, color3;
public Vector2 filmOffset;
public Vector2 filmScale;
public float rollAngle;
public Vector4 filmOption;
}
/// <summary>Director 每帧写这里;Feature 只读。索引 = PostFilmUpdateInfo.layerIndex。</summary>
public static readonly LayerState[] Layers = new LayerState[kLayerCount];
/// <summary>Live 结束时调用,避免参数残留到下一首歌。</summary>
public static void ResetLayers()
{
for (int i = 0; i < kLayerCount; i++) Layers[i] = default;
}
[SerializeField] private RenderPassEvent _passEvent = RenderPassEvent.AfterRenderingPostProcessing;
// ───────────────────────────────────────────────────────────────────────
// 默认关闭:渲染部分缺少 ground truth,开着会画错。
//
// 已验证的部分(保留并启用):字段名/类型、三条轨道的关键帧解析、分发与插值、
// filmMode / colorType 的枚举取值(song 1177 实测 100/161/87 keys)。
//
// 未验证的部分(就是它导致必须关闭):filmOptionParam 的单位与几何含义。
// song 1177 的 100 个 key 全是 Vignette 变体,取值形如 (0.2,0.05) (0,0.05) (0.2,0.25),
// 但「0.2」对应屏幕上多大范围无从得知——定义在游戏自己的 PostFilm shader 里,
// 而全库 499 个 shader(`shader` bundle)按名字和属性签名都搜过,**没有这个 shader**,
// 它应该编在 app 本体的 resources.assets 里,我们拿不到。
// 没有它,vignette 衰减只能靠观感标定,猜了两版都不对(一版全屏泛色、一版大边框)。
//
// 要继续做:对着参考视频找一帧 vignette 明显的画面,勾上下面的开关,
// 调 Width/Strength 直到吻合,然后把数值写死进代码并注明标定依据。
// ───────────────────────────────────────────────────────────────────────
[Header("⚠ 渲染部分未标定,默认关闭")]
[Tooltip("勾上才会实际绘制。filmOptionParam 的几何语义未知,开启后画面大概率不准。")]
[SerializeField] private bool _enableRendering = false;
[Header("Vignette 标定(仅在上面勾选后有意义)")]
[Tooltip("作用宽度系数。filmOptionParam.x 乘以它后,1.0 = 从边缘一直吃到画面中心。调小 = 边框更窄。")]
[Range(0.02f, 1f)]
[SerializeField] private float _vignetteWidth = 0.25f;
[Tooltip("整体强度系数。调小 = 边缘颜色更淡。")]
[Range(0f, 2f)]
[SerializeField] private float _vignetteStrength = 1f;
private PostFilmPass _pass;
private Material _material;
public override void Create()
{
if (_material == null)
{
Shader shader = Resources.Load<Shader>("Shaders/PostFilm");
if (shader == null)
{
Debug.LogError("[PostFilm] 找不到 Resources/Shaders/PostFilm,Feature 不会生效");
return;
}
_material = CoreUtils.CreateEngineMaterial(shader);
}
_pass = new PostFilmPass(_material) { renderPassEvent = _passEvent };
_pass.SetCalibration(_vignetteWidth, _vignetteStrength);
}
public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
{
// 渲染默认关闭,理由见字段声明处。数据层与分发层仍然照常跑,
// Director 的 [PostFilm] 日志不受影响,方便继续查参数。
if (!_enableRendering) return;
if (_pass == null || _material == null) return;
// 每帧同步,Inspector 里拖动能立刻在 Play 模式下看到变化
_pass.SetCalibration(_vignetteWidth, _vignetteStrength);
if (renderingData.cameraData.cameraType != CameraType.Game &&
renderingData.cameraData.cameraType != CameraType.SceneView) return;
bool anyActive = false;
for (int i = 0; i < kLayerCount; i++)
{
if (Layers[i].enable && Layers[i].filmMode != PostFilmMode.None && Layers[i].filmPower > 0f)
{
anyActive = true;
break;
}
}
if (!anyActive) return;
renderer.EnqueuePass(_pass);
}
protected override void Dispose(bool disposing)
{
_pass?.Dispose();
CoreUtils.Destroy(_material);
_material = null;
}
private class PostFilmPass : ScriptableRenderPass
{
private static readonly int kFilmMode = Shader.PropertyToID("_FilmMode");
private static readonly int kColorType = Shader.PropertyToID("_ColorType");
private static readonly int kFilmPower = Shader.PropertyToID("_FilmPower");
private static readonly int kColor0 = Shader.PropertyToID("_Color0");
private static readonly int kColor1 = Shader.PropertyToID("_Color1");
private static readonly int kColor2 = Shader.PropertyToID("_Color2");
private static readonly int kColor3 = Shader.PropertyToID("_Color3");
private static readonly int kFilmOffset = Shader.PropertyToID("_FilmOffset");
private static readonly int kFilmScale = Shader.PropertyToID("_FilmScale");
private static readonly int kRollAngle = Shader.PropertyToID("_RollAngle");
private static readonly int kFilmOption = Shader.PropertyToID("_FilmOption");
private static readonly int kVigWidth = Shader.PropertyToID("_VignetteWidth");
private static readonly int kVigStrength = Shader.PropertyToID("_VignetteStrength");
private readonly Material _material;
private RTHandle _temp;
private float _vigWidth = 0.25f;
private float _vigStrength = 1f;
public PostFilmPass(Material material)
{
_material = material;
profilingSampler = new ProfilingSampler("PostFilm");
}
public void SetCalibration(float width, float strength)
{
_vigWidth = width;
_vigStrength = strength;
}
public override void OnCameraSetup(CommandBuffer cmd, ref RenderingData renderingData)
{
var desc = renderingData.cameraData.cameraTargetDescriptor;
desc.depthBufferBits = 0;
desc.msaaSamples = 1;
RenderingUtils.ReAllocateIfNeeded(ref _temp, desc, FilterMode.Bilinear,
TextureWrapMode.Clamp, name: "_PostFilmTemp");
}
public override void Execute(ScriptableRenderContext context, ref RenderingData renderingData)
{
if (_material == null || _temp == null) return;
RTHandle src = renderingData.cameraData.renderer.cameraColorTargetHandle;
if (src == null) return;
CommandBuffer cmd = CommandBufferPool.Get();
using (new ProfilingScope(cmd, profilingSampler))
{
for (int i = 0; i < kLayerCount; i++)
{
LayerState s = Layers[i];
if (!s.enable || s.filmMode == PostFilmMode.None || s.filmPower <= 0f) continue;
_material.SetInt(kFilmMode, (int)s.filmMode);
_material.SetInt(kColorType, (int)s.colorType);
_material.SetFloat(kFilmPower, s.filmPower);
_material.SetColor(kColor0, s.color0);
_material.SetColor(kColor1, s.color1);
_material.SetColor(kColor2, s.color2);
_material.SetColor(kColor3, s.color3);
_material.SetVector(kFilmOffset, s.filmOffset);
_material.SetVector(kFilmScale, s.filmScale);
_material.SetFloat(kRollAngle, s.rollAngle);
_material.SetVector(kFilmOption, s.filmOption);
_material.SetFloat(kVigWidth, _vigWidth);
_material.SetFloat(kVigStrength, _vigStrength);
// 不能把 RT 直接 blit 回自身,走一次临时 RT。
Blitter.BlitCameraTexture(cmd, src, _temp, _material, 0);
Blitter.BlitCameraTexture(cmd, _temp, src);
}
}
context.ExecuteCommandBuffer(cmd);
cmd.Clear();
CommandBufferPool.Release(cmd);
}
public void Dispose()
{
_temp?.Release();
_temp = null;
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 61d001d58c2e5434590aaca747d7b830
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -83,6 +83,25 @@ namespace Gallop.Live
{
return;
}
// 组名可能是 _stageObjectUnits 的单元名而不是 GameObject 名
// (live10149 的 'neonsign' 就是 unit,底下挂 glow/wash_a/wash_b 三个子对象;
// 没有任何 GameObject 叫 neonsign,所以只查 StageObjectMap 会整条轨道失效,
// 灯牌就不会从吊顶降下来)。
if (!StageObjectMap.ContainsKey(updateInfo.data.name) &&
StageObjectUnitMap.TryGetValue(updateInfo.data.name, out StageObjectUnit unit))
{
foreach (var child in unit.ChildObjects)
{
if (child == null) continue;
if (!StageObjectMap.TryGetValue(child.name.Replace("(Clone)", ""), out var childGo))
childGo = child;
childGo.SetActive(updateInfo.renderEnable);
ApplyObjectTrs(childGo.transform, ref updateInfo);
}
return;
}
if (StageObjectMap.TryGetValue(updateInfo.data.name, out GameObject gameObject))
{
gameObject.SetActive(updateInfo.renderEnable);
@@ -112,21 +131,52 @@ namespace Gallop.Live
gameObject.transform.SetParent(attach_transform);
}
if (updateInfo.data.enablePosition)
{
gameObject.transform.localPosition = updateInfo.updateData.position;
}
if (updateInfo.data.enableRotate)
{
gameObject.transform.localRotation = updateInfo.updateData.rotation;
}
if (updateInfo.data.enableScale)
{
gameObject.transform.localScale = updateInfo.updateData.scale;
}
ApplyObjectTrs(gameObject.transform, ref updateInfo);
}
}
/// <summary>物件的原始 local TRS,Add 模式要在它之上叠加。首次触碰时懒记录。</summary>
private readonly Dictionary<Transform, TransformBaseData> _objectBaseTrs =
new Dictionary<Transform, TransformBaseData>();
/// <summary>
/// 按 OffsetType 应用 TRS。
/// Direct(0) = 绝对值;Add(1) = 相对物件原始 local TRS 的偏移。
/// 之前一直无视这个字段,一律当绝对值写,于是 Add 语义的物件全被挪到父物体原点
/// ——live10149 的 neonsign 灯牌就是这样掉到地上的(它的 y=12.58→0 是「抬高 12.58 开场,再降回原位」)。
/// </summary>
private void ApplyObjectTrs(Transform tr, ref ObjectUpdateInfo updateInfo)
{
if (!_objectBaseTrs.TryGetValue(tr, out TransformBaseData baseTrs))
{
baseTrs = new TransformBaseData
{
position = tr.localPosition,
rotation = tr.localRotation,
scale = tr.localScale,
};
_objectBaseTrs[tr] = baseTrs;
}
bool add = updateInfo.OffsetType == OffsetType.Add;
if (updateInfo.data.enablePosition)
tr.localPosition = add
? baseTrs.position + updateInfo.updateData.position
: updateInfo.updateData.position;
if (updateInfo.data.enableRotate)
tr.localRotation = add
? baseTrs.rotation * updateInfo.updateData.rotation
: updateInfo.updateData.rotation;
// scale 用乘法而不是加法:关键帧里 scale 恒为 1,加法会把物件放大一倍。
if (updateInfo.data.enableScale)
tr.localScale = add
? Vector3.Scale(baseTrs.scale, updateInfo.updateData.scale)
: updateInfo.updateData.scale;
}
public void UpdateTransform(ref TransformUpdateInfo updateInfo)
{
if (updateInfo.data == null)