Live: add monitors, cyalumes, and skirt collision tuning

This commit is contained in:
0xcjy
2026-08-09 22:55:16 +08:00
parent c744e16fca
commit fdc8635379
57 changed files with 9887 additions and 191 deletions
+408
View File
@@ -0,0 +1,408 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
UmaViewer is a Unity application (2022.3.62f1) that loads and displays assets from Uma Musume: Pretty Derby. It reads encrypted asset bundles directly from the game installation without modifying game files.
## Development
**Open the project:** Unity Hub → Add → `C:\Users\riten\Desktop\UmaBuild`
**Run:** Open `Assets/Scenes/Version2` scene → Play in Unity Editor
**Build:** File → Build Settings → PC Standalone → Build
No CLI build commands — Unity Editor is required.
## Game Data & Asset Pipeline
Game assets are encrypted. Key files:
- `meta` (SQLite-MC ChaCha20 encrypted) — maps bundle names → hash filenames in `dat/`
- `dat/{2-char-prefix}/{hash}` — encrypted asset bundles, each with a per-bundle XOR key from meta
**Bundle decryption** (`UmaAssetBundleStream.cs`): header (first 256 bytes) is plain; bytes ≥256 are XOR'd with `FKey = ABKey[i] XOR bundleKey_bytes[j]`.
**meta DB decryption**: `UmaDatabaseController.ReadMetaFromEncryptedDb()` uses `sqlite3mc_x64.dll` with cipher=3 (ChaCha20). Final key = `DBKey[i] XOR DBBaseKey[i % 13]`.
**Asset bundle paths** (key formats):
- Live cutt timelines: `cutt/cutt_son{music_id}/cutt_son{music_id}`
- Live effect prefabs: `3d/effect/live/pfb_{effectName}`
- Stage prefabs: `3d/env/live/live{bgId}/pfb_env_live{bgId}_controller000`
**Python tools** (`tools/`) — rebuilt 2026-08-04 after the old `UmaCrack\` toolchain was deleted. Run with `~/.venvs/umatools/bin/python3` (needs `UnityPy` + `apsw-sqlite3mc`, **not** plain `apsw`; see `tools/requirements.txt`).
| Script | Use |
|---|---|
| `uma_common.py` | Library: key constants, `decrypt_bundle_bytes()`, `MetaDb` (path→hash, UnityPy load) |
| `dump_meta.py` | `plain` → `out/meta_plain.db` · `find <LIKE>` · `info <path>` (hash, dat path, deps, `[NOT DOWNLOADED]`) · `songs` |
| `dump_bundle.py` | `list` / `tree` / `json` / `save` / `gameobjects`, `--deps` |
| `dump_cutt_typetree.py` | **Authoritative track schema.** `--fields <song>` · `--tree <field> <song>` · `--sample <field> <song>` · `--groups` |
| `scan_live_tracks.py` | All 59 songs → `out/scan_keys.csv`, `scan_groups.csv`, `scan_summary.txt`, `scan.json` (~21 s) |
Notes: `meta` decrypts via sqlite3mc `PRAGMA cipher='chacha20'` + `PRAGMA hexkey='<64 hex>'` (the `key=x'...'` and `raw:` forms both fail); `VACUUM INTO` re-encrypts the target, so `dump_meta.py plain` copies schema+rows explicitly.
**Cutt bundles ship full TypeTrees, so field names and keyframe counts from these tools are ground truth.** All 70 worksheets across all 59 songs share one 128-field schema — no version drift. Only `son<id>_camera` and `type_01..type_10` carry the trees; the `cutt_son<id>` prefab and `data` bundles are stripped.
**Getting a track's real field name without the dump tools** — Unity matches bundle TypeTree fields by name, case-sensitively. A field whose name doesn't match is never populated (it does *not* become null — Unity initialises every serialised `List<T>` to an empty list, so null-vs-empty tells you nothing). To test a candidate name, declare it on `LiveTimelineWorkSheet` and check whether it receives keys:
```csharp
LiveTimelineWorksheetDiag.Probe(sheet, "candidateFieldName");
```
Unsure about casing? Declare both spellings side by side — only the correct one fills. This is how `WashLightList` (capital W) was confirmed; note upstream katboi01 uses `washLightList`, which never populates.
## Live Timeline Architecture
The Live playback system is in `Assets/Scripts/umamusume/Gallop/Live/`:
```
Director.cs ← singleton, wires everything together
Cutt/
LiveTimelineControl.cs ← dispatches timeline events each frame (~1900 lines)
LiveTimelineWorkSheet.cs ← all track data fields (ScriptableObject)
LiveTimelineDataList/ ← one .cs per track type
UpdateInfo/ ← structs passed to event handlers
StageController.cs ← manages stage GameObjects, StageObjectMap
```
**Track implementation pattern** (repeat for each new track):
1. Add data class in `LiveTimelineDataList/` (extend `LiveTimelineKeyWithInterpolate` for key, add group wrapper implementing `ILiveTimelineGroupDataWithName`)
2. Add `[SerializeField] public List<...> trackList;` to `LiveTimelineWorkSheet`
3. Add `public event Action<TData, TKey> OnUpdateXxx;` in `LiveTimelineControl`
4. Call `AlterUpdate_SimpleListControl(workSheet.trackList, d => d.keys, OnUpdateXxx, _currentFrame)` in `AlterLateUpdate()`
5. Subscribe `_liveTimelineControl.OnUpdateXxx += handler` in `Director.InitializeTimeline()`
**Important:** `LiveTimelineKeyWithInterpolate` already declares `frame`, `attribute`, `interpolateType`, `curve`, `easingType` — do NOT redeclare these in subclasses.
**Stage shader colour channels** (enumerated at runtime via `Shader.GetPropertyType`, confirmed on stage live10149 / song 1177):
| shader | colour | brightness | other |
|---|---|---|---|
| `Gallop/3D/Live/Stage/DefaultNoAmbient` | `_MulColor0` | `_ColorPower` | |
| `Gallop/3D/Live/Stage/DefaultEnvMapNoAmbient` | `_MulColor0` | `_ColorPower` | `_AddColor`, `_EnvMap` |
| `Gallop/3D/Live/Stage/DefaultTransparentNoAmbient` | `_MulColor0` | `_ColorPower` | `_AmbientColor` |
| `Gallop/3D/Live/Stage/LightBlinkBlend` | `_BlinkLightColor` | `_ColorPower` | `_SrcBlend`/`_DstBlend` |
| `Gallop/3D/Live/Stage/LightBlend` / `LightAdd1` | `_MulColor0`, `_MulColor1` | `_ColorPower`, `_ColorPowerMultiply` | |
| `Gallop/3D/Live/Stage/Laser` | `_MulColor0`, `_MulColor1` | `_ColorPower` | `_RandomValue` |
| `Gallop/3D/Bg/BgMirrorBall` | `_MulColor0` | **(none)** | |
| `Gallop/3D/Live/Stage/StageMirrorBallShine` | (none) | | |
| `Gallop/3D/Live/Stage/StageTransmittedLightMask` | (none) | | `_TransmittedLightMaskScale` |
**Brightness is a separate channel — do not fold it into the colour.** Write the key's colour to the colour property and its `power` to `_ColorPower`; let the shader combine them. Only fall back to `colour * power` where `_ColorPower` is absent (`BgMirrorBall`).
**Writing shader properties: one shared block, always Get → modify → Set.** All of `Director`'s
handlers write through the single `PropBlock` (`Director._propBlock`) and must call
`renderer.GetPropertyBlock(block)` first — `SetPropertyBlock()` **replaces** the block rather than
merging, so a handler that starts from an empty block wipes whatever another track wrote to the
same renderer earlier in the same frame. That is exactly how GlobalLight was silently dead for
59/59 songs (BgColor1's character branch runs right after it and cleared all 13 rim properties).
Never `new MaterialPropertyBlock()` per frame, and never write `_Color` without first checking the
target shader's real property table — three separate tracks shipped no-op colour writes that way.
Dump any shader's real property table with:
```python
# tools/, via ~/.venvs/umatools/bin/python3
env = MetaDb().load("shader") # 499 shaders
pf = obj.read_typetree()["m_ParsedForm"] # m_Name, m_PropInfo.m_Props
```
**`_MulColor0` is on every stage shader and is BgColor1's channel; `_AmbientColor` belongs to BgColor2.** They are separate properties on the same shader, so the two tracks must not write the same one. Same naming family as WashLight's `MulColor0` and UVScrollLight's `mulColor1`.
⚠️ A material's `m_SavedProperties` in the bundle lists properties it no longer has — it retains entries from whatever shader it was authored against. `Material.HasProperty()` asks the *current shader*. Always enumerate the shader, never trust the bundle's saved-property table.
**渲染状态也从这张过期表里读,后果最严重。** Gallop 的发光类 shader 把混合写成
`Blend [_SrcBlend] [_DstBlend]`,也就是**从材质属性取渲染状态**。live10149 的
**26/26 个材质**存的都是 `_SrcBlend=One, _DstBlend=Zero`(不透明覆盖),且每一个的 float 表里
都带着 `_WorkflowMode`/`_Surface`/`_ClearCoatMask`/`_Smoothness`/`_Blend` —— 那是 **URP Lit
的属性表**,整批盖上去的,One/Zero 正是 URP Lit 的不透明预设。
作者意图可查:全库 16 个声明这两个属性的 shader 里,**14 个默认 `One/One`(加法混合)** ——
`LightBlinkBlend`、`LightBlend`、`StageBeamLight{,Cutoff,Fadeout}`、
`StageLightBlink{Cutoff,Fadeout}`、`StageProjectorBlend{,VertexAlpha,AnimVertexAlpha}`、
`StageMonitorBlend*`、`MirrorBallProjector`、`BgUnlitStencilAlpha`,全是发光/投影那一批。
被压成不透明后,加法光柱「颜色为黑 = 什么都不加 = 不可见」变成了**一堵实心黑墙**。
⚠️ **实际受害面比存档表窄得多,别照着 26 这个数推。** 只有**当前 shader 真的声明了**这两个
属性的材质才会被读到;其余材质虽然也存着 `One/Zero`,但那只是过期条目,无人问津。
live10149 实测(`[StageBlend]` 日志):31 个材质里**只有 1 个**被修正 ——
`mtl_env_live10149_blinklight000` / `LightBlinkBlend`,`_DstBlend` 0 → 1。
**但「1 个材质」严重低估了影响面 —— 材质在舞台上是高度共享的。** 那一个材质铺满
27 个 BlinkLight 组、约 **1450 个渲染器**(观众席 95、mob000 570、glow_object 67、
glow_ramp 58、镜面球闪片 83、全部 wash 组、各种 LED 板),实机确认修好的不只是地面 wash 灯的
黑楔子,长期挂着的「小灯泡渲染成黑块」也一并正常了。
**教训(第三次同一天):报数要报对单位。** 舞台上约 1500 个渲染器只有 31 个材质,
按材质计数会把影响面说小两个数量级 —— `[StageBlend]` 日志现在同时报渲染器数。
`StageController.RestoreAuthoredBlendState()` 在舞台初始化时把这两个属性写回 **shader 声明的
默认值**(`Shader.GetPropertyDefaultFloatValue`),对本来就该不透明的 shader 是空操作,自限。
改了哪些材质会逐条打进 `[StageBlend]` 日志。
> 教训比这个 bug 本身通用:**过期存档表不只影响颜色,还能改渲染状态**。
> 看到「本该发光的东西是黑的/挡住后面的东西」,先查 `_SrcBlend`/`_DstBlend`,
> 再查颜色写对没有。
Worked example — `mtl_env_live10149_projector001` (shader `Gallop/3D/MirrorAndShadow/MirrorBallProjector`, 22 real properties) saves 40+, including Standard-shader leftovers (`_Glossiness`, `_Metallic`, `_Parallax`, `_Mode`, `_UVSec`) **and** four plausible-looking ghosts the current shader never declares: `_IsLoopYRotation`, `_LoopYRotationSpeed`, `_MirrorBallProjectionIntensity`, `_MirrorBallRotateWS`. Two of those are older spellings of properties that *do* exist (`_MirrorBallIsLoopRotation`, `_MirrorBallLoopRotationSpeed`) — exactly the kind of near-miss that reads as a discovery. Note also that where a component field and a saved property disagree (`MirrorBallFallOffPower` = 1.0 on the component vs `_MirrorBallFalloffPower` = 2.0 in the material), the component wins at runtime: these controllers push their own fields into the material.
**Track group names are not always GameObject names.** Four separate silent failures were traced to this in one session, so check the mapping before assuming a track is unimplemented:
- a whitelist filter in the dispatcher dropped 92% of BgColor1's keys (`validBgColorNames`)
- `StageObjectMap` dedupes by name, so a crowd of 66 same-named objects only ever got its first member
- the Object track's `neonsign` is a `_stageObjectUnits` **unit name**, not a GameObject — no object of that name exists
- `ObjectUpdateInfo.OffsetType` was populated by the dispatcher but never read by `StageController`, so `Add`-relative positions were written as absolute
All four failed silently, with no error. Treat a track that "works" but looks wrong as a routing problem first.
**StageObjectMap:** Stage child GameObjects are indexed by name. Objects whose names match `IsTimelineControlledLight()` (`blinklight`, `spotlight3d`, `_wash_`, `laser`) start `SetActive(false)` — their handlers call `SetActive(true)` when the track fires. All other objects (neonsigns, glow meshes, UV scroll objects, etc.) keep their prefab default state.
## Ground-truth sources: what is recoverable and what is not
Mapped 2026-08-05. **Check this list before declaring something "unknown"** — several things
previously written off as un-reversible are in fact dumpable, and one whole class of problem
was solved by realising the game's own scripts can be re-bound.
### Available
| 来源 | 给出什么 | 怎么取 |
|---|---|---|
| cutt bundle TypeTree | 128 个轨道字段名 + 全部关键帧值,59 首全带 | `tools/dump_cutt_typetree.py` |
| 舞台 prefab TypeTree | GameObject 层级、组件构成,**以及缺失脚本 MonoBehaviour 的字段名和值**(离线可读) | `MetaDb().load('3d/env/live/liveNNNNN/pfb_..._controller000')` |
| shader bundle | 499 个 shader 的真实属性表(名/类型/默认值/range) | 枚举 `m_ParsedForm.m_PropInfo.m_Props` |
| material bundle | 材质的 shader 绑定与存档属性(⚠ 存档表含过期条目,见下) | `sourceresources/3d/env/live/liveNNNNN/materials/mtl_*` |
| AnimationClip | 曲线的 path / 目标属性 / 关键帧时间与值 | `m_RotationCurves` 等 |
| MonoScript | **类名 + namespace + 程序集名** | 见下,这是最重要的一条 |
### ⭐ 用原签名重建脚本,可直接拿到真实序列化字段
舞台 prefab 引用了 11 个脚本类,UmaViewer 起初只实现了 `StageController`,其余 892 个
MonoBehaviour 实例脚本为空。**但这些脚本是可以「接管」的** —— Unity 按
**类名 + namespace + 程序集名** 解析 MonoScript,而 `Assets/Scripts/umamusume.asmdef`
的程序集名正是 bundle 里写的 `umamusume`。所以只要按签名建类,字段就会被真正反序列化进来。
已验证:新建 `Gallop.Live.BillboardController` 后,`[StageScripts]` 的「脚本丢失」
从 **892 降到 728**(正好 −164 个实例),`_rotationType` 等字段直接可读。
签名清单(`Assets/Scripts/` 下任意位置均可,只要在 umamusume 程序集内):
| namespace | 类 | live10149 实例数 | 状态 / 已 dump 到的字段 |
|---|---|---|---|
| `Gallop.Live` | `AnimationObjectController` | 282 | ❌ **别建,建了也没用**:实测序列化字段 **一个都没有**(282 个实例全空)。重建只会让「脚本丢失」892→446,拿到 0 bit 新信息 —— 它是纯行为类,播哪个 clip / 何时播 / 多快全在拿不到的方法体里。顶替版已于 2026-08-07 撤除,舞台 Animation 现在**有意不播**,见下 |
| `Gallop.Live` | `UnityLensFlareController` | 205 | ❌ 只有 1 个字段 `_enableAngleDegree`(0),本体不在这里 |
| `Gallop.Live` | `BillboardController` | 164 | ✅ 已建(字段已读到,朝向行为待定)|
| `Gallop.Live` | `WashLightController` | 27 | ❌ 24 个字段全是具体数值 + 两张投影贴图(`_projectionTexture`/`_cameraProjectionTexture`),缺的是 URP 下的投影实现 |
| `Gallop.Live` | `LightProjection` | 4 | ❌ 只有 `_projectionType`(1) |
| `Gallop.RenderPipeline` | `CustomLensFlare` | 200 | ❌ **数据最完整、最值得做的一个**,见下 |
| `Gallop.RenderPipeline` | `MirrorBallProjector` | 4 | ❌ 字段已 dump 但**运行时绑定不可见**,见 Blocked |
| `Gallop.RenderPipeline` | `CustomProjector` | 4 | ❌ 就是一套 legacy `Projector` 参数(near .1 / far 60 / fov 30 / aspect 1 + `Material`)|
| `Gallop` | `MirrorReflection` | 1 | ✅ 已建并实现(平面镜反射,见下)|
| `Gallop.Live.ShaderParam` | `ShaderParamController` | 1 | ⚠ **不值得做**:实测只有 `_AmbientColor`/`_CharaColor` 两个向量,值都是 (1,1,1,1),实现出来是空操作 |
**LensFlare 这条线数据是齐的**(这也是目前性价比最高的一块):
- `CustomLensFlare` 字段:`Flare`(PPtr) / `Brightness` / `FadeSpeed` / `Color` / `IgnoreLayers` / `IsDirectional`。
- `Flare` 指向的**不是** Unity 内置 `Flare` 资产,而是 `Gallop.RenderPipeline.LensFlareData`
这个 ScriptableObject(同样在 umamusume 程序集里,照样可以按签名重建)。
路径形如 `sourceresources/3d/env/live/common/lensflare/flare013/flares/flare013_00`。
- 它的字段是 Unity legacy Flare 的逐字翻版:
`TextureLayout` / `Texture` / `ElementArray[{ImageIndex, Position, Size, Color, UseLightColor,
Rotate, Zoom, Fade}]` / `UseFog`。**所以语义不受 IL2CPP 阻塞** —— 这些是 Unity 自己的概念。
- live10149 上 199 个物件同时挂 `CustomLensFlare` + `UnityLensFlareController` + MeshRenderer,
另有 5 个只挂 `UnityLensFlareController`。舞台上**没有任何 legacy `LensFlare` 组件**,
原版是自己画的。URP 侧对应物是 `LensFlareComponentSRP` + `LensFlareDataSRP`。
字段名必须和 TypeTree 逐字一致(大小写敏感),dump 一下即可。
这条同样适用于任何其它 bundle 里的 MonoBehaviour。
**重建签名只拿得到数据,拿不到方法体。** 两个已建的类正好是两种情形:
- `MirrorReflection`(`Assets/Scripts/umamusume/Gallop/MirrorReflection.cs`)—— 接收端能
自己查出来,所以能补完行为:宿主 `mirror_a` 的材质用 `Cygames/MirrorAndShadow/ReceiveMirror`,
该 shader 只有 `_MainTex`/`_ReflectionTex`/`_Color`/`_ReflectionRate`,而组件字段
`_mirrorReflectionColor`/`_mirrorReflectionRate` 与后两者逐字对应。实现方式是第二摄像机
+ 反射矩阵 + 斜投影渲进 RT 再写回 `_ReflectionTex`(URP 下不能用 `Camera.Render()`,
必须挂 `beginCameraRendering` 拿 `ScriptableRenderContext` 后 `RenderSingleCamera`)。
这同时解决了长期的「地板发白」——`_ReflectionTex` 未绑定时 Unity 代入白贴图,
而 `_ReflectionRate` 默认 1.0,等于满强度白反射。
- `BillboardController` —— 字段读到了,**但行为写了一版又关掉了**,这是「重建签名」的能力边界。
`_rotationType == 0` 是不是「整轴朝向摄像机」、朝向该用摄像机 up(跟镜头 roll)还是世界 up
(只绕 Y),都没有 ground truth;实机看是「一直转来转去,而那东西不该转」,
于是 `BillboardController.EnableRotation` 默认 false,面片保持 prefab 作者摆好的朝向。
同一次排查里揪出一个**确定的** bug(已修):目标摄像机只解析了一次就缓存,
而生效摄像机随 CameraSwitcher 每帧变 —— 切换后面片朝的是观众看不见的那台,
那台还在自己动,看上去就是无缘无故乱转。**凡是引用「当前摄像机」的舞台脚本都要注意这条。**
**舞台自带 Animation 有意不播**(283 个组件,2026-08-07 撤除了替代实现):
它们全部 `playAutomatically = true` 但默认 clip 为空,原版靠 `AnimationObjectController`
显式 `Play()`,而那个类**零序列化字段**,纯行为,方法体拿不到 —— 「播哪个 / 何时播 / 多快」
没有任何数据能还原。写过一版 `StageAnimationPlayer` 顶替,策略是「只有一个 clip 就播」,
错在把「这物件只有一个**状态**」当成「这个状态**一直在播**」(后者正是那个缺失的类每帧做的
决定):wash 灯 2 秒一个来回疯狂摆头(原版不扫只闪),镜面球转速无从校验。已整体撤除。
**完整理由与复活条件见 `LIVE_TRACKS.md` →「舞台自带 Animation 为什么不播」专条。**
⚠ 撤除时唯一保留下来的结论:**驱动舞台动画不要用 `Animation.Play()`**,
那跑 Unity 自己的时钟,暂停/拖动进度条都会脱节;应每帧把 `AnimationState.time` 设成
`LiveTimelineControl.currentLiveTime`(秒)再 `Sample()`。将来重做直接用这条。
### 🔎 舞台上「行为不对」的第一嫌疑:缺组件,不是解析错
**这是分诊规则,不是某一次的结论。** 舞台 prefab 引用 11 个脚本类,至今仍有 **728 个
MonoBehaviour 实例是空的**(基线 892,已补 `BillboardController` −164)。其中
`AnimationObjectController` 一家就占 282 个实例 —— 舞台上所有「什么时候动、动多快、
动哪一个」的决定原本都在它手里,而它整个不存在。
所以当某个舞台元素表现不对(该动的不动、不该动的在动、动的节奏不对),
**默认假设应该是「原版那个组件缺失,现在由我们猜的行为顶着」,而不是「数据解析错了」**。
理由是可检验的:字段值、clip 曲线、shader 属性表这些**数据**层面的东西全都能 dump 出来核对,
而且一路核对下来**基本都是对的**;反复出错的是**行为**层面 —— 那正好是拿不到方法体的部分。
已经有两个同形状的例子,都是「物件选对了、行为猜错了」:
| 现象 | 数据对不对 | 真实原因 |
|---|---|---|
| wash 灯疯狂摆头 | clip 曲线完全正确(实测 90°→0°→90°) | 缺 `AnimationObjectController`,我们替它决定了「一直播」 |
| 面片一直乱转 | 字段全部正确读到 | 缺原方法体,`_rotationType==0` 的语义是我们猜的 |
推论一:动手前先查一眼**这个物件挂过什么脚本、那个脚本我们有没有**
(`[StageScripts]` 日志 + `CLAUDE.md` 的签名清单),比直接怀疑解析快得多。
推论零:**先 dump 一眼那个缺失组件有没有字段,再决定值不值得重建。**
`AnimationObjectController` 实测**零字段**(282 个实例全空),所以重建它拿不到任何信息,
舞台动画的那些时机问题不可能靠「把原类建出来」解决 —— 这个方向是死的,
别再试。反过来 `CustomLensFlare` 字段齐全(还牵出 `LensFlareData`),就非常值得建。
判断只要一条命令,不要凭实例数多就以为收益大。
推论二:**顶替缺失组件时,要把「我在替谁做决定」写清楚**,并且区分
「从 bundle 数据推出来的」和「看参考视频观察到的」—— 后者是有效证据,但不是 ground truth,
换个舞台可能就不成立。(反面教材:舞台动画的 `_wash_` 排除规则就是这么来的,
后来连同整个替代实现一起撤掉了 —— 观察能证伪一个实现,但支撑不起一个实现。)
反例也要记住:**并非所有舞台画面问题都是缺组件。** 光柱渲染成黑墙那次,
根因是材质被盖了 URP Lit 的属性表、`_DstBlend` 从 One 变成 Zero,
把加法混合压成了不透明 —— 那是**数据**问题,而且是 dump 得出来的(见下)。
分诊规则的意思是「先查缺不缺组件」,不是「一律归咎于缺组件」。
### Blocked
**IL2CPP 元数据拿不到。** 游戏是 IL2CPP 构建,但 `global-metadata.dat` 既不在
`umamusume_Data/il2cpp_data/`(那里只有 `Resources/`),也没嵌在 `GameAssembly.dll` 里
—— 魔数 `0xFAB11BAF` 零命中,`BillboardController` / `_rotationType` 等标识符字符串
也全部搜不到。这是该游戏已知的反篡改,元数据运行时才还原。
因此以下**永远拿不到标准答案**,只能靠交叉验证或承认未知:
- **游戏自己定义的枚举语义** —— `_rotationType`(0/2)、`LightBlendMode`、`CmnColorType0/1`、
BlinkLight 的 `pattern`、`ColorType` 等。字段值读得到,含义读不到。
⚠ 但**先分清是谁的枚举**:`LensFlareData.TextureLayout` 长得像这一类,其实是 Unity
legacy `Flare` 资产的 `FlareTextureLayout`,语义在 Unity 侧公开,不受此限。
- **对象之间的运行时绑定** —— 重建签名只能拿到**数据**,拿不到原方法体,所以「这个组件
跑起来去找谁」是不可见的。实例:`MirrorBallProjector` 的 shader 需要
`_MirrorBallPosWS`/`_MirrorBallScale`/`_MirrorBallRotateValue`,而组件里**没有任何指向
灯球的引用字段**,4 个 projector 全在原点、共用同一个材质,灯球却散布在 6 个位置。
绑定关系只存在于方法体里。
- **速度/系数一类的常量** —— 如果原版在代码里对动画做了调速,那个倍率无从得知。
(注意这条不要滥用:镜面球转速一度被记成此类,实测其实完全确定,见下。)
- 未随包发布的 shader(PostFilm 的定义性 shader 就在 app 二进制里)。
**「灯球转速」到底确定到什么程度(2026-08-07,当天两次修正)**
⚠️ **先说结论的边界**:clip 里的转速是硬数据,**但「原版按 1.0 倍速播这条 clip」不是**。
实机跑下来观察是「所有迪斯科灯球还是太快」,也就是说原版多半有个播放倍率或者压根不用
这条 clip 驱动球体 —— 那正是本节上面「速度/系数一类的常量」说的那类不可知量。
下面的数字只证明「**我们忠实地播了这条 clip**」,不证明「和原版一致」。
已确定的部分:
- live10149 的 4 个 `MirrorBallProjector` 实例**全部** `MirrorBallIsLoopRotation = 0`、
`MirrorBallLoopRotationSpeed = 0`、`MirrorBallLoopRotationValue = 0` ——
shader 自带的 loop-rotation 通路在这个舞台上是**关掉的**,不是转速来源。
- 真正在转的是 clip `anm_env_live10149_blinklight_mirrorball_flarelight_loop_000`:
6 条 quaternion 曲线(`mirrorball_a_000` + `b_000..004`),7 个关键帧,
**恒定 −179.50°/s 绕 Y**(每个关键帧算出来都是这个数),2.0 s 一圈,wrapMode = Loop。
末帧 −359° 而不是 −360°,是为了循环接缝不重复一帧。
- 曾接管过这条 clip,采样用的 `currentLiveTime` 单位是秒(帧号 = ×60),**没有单位换算错误**,
放出来就是 clip 写的 2.0 s 一圈 —— 但实机仍然偏快,这正是下面第 2 条教训的由来。
该实现已于 2026-08-07 撤除,球现在不转。
两条教训,方向相反,都要记:
1. 把「值是 0」当成「值未知」是两回事 —— dump 之前先看一眼开关字段
(`MirrorBallIsLoopRotation` 就是那个开关)。
2. **但「clip 的转速是确定的」推不出「转速问题解决了」**。我上一版文档写成了
「我们的构建里转速本来就是对的」,实机观察当天就否掉了它。
数据确定的是**素材**,不是**原版怎么用这个素材**;中间那个倍率仍然是不可知量。
### ⚠ 现象描述不是 ground truth
字段值、shader 属性表是硬数据;**「哪个物件看起来不对」不是** —— 文档里的现象描述往往
没经过验证。地板发白排查栽在这上面:文档记着 `plane_000` / `stage_object_001` /
`specular_002` 发白,整轮排查都围着这三个转,最后查明白的其实是**叠在同一位置的
`mirror_a`**(同在 `pfb_env_live10149_main000` 下、局部变换全为单位,肉眼分不出)。
它用 `Cygames/MirrorAndShadow/ReceiveMirror`,`_ReflectionRate` 默认 1.0 而
`_ReflectionTex` 从没赋值 —— Unity 对未绑定采样器代入白贴图,满强度白反射。
期间那个「把 `_EnvRate` 归零无变化」的实验因此也是误导:切的是另一批物件。
**排查视觉问题的第一步应该是确认现象归属于哪个物件**(让代码把可疑物件隔离/高亮),
而不是从文档里的名字出发。
### 间接手段(已被证明有效)
拿不到直接答案时,**相关性交叉验证**多次奏效,比猜测可靠得多:
- BlinkLight 调色板槽位规则:靠「物件名 `lightNNN_`/`alphaNNN_` 前缀的种数」与
「调色板里去重颜色数」在**每一组都精确相等**推出,7 组无一例外。
- BgColor1 的通道 `_MulColor0`:靠枚举 shader 属性表 + 排除法确认。
- 「某属性存在」≠「目标 shader 上有该属性」—— `_Color` 空写连续栽了三次
(BlinkLight / Spotlight3d / UVScrollLight),扫一遍 shader 属性表即可批量排查。
## Working rule: no ground truth, no implementation
Field names, keyframe values and shader property tables are all recoverable from the bundles
(`tools/`), so **verify before you write code** — and when something genuinely cannot be
recovered, leave it unimplemented with a TODO rather than guessing a value.
Guessing has cost real time in this project: the PostFilm vignette geometry was guessed twice
(full-screen wash, then a huge border) before it turned out the defining shader isn't shipped in
any bundle. `PostFilmRendererFeature._enableRendering` is now `false` for exactly this reason.
Note the distinction that has caught me out: *"the property exists"* is not *"this field maps to
that property"*. `_ColorPower` is present on the stage shaders, but there is no evidence that
BgColor1's `power` field drives it — so that write was reverted while BlinkLight's was kept
(there the shader `LightBlinkBlend` is named for the track and pairs `_BlinkLightColor` with
`_ColorPower`, which is a much tighter fit).
Open items and why each is blocked are tracked in `LIVE_TRACKS.md` → 「已知未解问题」.
## Currently Implemented Tracks
| Track | Data class | Notes |
|-------|-----------|-------|
| Camera (pos/lookat/fov/roll/switcher) | existing | fully working |
| CharaMotionSequence | existing | |
| Facial / LipSync / FormationOffset | existing | |
| GlobalLight (48) | existing | sets rim light shader props on characters |
| BgColor1 | existing | **two branches.** Groups named `CharaCenter/CharaLeft/CharaRight/CharaColor` (`LiveTimelineControl.CharaBgColorNames`) → character toon props (`_CharaColor`, `_ToonDarkColor`, …). Every other group name → stage renderers, resolved by walking the whole stage hierarchy for same-named Transforms (`Director.ResolveStageTargets`), writing **`_MulColor0`** via `MaterialPropertyBlock`. |
| BgColor2 | existing | sets `_AmbientColor` on stage renderers via `Lerp(color1, color2, value)` |
| Transform / Object | existing | handled by `StageController.cs` |
| Effect (60) | `LiveTimelineEffectData` | loads prefab from `3d/effect/live/pfb_{name}` |
| GlobalFog (49) | `LiveTimelineGlobalFogData` | sets `RenderSettings.fog*` |
| Spotlight3d (68) | `LiveTimelineSpotlight3dData` | looks up `keyData.assetName` in StageObjectMap |
| UVScrollLight (46) | `LiveTimelineUVScrollLightData` | sets texture UV on stage materials |
| VolumeLight (37) | `LiveTimelineVolumeLightData` | data deserialized, no visual (component absent) |
| LightShafts (50) | `LiveTimelineLightShaftsData` | data deserialized, no visual (component absent) |
| Particle (41) | `LiveTimelineParticleData` | sets ParticleSystem.emission.rateOverTime |
| ParticleGroup (42) | `LiveTimelineParticleGroupData` | sets FlickerLightRate |
| ChromaticAberration (73) | `LiveTimelineChromaticAberrationData` | URP `ChromaticAberration.intensity` ← `power`;per-channel offset 无法映射 |
| HdrBloom (38) | `LiveTimelineHdrBloomData` | **no handler, deliberately** — 0/58 songs carry data (`hdrBloomKeys` empty in every scan and in the song-1177 dump), and the field→URP-`Bloom` mapping was never verifiable. Data layer (data class, worksheet field, event) is kept; the Director handler and the `Bloom` volume override were removed 2026-08-07. |
| ColorCorrection (61) | `LiveTimelineColorCorrectionData` | URP `ColorAdjustments.saturation` + `ColorCurves` RGB;depth/blend curve 无 URP 对应 |
| BlinkLight | `LiveTimelineBlinkLightData` | SetActive + writes `_BlinkLightColor`/`_ColorPower` on the group's renderers (56/58 songs). **Biggest lighting payload by far** — 全语料 19420 keys / 57 songs, and over half are named `*_wash_*` (`wash_truss_a` 147, `wash_truss_b` 147, `wash_ground_a` 51, …). Palette slot = the `lightNNN_`/`alphaNNN_` prefix of the renderer or its nearest ancestor; renderers with no prefix are left to their real owner (BgColor1) when the group has slotted ones. `color1Array` / `LightBlendMode` / `isReverseHueArray` still unused. |
| WashLight | `LiveTimelineWashLightData` | SetActive only (5/58 songs). Low value: real "wash light" output comes from the BlinkLight `*_wash_*` groups above, not this track — song 1177 has just 1 group / 11 keys here. |
| Laser | `LiveTimelineLaserData` | SetActive + position/rotation/scale — blink/raycast not implemented (6/58 songs) |
## Track Coverage (from 58-song scan)
**The pre-2026-08-04 scan data in `LIVE_TRACKS.md` was wrong** and has been re-measured with `tools/`. Two systematic errors: a batch of tracks marked "WorkSheet 无 X 字段" do have the field (`MobControlKeys`, `CyalumeControlKeys`, `nodeScaleList`, `sweatLocatorList`, `monitorCameraPos/LookAtKeys`, `facialNoiseKeys`, `charaMotionNoiseKeys`, …), and a batch marked "全 0 keyframe 空占位" carry thousands of keys (`postFilmKeys`/`postFilm2Keys`/`postFilm3Keys` ≈20 200 keys over 59/59 songs, `radialBlurKeys`, `tiltShiftKeys`, `charaFootLightKeys`, …). Rows tagged ✅实测 are trustworthy; untagged rows are still old data. **Re-verify with `tools/` before acting on any untagged row.**
Coverage numbers measure **what exists in the bundles**, not what the C# consumes — the two diverge. When a track looks under-powered, check the dispatcher in `AlterUpdate_*` for filters before assuming it's unimplemented (BgColor1 was dropping ~92% of its keys that way).
**Measuring what a song actually carries**: `LiveTimelineWorksheetDiag.Dump()` is called from `Director.InitializeTimeline()` and logs, per worksheet, every track field with its group names and key counts. Confirmed on song 1177: `worksheetList.Count == 1`, `SheetType == MainLive`, so reading `worksheetList[0]` loses nothing.
+68
View File
@@ -0,0 +1,68 @@
Shader "Custom/CyalumeSimpleOfficialLike"
{
Properties
{
_MainTex ("Flipbook", 2D) = "white" {}
_Tint ("Tint", Color) = (1,1,1,1)
_Intensity ("Intensity", Float) = 1.0
}
SubShader
{
Tags
{
"Queue"="Transparent"
"RenderType"="Transparent"
"IgnoreProjector"="True"
}
Pass
{
Blend One One
ZWrite Off
Cull Off
CGPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "UnityCG.cginc"
sampler2D _MainTex;
float4 _MainTex_ST;
fixed4 _Tint;
float _Intensity;
struct appdata
{
float4 vertex : POSITION;
float2 uv : TEXCOORD0;
};
struct v2f
{
float4 pos : SV_POSITION;
float2 uv : TEXCOORD0;
};
v2f vert(appdata v)
{
v2f o;
o.pos = UnityObjectToClipPos(v.vertex);
o.uv = TRANSFORM_TEX(v.uv, _MainTex);
return o;
}
fixed4 frag(v2f i) : SV_Target
{
fixed4 tex = tex2D(_MainTex, i.uv);
// 锟斤拷锟斤拷 discard锟斤拷锟斤拷锟斤拷 alpha clip
fixed3 rgb = tex.rgb * _Tint.rgb * _Intensity;
// additive 锟斤拷 alpha 锟斤拷锟斤拷锟斤拷锟斤拷要锟斤拷锟斤拷 1 锟斤拷锟斤拷
return fixed4(rgb, 1.0);
}
ENDCG
}
}
}
@@ -0,0 +1,9 @@
fileFormatVersion: 2
guid: 312ecc39ee9d48b4789a1d4f2dfcf36e
ShaderImporter:
externalObjects: {}
defaultTextures: []
nonModifiableTextures: []
userData:
assetBundleName:
assetBundleVariant:
+187 -14
View File
@@ -63,6 +63,12 @@ public class DynamicBone : MonoBehaviour
public float m_Radius = 0;
public AnimationCurve m_RadiusDistrib = null;
// Point-only collision allows a long skirt bone segment to pass through a
// limb even when both of its endpoints are outside the collider.
public bool m_ContinuousCollision = false;
// Assigned by the CySpring importer so runtime tuning can target a semantic group.
public string m_MotionGroup = string.Empty;
#if UNITY_5_3_OR_NEWER
[Tooltip("If End Length is not zero, an extra bone is generated at the end of transform hierarchy.")]
#endif
@@ -118,6 +124,10 @@ public class DynamicBone : MonoBehaviour
float m_Time = 0;
float m_Weight = 1.0f;
bool m_DistantDisabled = false;
Transform m_MotionReference = null;
Vector3 m_MotionReferencePrevPosition = Vector3.zero;
Quaternion m_MotionReferencePrevRotation = Quaternion.identity;
bool m_MotionReferenceInitialized = false;
public class Particle
{
@@ -138,6 +148,7 @@ public class DynamicBone : MonoBehaviour
public Vector3 m_LimitAngel_Max = Vector3.zero;
public Vector3 m_Position = Vector3.zero;
public Vector3 m_PrevPosition = Vector3.zero;
public Vector3 m_PreviousFramePosition = Vector3.zero;
public Vector3 m_EndOffset = Vector3.zero;
public Vector3 m_InitLocalPosition = Vector3.zero;
public Quaternion m_InitLocalRotation = Quaternion.identity;
@@ -277,6 +288,7 @@ public class DynamicBone : MonoBehaviour
return;
m_ObjectScale = Mathf.Abs(transform.lossyScale.x);
StabilizeLargeReferenceMotion();
m_ObjectMove = transform.position - m_ObjectPrevPosition;
m_ObjectPrevPosition = transform.position;
@@ -287,11 +299,11 @@ public class DynamicBone : MonoBehaviour
{
if (m_UpdateRate > 0)
{
timeVar = Time.deltaTime * m_UpdateRate;
timeVar = Mathf.Min(t * m_UpdateRate, 1f);
}
else
{
timeVar = Time.deltaTime;
timeVar = t;
}
}
else
@@ -344,6 +356,62 @@ public class DynamicBone : MonoBehaviour
m_BoneTotalLength = 0;
AppendParticles(m_Root, -1, 0);
UpdateParameters();
ResetMotionReference();
}
private void ResetMotionReference()
{
m_MotionReference = m_Root != null && m_Root.parent != null ? m_Root.parent : m_Root;
if (m_MotionReference == null)
{
m_MotionReferenceInitialized = false;
return;
}
m_MotionReferencePrevPosition = m_MotionReference.position;
m_MotionReferencePrevRotation = m_MotionReference.rotation;
m_MotionReferenceInitialized = true;
}
private void StabilizeLargeReferenceMotion()
{
Transform currentReference = m_Root != null && m_Root.parent != null ? m_Root.parent : m_Root;
if (currentReference == null)
return;
if (!m_MotionReferenceInitialized || currentReference != m_MotionReference)
{
ResetMotionReference();
return;
}
Vector3 currentPosition = currentReference.position;
Quaternion currentRotation = currentReference.rotation;
float translation = Vector3.Distance(currentPosition, m_MotionReferencePrevPosition);
float rotation = Quaternion.Angle(m_MotionReferencePrevRotation, currentRotation);
float translationThreshold = Mathf.Max(0.05f * m_ObjectScale, m_BoneTotalLength * 0.2f);
if (translation > translationThreshold || rotation > 30f)
{
Quaternion rotationDelta = currentRotation * Quaternion.Inverse(m_MotionReferencePrevRotation);
for (int i = 0; i < Particles.Count; ++i)
{
Particle particle = Particles[i];
particle.m_Position = currentPosition + rotationDelta *
(particle.m_Position - m_MotionReferencePrevPosition);
particle.m_PrevPosition = currentPosition + rotationDelta *
(particle.m_PrevPosition - m_MotionReferencePrevPosition);
particle.m_PreviousFramePosition = currentPosition + rotationDelta *
(particle.m_PreviousFramePosition - m_MotionReferencePrevPosition);
}
// The animated parent motion has already been applied to the particle
// state and must not be counted again as Verlet velocity/inertia.
m_ObjectPrevPosition = transform.position;
}
m_MotionReferencePrevPosition = currentPosition;
m_MotionReferencePrevRotation = currentRotation;
}
void AppendParticles(Transform b, int parentIndex, float boneLength)
@@ -472,15 +540,18 @@ public class DynamicBone : MonoBehaviour
if (p.m_Transform != null)
{
p.m_Position = p.m_PrevPosition = p.m_Transform.position;
p.m_PreviousFramePosition = p.m_Position;
}
else // end bone
{
Transform pb = Particles[p.m_ParentIndex].m_Transform;
p.m_Position = p.m_PrevPosition = pb.TransformPoint(p.m_EndOffset);
p.m_PreviousFramePosition = p.m_Position;
}
p.m_isCollide = false;
}
m_ObjectPrevPosition = transform.position;
ResetMotionReference();
}
void UpdateParticles1(float timeVar)
@@ -497,8 +568,16 @@ public class DynamicBone : MonoBehaviour
Particle p = Particles[i];
if (p.m_ParentIndex >= 0)
{
p.m_PreviousFramePosition = p.m_Position;
// verlet integration
Vector3 v = p.m_Position - p.m_PrevPosition;
if (m_ContinuousCollision)
{
Particle parent = Particles[p.m_ParentIndex];
float maxStep = Vector3.Distance(parent.m_Position, p.m_Position);
if (maxStep > Mathf.Epsilon)
v = Vector3.ClampMagnitude(v, maxStep);
}
Vector3 rmove = m_ObjectMove * p.m_Inert;
p.m_PrevPosition = p.m_Position + rmove;
float damping = p.m_Damping;
@@ -560,18 +639,6 @@ public class DynamicBone : MonoBehaviour
}
}
// collide
if (p.m_Colliders != null)
{
float particleRadius = p.m_Radius * m_ObjectScale;
for (int j = 0; j < p.m_Colliders.Count; ++j)
{
DynamicBoneColliderBase c = p.m_Colliders[j];
if (c != null && c.enabled)
p.m_isCollide |= c.Collide(ref p.m_Position, particleRadius);
}
}
// freeze axis, project to plane
if (m_FreezeAxis != FreezeAxis.None)
{
@@ -595,9 +662,110 @@ public class DynamicBone : MonoBehaviour
float leng = dd.magnitude;
if (leng > 0)
p.m_Position += dd * ((leng - restLen) / leng);
// This must be the final positional constraint. Otherwise the
// length correction can pull the skirt back inside a leg volume.
float particleRadius = p.m_Radius * m_ObjectScale;
ResolveParticleCollisions(p, particleRadius);
if (m_ContinuousCollision)
{
ResolveSegmentCollisions(p0, p, particleRadius);
ResolveSweptCollisions(p, particleRadius);
}
}
}
private void ResolveParticleCollisions(Particle particle, float particleRadius)
{
if (particle.m_Colliders == null)
return;
for (int i = 0; i < particle.m_Colliders.Count; ++i)
{
DynamicBoneColliderBase collider = particle.m_Colliders[i];
if (collider != null && collider.enabled)
particle.m_isCollide |= collider.Collide(ref particle.m_Position, particleRadius);
}
}
private void ResolveSegmentCollisions(Particle parent, Particle particle, float particleRadius)
{
if (particle.m_Colliders == null)
return;
Vector3 correction = Vector3.zero;
// Test several points because a segment can enter a capsule near either
// endpoint while its midpoint remains outside.
for (int sampleIndex = 1; sampleIndex <= 3; ++sampleIndex)
{
float t = sampleIndex * 0.25f;
Vector3 segmentPoint = Vector3.Lerp(parent.m_Position, particle.m_Position, t);
for (int i = 0; i < particle.m_Colliders.Count; ++i)
{
DynamicBoneColliderBase collider = particle.m_Colliders[i];
if (collider == null || !collider.enabled)
continue;
Vector3 projectedPoint = segmentPoint;
if (!collider.Collide(ref projectedPoint, particleRadius))
continue;
// Move the child enough to move this sample onto the surface.
Vector3 candidate = (projectedPoint - segmentPoint) / t;
if (candidate.sqrMagnitude > correction.sqrMagnitude)
correction = candidate;
}
}
if (correction == Vector3.zero)
return;
particle.m_Position += correction;
particle.m_PrevPosition += correction;
particle.m_isCollide = true;
ResolveParticleCollisions(particle, particleRadius);
}
private void ResolveSweptCollisions(Particle particle, float particleRadius)
{
if (particle.m_Colliders == null)
return;
Vector3 start = particle.m_PreviousFramePosition;
Vector3 end = particle.m_Position;
// Catch a skirt particle that crossed a leg between rendered frames.
// The final point may already be outside the collider, so checking it
// alone cannot detect this case.
float travel = Vector3.Distance(start, end);
int sampleCount = Mathf.Clamp(Mathf.CeilToInt(travel / Mathf.Max(particleRadius * 0.5f, 0.01f)), 4, 8);
for (int sampleIndex = 1; sampleIndex <= sampleCount; ++sampleIndex)
{
float t = sampleIndex / (float)sampleCount;
Vector3 probe = Vector3.Lerp(start, end, t);
for (int i = 0; i < particle.m_Colliders.Count; ++i)
{
DynamicBoneColliderBase collider = particle.m_Colliders[i];
if (collider == null || !collider.enabled)
continue;
Vector3 projected = probe;
if (!collider.Collide(ref projected, particleRadius))
continue;
Vector3 correction = projected - probe;
particle.m_Position += correction;
particle.m_PrevPosition += correction;
particle.m_isCollide = true;
end = particle.m_Position;
}
}
ResolveParticleCollisions(particle, particleRadius);
}
// only update stiffness and keep bone length
void SkipUpdateParticles()
{
@@ -718,4 +886,9 @@ public class DynamicBone : MonoBehaviour
p.m_Transform.position = p.m_Position;
}
}
public void ApplyParticlesToTransformsNow()
{
ApplyParticlesToTransforms();
}
}
+4 -1
View File
@@ -33,6 +33,9 @@ public class HandleManager : MonoBehaviour
private void Update()
{
if (UmaViewerUI.Instance == null || UmaViewerUI.Instance.PoseManager == null)
return;
var camera = Camera.main;
var poseModeOn = UmaViewerUI.Instance.PoseManager.PoseModeOn;
@@ -134,4 +137,4 @@ public class HandleManager : MonoBehaviour
{
EnabledLines = value;
}
}
}
+103
View File
@@ -353,6 +353,109 @@ public class UmaContainerCharacter : UmaContainer
{
cySpringDataContainers[i].InitializePhysics(bones, colliders);
}
SkirtSurfaceCollisionSolver surfaceSolver = GetComponent<SkirtSurfaceCollisionSolver>();
if (surfaceSolver == null)
surfaceSolver = gameObject.AddComponent<SkirtSurfaceCollisionSolver>();
bones.TryGetValue("Hip", out Transform skirtHip);
var skirtColliders = new List<DynamicBoneColliderBase>();
foreach (Transform transform in colliders.Values)
{
DynamicBoneColliderBase collider = transform.GetComponent<DynamicBoneColliderBase>();
if (collider != null)
{
if (IsBodySkirtCollider(collider) && collider.GetComponent<SkirtCollisionVisualizer>() == null)
collider.gameObject.AddComponent<SkirtCollisionVisualizer>();
if (IsThighSkirtCollider(collider.ColliderName))
skirtColliders.Add(collider);
}
}
surfaceSolver.Initialize(GetComponentsInChildren<DynamicBone>(true), skirtHip, skirtColliders);
}
private static bool IsBodySkirtCollider(DynamicBoneColliderBase collider)
{
if (collider == null || string.IsNullOrEmpty(collider.ColliderName) ||
collider.ColliderName.IndexOf("Skirt", StringComparison.OrdinalIgnoreCase) < 0)
return false;
if (collider.ColliderName.IndexOf("Thigh", StringComparison.OrdinalIgnoreCase) >= 0)
return true;
DynamicBoneCollider dynamicCollider = collider as DynamicBoneCollider;
bool isSideHip = collider.ColliderName.EndsWith("_L", StringComparison.OrdinalIgnoreCase) ||
collider.ColliderName.EndsWith("_R", StringComparison.OrdinalIgnoreCase);
return collider.ColliderName.IndexOf("Hip", StringComparison.OrdinalIgnoreCase) >= 0 &&
isSideHip && (dynamicCollider == null || dynamicCollider.m_Radius <= 0.25f);
}
private static bool IsThighSkirtCollider(string colliderName)
{
return !string.IsNullOrEmpty(colliderName) &&
colliderName.IndexOf("Thigh", StringComparison.OrdinalIgnoreCase) >= 0 &&
colliderName.IndexOf("MSkirt", StringComparison.OrdinalIgnoreCase) >= 0;
}
public void LoadSchoolUniformSkirtCollision(UmaDatabaseEntry entry)
{
if (entry == null)
return;
GameObject source = entry.Get<GameObject>();
if (source == null)
return;
if (!PhysicsContainer)
{
PhysicsContainer = new GameObject("PhysicsController");
PhysicsContainer.transform.SetParent(transform);
}
CySpringDataContainer destination = PhysicsContainer.GetComponent<CySpringDataContainer>();
if (destination == null)
destination = PhysicsContainer.AddComponent<CySpringDataContainer>();
if (destination.collisionParam == null)
destination.collisionParam = new List<CySpringCollisionData>();
if (destination.springParam == null)
destination.springParam = new List<CySpringParamDataElement>();
foreach (CySpringDataContainer sourceContainer in source.GetComponentsInChildren<CySpringDataContainer>(true))
{
foreach (CySpringCollisionData collision in sourceContainer.collisionParam)
{
if (!IsSchoolUniformSkirtCollider(collision))
continue;
destination.collisionParam.Add(CloneCollisionData(collision));
}
}
}
private static bool IsSchoolUniformSkirtCollider(CySpringCollisionData collision)
{
if (collision == null)
return false;
return string.Equals(collision._collisionName, "Col_B_Thigh_L_MSkirt_L", StringComparison.OrdinalIgnoreCase) ||
string.Equals(collision._collisionName, "Col_B_Thigh_R_MSkirt_R", StringComparison.OrdinalIgnoreCase) ||
string.Equals(collision._collisionName, "Col_B_Hip_MSkirt_L", StringComparison.OrdinalIgnoreCase) ||
string.Equals(collision._collisionName, "Col_B_Hip_MSkirt_R", StringComparison.OrdinalIgnoreCase);
}
private static CySpringCollisionData CloneCollisionData(CySpringCollisionData source)
{
return new CySpringCollisionData
{
_collisionName = source._collisionName,
_targetObjectName = source._targetObjectName,
_isOtherTarget = source._isOtherTarget,
_offset = source._offset,
_offset2 = source._offset2,
_radius = source._radius,
_distance = source._distance,
_normal = source._normal,
_type = source._type,
_isInner = source._isInner
};
}
public void SetDynamicBoneEnable(bool isOn)
+7
View File
@@ -202,6 +202,13 @@ public class UmaViewerBuilder : MonoBehaviour
else
{
umaContainer.LoadBody(asset);
// Winner costumes use their own cloth physics, but their skirt collision
// volumes are not consistently authored. Reuse the verified school-uniform
// MSkirt collision definition on the current character skeleton.
const string schoolUniformCollision = "3d/chara/body/bdy0002_00/clothes/pfb_bdy0002_00_cloth00";
if (Main.AbList.TryGetValue(schoolUniformCollision, out UmaDatabaseEntry schoolCollisionAsset))
umaContainer.LoadSchoolUniformSkirtCollision(schoolCollisionAsset);
//Load Physics
var asset1 = Main.AbList[UmaDatabaseController.BodyPath + $"bdy{id}_{costumeId}/clothes/pfb_bdy{id}_{costumeId}_cloth00"];
umaContainer.LoadPhysics(asset1);
+7 -4
View File
@@ -150,17 +150,20 @@ public class UmaViewerUI : MonoBehaviour
private void Update()
{
if (Builder.CurrentAudioSources.Count > 0 && Builder.CurrentAudioSources[0])
if (Builder == null)
return;
if (Builder.CurrentAudioSources != null && Builder.CurrentAudioSources.Count > 0 && Builder.CurrentAudioSources[0])
{
AudioSource MianSource = Builder.CurrentAudioSources[0];
if (MianSource.clip)
if (MianSource.clip && AudioSettings != null)
{
AudioSettings.UpdateTrack(MianSource);
}
}
var umaContainer = Builder.CurrentUMAContainer;
if (umaContainer != null && umaContainer.OverrideController != null)
if (umaContainer != null && umaContainer.OverrideController != null && AnimationSettings != null)
{
AnimationSettings.UpdateAnimationInfo(umaContainer);
}
@@ -1347,4 +1350,4 @@ public class UmaViewerUI : MonoBehaviour
UmaViewerUI.Instance.ShowMessage($"ColorSet data saved: {fullPath}", UIMessageType.Success);
}
}
}
@@ -34,16 +34,25 @@ namespace Gallop
foreach (CySpringCollisionData collider in collisionParam)
{
if (collider._isInner) continue;
if (bones.TryGetValue(collider._targetObjectName, out Transform bone))
{
if (IsSkirtLegOrHipCollider(collider._collisionName) &&
!IsActiveSkirtBodyCollider(collider._collisionName))
continue;
Transform child;
Transform bone = null;
bool authoredTransform = bones.TryGetValue(collider._collisionName, out child);
if (!authoredTransform && !bones.TryGetValue(collider._targetObjectName, out bone))
continue;
var child = new GameObject(collider._collisionName);
if (!authoredTransform)
{
child = new GameObject(collider._collisionName).transform;
child.transform.SetParent(bone);
child.transform.localPosition = Vector3.zero;
child.transform.localRotation = Quaternion.identity;
child.transform.localScale = Vector3.one;
colliders.Add(child.name, child.transform);
}
if (!colliders.ContainsKey(child.name))
colliders.Add(child.name, child);
//修改(动骨与碰撞相关)
if (collider._collisionName == "Col_B_Hip_Tail")
@@ -54,9 +63,9 @@ namespace Gallop
{
collider._radius *= 1.14f;
}
else if (collider._collisionName.Contains("Col_B_Hip_Skirt"))
else if (collider._collisionName.IndexOf("Skirt", System.StringComparison.OrdinalIgnoreCase) >= 0)
{
collider._radius *= 0.8f;
// Skirt collision volumes are authored for the skirt and must stay at source size.
}
else if (collider._collisionName.Contains("Col_B_Hip_Jacket"))
{
@@ -80,33 +89,44 @@ namespace Gallop
switch (collider._type)
{
case CySpringCollisionData.CollisionType.Capsule:
var dynamic = child.AddComponent<DynamicBoneCollider>();
var dynamic = child.gameObject.AddComponent<DynamicBoneCollider>();
dynamic.ColliderName = collider._collisionName;
child.transform.localPosition = (collider._offset + collider._offset2) / 2;
child.transform.LookAt(child.transform.TransformPoint(collider._offset2));
dynamic.m_Direction = DynamicBoneColliderBase.Direction.Z;
dynamic.m_Height = (collider._offset - collider._offset2).magnitude + collider._radius;
Vector3 capsuleAxis = collider._offset2 - collider._offset;
if (!authoredTransform)
{
child.transform.localPosition = (collider._offset + collider._offset2) / 2;
if (capsuleAxis.sqrMagnitude > 0.000001f)
child.transform.localRotation = Quaternion.FromToRotation(Vector3.forward, capsuleAxis);
}
dynamic.m_Direction = authoredTransform
? DynamicBoneColliderBase.Direction.Y
: DynamicBoneColliderBase.Direction.Z;
// DynamicBone stores full capsule height, including both hemispheres.
dynamic.m_Height = capsuleAxis.magnitude + collider._radius * 2f;
dynamic.m_Radius = collider._radius;
dynamic.m_Bound = collider._isInner ? DynamicBoneColliderBase.Bound.Inside : DynamicBoneColliderBase.Bound.Outside;
break;
case CySpringCollisionData.CollisionType.Sphere:
var Spheredynamic = child.AddComponent<DynamicBoneCollider>();
var Spheredynamic = child.gameObject.AddComponent<DynamicBoneCollider>();
Spheredynamic.ColliderName = collider._collisionName;
child.transform.localPosition = collider._offset;
if (!authoredTransform)
child.transform.localPosition = collider._offset;
Spheredynamic.m_Radius = collider._radius;
Spheredynamic.m_Height = collider._distance;
// A CySpring sphere's distance is not DynamicBone capsule height.
// Giving it a height turns the authored hip sphere into a long capsule.
Spheredynamic.m_Height = 0f;
Spheredynamic.m_Bound = collider._isInner ? DynamicBoneColliderBase.Bound.Inside : DynamicBoneColliderBase.Bound.Outside;
break;
case CySpringCollisionData.CollisionType.Plane:
var planedynamic = child.AddComponent<DynamicBonePlaneCollider>();
var planedynamic = child.gameObject.AddComponent<DynamicBonePlaneCollider>();
planedynamic.ColliderName = collider._collisionName;
child.transform.localPosition = collider._offset;
if (!authoredTransform)
child.transform.localPosition = collider._offset;
planedynamic.m_Bound = collider._isInner ? DynamicBoneColliderBase.Bound.Inside : DynamicBoneColliderBase.Bound.Outside;
break;
case CySpringCollisionData.CollisionType.None:
break;
}
}
}
return colliders;
}
@@ -117,11 +137,23 @@ namespace Gallop
public void InitializePhysics(Dictionary<string, Transform> bones, Dictionary<string, Transform> colliders)
{
DynamicBones.Clear();
var skirtColliders = new List<DynamicBoneColliderBase>();
foreach (Transform transform in colliders.Values)
{
DynamicBoneColliderBase collider = transform.GetComponent<DynamicBoneColliderBase>();
if (collider != null && IsSharedThighSkirtCollider(collider.ColliderName))
{
skirtColliders.Add(collider);
}
}
// 获取对象名称并判断类型
string nameLower = gameObject.name.ToLower();
bool isTailObject = nameLower.Contains("tail"); // 判断是否为尾巴
bool isClothObject = !isTailObject && nameLower.Contains("chr"); // 判断是否为头发,排除尾巴避免重复匹配
bool isChestBowObject = !isTailObject &&
(nameLower.Contains("ribbon") || nameLower.Contains("bow") ||
nameLower.Contains("tie") || nameLower.Contains("bust"));
foreach (CySpringParamDataElement spring in springParam)
{
@@ -129,6 +161,8 @@ namespace Gallop
var dynamic = bone.gameObject.AddComponent<DynamicBone>();
dynamic.m_Root = bone;
dynamic.m_MotionGroup = isChestBowObject ? "ChestBow" :
(isClothObject ? "Hair" : string.Empty);
// 设置重力
dynamic.m_Gravity = new Vector3(0, Mathf.Clamp01(-30f / spring._gravity), 0);
@@ -166,13 +200,41 @@ namespace Gallop
dynamic.SetupParticles();
DynamicBones.Add(dynamic);
bool isSkirtChain = spring._boneName.IndexOf("Skirt", System.StringComparison.OrdinalIgnoreCase) >= 0;
if (isSkirtChain)
{
dynamic.m_ContinuousCollision = true;
}
// 主碰撞器添加
// The source list belongs to the root spring node. DynamicBone particle zero
// is fixed, so apply it to the first simulated particle for skirt chains.
foreach (string collisionName in spring._collisionNameList)
{
if (colliders.TryGetValue(collisionName, out Transform tmp))
if (!colliders.TryGetValue(collisionName, out Transform tmp))
continue;
DynamicBoneColliderBase collider = tmp.GetComponent<DynamicBoneColliderBase>();
if (isSkirtChain && !ShouldUseAuthoredSkirtCollider(collider))
continue;
int particleIndex = isSkirtChain && dynamic.Particles.Count > 1 ? 1 : 0;
AddCollider(dynamic.Particles[particleIndex], collider);
}
if (isSkirtChain)
{
Transform hipBone;
bones.TryGetValue("Hip", out hipBone);
foreach (DynamicBoneColliderBase collider in skirtColliders)
AddColliderToSkirtParticles(dynamic, collider);
foreach (Transform transform in colliders.Values)
{
dynamic.Particles[0].m_Colliders.Add(tmp.GetComponent<DynamicBoneColliderBase>());
DynamicBoneColliderBase collider = transform.GetComponent<DynamicBoneColliderBase>();
if (collider == null || !IsHipSkirtCollider(collider.ColliderName) ||
!IsSameSkirtSide(dynamic.m_Root, collider, hipBone))
continue;
AddColliderToSkirtParticles(dynamic, collider);
}
}
@@ -218,18 +280,96 @@ namespace Gallop
{
if (colliders.TryGetValue(collisionName, out Transform tmp))
{
var collider = tmp.GetComponent<DynamicBoneColliderBase>();
if (collider != null)
{
tempParticle.m_Colliders.Add(collider);
}
DynamicBoneColliderBase collider = tmp.GetComponent<DynamicBoneColliderBase>();
if (!isSkirtChain || ShouldUseAuthoredSkirtCollider(collider))
AddCollider(tempParticle, collider);
}
}
}
}
}
private static void AddColliderToSkirtParticles(DynamicBone dynamic, DynamicBoneColliderBase collider)
{
// Particle zero is the fixed skirt root and is not simulated.
for (int i = 1; i < dynamic.Particles.Count; i++)
AddCollider(dynamic.Particles[i], collider);
}
private static void AddCollider(DynamicBone.Particle particle, DynamicBoneColliderBase collider)
{
if (particle != null && collider != null && !particle.m_Colliders.Contains(collider))
particle.m_Colliders.Add(collider);
}
private static bool IsThighSkirtCollider(string colliderName)
{
return !string.IsNullOrEmpty(colliderName) &&
colliderName.IndexOf("Skirt", System.StringComparison.OrdinalIgnoreCase) >= 0 &&
colliderName.IndexOf("Thigh", System.StringComparison.OrdinalIgnoreCase) >= 0;
}
private static bool IsSkirtLegOrHipCollider(string colliderName)
{
return !string.IsNullOrEmpty(colliderName) &&
colliderName.IndexOf("Skirt", System.StringComparison.OrdinalIgnoreCase) >= 0 &&
(colliderName.IndexOf("Thigh", System.StringComparison.OrdinalIgnoreCase) >= 0 ||
colliderName.IndexOf("Hip", System.StringComparison.OrdinalIgnoreCase) >= 0);
}
private static bool IsActiveSkirtBodyCollider(string colliderName)
{
return string.Equals(colliderName, "Col_B_Thigh_L_MSkirt_L", System.StringComparison.OrdinalIgnoreCase) ||
string.Equals(colliderName, "Col_B_Thigh_R_MSkirt_R", System.StringComparison.OrdinalIgnoreCase) ||
string.Equals(colliderName, "Col_B_Hip_MSkirt_L", System.StringComparison.OrdinalIgnoreCase) ||
string.Equals(colliderName, "Col_B_Hip_MSkirt_R", System.StringComparison.OrdinalIgnoreCase);
}
private static bool IsSharedThighSkirtCollider(string colliderName)
{
return !string.IsNullOrEmpty(colliderName) &&
colliderName.IndexOf("Thigh", System.StringComparison.OrdinalIgnoreCase) >= 0 &&
colliderName.IndexOf("MSkirt", System.StringComparison.OrdinalIgnoreCase) >= 0;
}
private static bool ShouldUseAuthoredSkirtCollider(DynamicBoneColliderBase collider)
{
if (collider == null || string.IsNullOrEmpty(collider.ColliderName))
return false;
if (IsThighSkirtCollider(collider.ColliderName))
return false;
bool isHipSkirt = collider.ColliderName.IndexOf(
"Hip", System.StringComparison.OrdinalIgnoreCase) >= 0 &&
collider.ColliderName.IndexOf(
"Skirt", System.StringComparison.OrdinalIgnoreCase) >= 0;
// The original CySpring solver can use broad rear/center hip spheres,
// but they over-expand an independent DynamicBone skirt chain. This is
// true for both the old MSkirt names and newer Col_B_Hip_Skirt_B/BLR.
// Keep only an explicitly left/right localized hip pair.
return !isHipSkirt || IsHipSkirtCollider(collider.ColliderName);
}
private static bool IsHipSkirtCollider(string colliderName)
{
return !string.IsNullOrEmpty(colliderName) &&
colliderName.IndexOf("Skirt", System.StringComparison.OrdinalIgnoreCase) >= 0 &&
colliderName.IndexOf("Hip", System.StringComparison.OrdinalIgnoreCase) >= 0 &&
(colliderName.EndsWith("_L", System.StringComparison.OrdinalIgnoreCase) ||
colliderName.EndsWith("_R", System.StringComparison.OrdinalIgnoreCase));
}
private static bool IsSameSkirtSide(Transform skirtRoot, DynamicBoneColliderBase collider, Transform hip)
{
if (skirtRoot == null || collider == null || hip == null)
return true;
bool rootLeft = hip.InverseTransformPoint(skirtRoot.position).x < 0f;
bool colliderLeft = hip.InverseTransformPoint(collider.transform.position).x < 0f;
return rootLeft == colliderLeft;
}
public void EnablePhysics(bool isOn)
{
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,8 @@
namespace Gallop
{
// Asset bundles reference this original qualified signature. The recovered implementation
// lives in the global namespace, so inherit it to preserve all serialized collision fields.
public class CySpringCollisionComponent : global::CySpringCollisionComponent
{
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 9459a406c0ff58349bd3255f1827c124
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -78,6 +78,19 @@ namespace Gallop.Live.Cutt
public event Action<LiveTimelineChromaticAberrationData, LiveTimelineKeyChromaticAberrationData> OnUpdateChromaticAberration;
public event Action<LiveTimelineHdrBloomData, LiveTimelineKeyHdrBloomData> OnUpdateHdrBloom;
public event Action<LiveTimelineColorCorrectionData, LiveTimelineKeyColorCorrectionData> OnUpdateColorCorrection;
public event MobCyalumeUpdateInfoDelegate OnUpdateMobControl;
public event MobCyalumeUpdateInfoDelegate OnUpdateCyalumeControl;
public event Action<LiveTimelineMonitorControlData, LiveTimelineKeyMonitorControlData> OnUpdateMonitorControl;
public event Action<LiveTimelineMonitorCameraPositionData, LiveTimelineKeyMonitorCameraPositionData> OnUpdateMonitorCameraPosition;
public event Action<LiveTimelineMonitorCameraLookAtData, LiveTimelineKeyMonitorCameraLookAtData> OnUpdateMonitorCameraLookAt;
public event Action<LiveTimelineLensFlareData, LiveTimelineKeyLensFlareData> OnUpdateLensFlare;
public event Action<LiveTimelineStageEnvironmentData, LiveTimelineKeyStageEnvironmentData> OnUpdateEnvironment;
public event Action<int, LiveTimelineKeyFacialToonData> OnUpdateFacialToon;
public event Action<LiveTimelinePropsData, LiveTimelineKeyPropsData> OnUpdateProps;
public event Action<LiveTimelinePropsAttachData, LiveTimelineKeyPropsAttachData> OnUpdatePropsAttach;
public event Action<LiveTimelineKeyCharaFootLightData> OnUpdateCharaFootLight;
public event Action<LiveTimelineAdditionalLight, LiveTimelineKeyData_AdditionalLight> OnUpdateAdditionalLight;
public event Action<LiveTimelineLightProjectionData, LiveTimelineKeyLightProjectionData> OnUpdateLightProjection;
private static Func<LiveTimelineKeyCameraPositionData, LiveTimelineControl, FindTimelineConfig, Vector3> fnGetCameraPosValue = GetCameraPosValue;
@@ -210,6 +223,7 @@ namespace Gallop.Live.Cutt
private Vector3 _liveStageCenterPos = Vector3.zero;
private TimelinePlayerMode _playMode = TimelinePlayerMode.Default;
public TimelinePlayerMode PlayMode => _playMode;
public static int liveCharaPositionMax
{
@@ -382,6 +396,19 @@ namespace Gallop.Live.Cutt
// 分发保留,接上只需订阅 OnUpdateHdrBloom。
AlterUpdate_SimpleListControl(workSheet.hdrBloomKeys, d => d.keys, OnUpdateHdrBloom, _currentFrame);
AlterUpdate_SimpleListControl(workSheet.colorCorrectionDataLists, d => d.keys, OnUpdateColorCorrection, _currentFrame);
AlterUpdate_MobCyalumeControl(workSheet.MobControlKeys, _currentFrame, OnUpdateMobControl);
AlterUpdate_MobCyalumeControl(workSheet.CyalumeControlKeys, _currentFrame, OnUpdateCyalumeControl);
AlterUpdate_SimpleListControl(workSheet.monitorControlList, d => d.keys, OnUpdateMonitorControl, _currentFrame);
AlterUpdate_SimpleListControl(workSheet.monitorCameraPosKeys, d => d.keys, OnUpdateMonitorCameraPosition, _currentFrame);
AlterUpdate_SimpleListControl(workSheet.monitorCameraLookAtKeys, d => d.keys, OnUpdateMonitorCameraLookAt, _currentFrame);
AlterUpdate_SimpleListControl(workSheet.lensFlareList, d => d.keys, OnUpdateLensFlare, _currentFrame);
AlterUpdate_SimpleListControl(workSheet.environmentDataLists, d => d.keys, OnUpdateEnvironment, _currentFrame);
AlterUpdate_FacialToon(workSheet.facialToonSet, _currentFrame);
AlterUpdate_SimpleListControl(workSheet.propsList, d => d.keys, OnUpdateProps, _currentFrame);
AlterUpdate_SimpleListControl(workSheet.propsAttachList, d => d.keys, OnUpdatePropsAttach, _currentFrame);
AlterUpdate_SimpleListControl(workSheet.AdditionalLightList, d => d.keys, OnUpdateAdditionalLight, _currentFrame);
AlterUpdate_CharaFootLight(workSheet.charaFootLightKeys, _currentFrame);
AlterUpdate_SimpleListControl(workSheet.lightProjectionList, d => d.keys, OnUpdateLightProjection, _currentFrame);
AlterUpdate_PostFilm(workSheet.postFilmKeys, 0, _currentFrame);
AlterUpdate_PostFilm(workSheet.postFilm2Keys, 1, _currentFrame);
AlterUpdate_PostFilm(workSheet.postFilm3Keys, 2, _currentFrame);
@@ -609,9 +636,14 @@ namespace Gallop.Live.Cutt
{
var formationList = data.worksheetList[0].formationOffsetSet.Init();
if (formationList == null)
return;
for (int i = 0; i < Director.instance.characterCount; i++)
int count = Mathf.Min(Director.instance.characterCount, formationList.Count);
for (int i = 0; i < count; i++)
{
if (formationList[i] == null)
continue;
LiveTimelineKeyIndex curKey = AlterUpdate_Key(formationList[i], liveTime);
if (curKey != null && curKey.index != -1)
@@ -2015,6 +2047,72 @@ namespace Gallop.Live.Cutt
}
}
private void AlterUpdate_MobCyalumeControl(
List<LiveTimelineMobCyalumeControlData> dataList,
float currentFrame,
MobCyalumeUpdateInfoDelegate callback)
{
if (callback == null || dataList == null) return;
for (int i = 0; i < dataList.Count; i++)
{
var data = dataList[i];
var keys = data != null ? data.keys : null;
if (keys == null || keys.Count == 0 || keys.HasAttribute(LiveTimelineKeyDataListAttr.Disable) ||
!keys.EnablePlayModeTimeline(_playMode)) continue;
FindTimelineKey(out var curKey, out var nextKey, keys, currentFrame);
var current = curKey as LiveTimelineKeyMobCyalumeControlData;
var next = nextKey as LiveTimelineKeyMobCyalumeControlData;
if (current == null) continue;
var info = new MobCyalumeUpdateInfo
{
data = data,
unk0 = (uint)keys.unk48 < 11u ? keys.unk48 : i,
currentFrame = currentFrame,
currentLiveTime = currentLiveTime
};
if (next != null && next.interpolateType != LiveCameraInterpolateType.None)
{
float t = CalculateInterpolationValue(current, next, currentFrame);
info.position = Vector3.Lerp(current.position, next.position, t);
info.rotation = Quaternion.Lerp(current.GetRotation(), next.GetRotation(), t);
info.scale = Vector3.Lerp(current.scale, next.scale, t);
}
else
{
info.position = current.position;
info.rotation = current.GetRotation();
info.scale = current.scale;
}
callback.Invoke(ref info);
}
}
private void AlterUpdate_FacialToon(LiveTimelineFacialToonData data, float currentFrame)
{
if (data == null || OnUpdateFacialToon == null) return;
for (int i = 0; i < 20; i++)
{
var keys = data.GetKeys(i);
if (keys == null || keys.Count == 0 || keys.HasAttribute(LiveTimelineKeyDataListAttr.Disable) ||
!keys.EnablePlayModeTimeline(_playMode)) continue;
FindTimelineKey(out var current, out var _, keys, currentFrame);
if (current is LiveTimelineKeyFacialToonData key) OnUpdateFacialToon.Invoke(i, key);
}
}
private void AlterUpdate_CharaFootLight(LiveTimelineKeyCharaFootLightDataList keys, float currentFrame)
{
if (keys == null || OnUpdateCharaFootLight == null || keys.Count == 0 ||
keys.HasAttribute(LiveTimelineKeyDataListAttr.Disable) || !keys.EnablePlayModeTimeline(_playMode)) return;
FindTimelineKey(out var current, out var _, keys, currentFrame);
if (current is LiveTimelineKeyCharaFootLightData key) OnUpdateCharaFootLight.Invoke(key);
}
private void AlterUpdate_GlobalFogControl(LiveTimelineWorkSheet sheet, float currentFrame)
{
if (sheet.globalFogDataLists == null || OnUpdateGlobalFog == null) return;
@@ -2070,4 +2168,3 @@ namespace Gallop.Live.Cutt
}
@@ -0,0 +1,311 @@
using System;
using UnityEngine;
namespace Gallop.Live.Cutt
{
[Serializable]
public class LiveTimelineKeyLensFlareData : LiveTimelineKeyWithInterpolate
{
public Vector3 offset;
public Color color = Color.white;
public float brightness = 1f;
public float fadeSpeed = 1f;
public int enableParameter;
public int enableFlare;
public int IsAutoBrightness;
public int IsOverridePosition;
}
[Serializable]
public class LiveTimelineKeyLensFlareDataList : LiveTimelineKeyDataListTemplate<LiveTimelineKeyLensFlareData> { }
[Serializable]
public class LiveTimelineLensFlareData : ILiveTimelineGroupDataWithName
{
public LiveTimelineKeyLensFlareDataList keys;
}
[Serializable]
public class LiveTimelineKeyStageEnvironmentData : LiveTimelineKey
{
public bool isValidMirror;
public bool isMirror;
public bool isBgMirror;
public bool IsMirrorBg3d;
public bool EnableCharacterMirrorExpandFaceBounds;
public int characterMirror;
public int CharacterMirrorHead;
public int CharacterMirrorExpandFaceBounds;
public float mirrorReflectionRate = 1f;
public bool isValidShadow;
public int characterShadow;
public bool isSoftShadow;
public bool IsToonMirror;
public bool isValidWaterReflection;
public float waterReflection;
public float waveScale;
public float waterDistortion;
public float waterUCross;
public float waterVCross;
public float waterUSpeed;
public float waterVSpeed;
public float waterNormalPower;
public float waveDistortionPower;
public float waveClearly;
public float waveDiffusion;
public float waveDecline;
public Color waterColor;
public float waterVOffset;
public bool IsValidStageFovShift;
public float BaseFov;
public float ShiftPower;
public bool IsShiftY;
public bool _isValidMirror;
public int _mirror;
public int _bgMirror;
public int _characterMirror;
public float _mirrorReflectionRate = -1f;
public bool MirrorIsValid => isValidMirror || _isValidMirror;
public bool MirrorEnabled => isMirror || isBgMirror || IsMirrorBg3d || _mirror != 0 || _bgMirror != 0;
public float ReflectionRate => _mirrorReflectionRate >= 0f ? _mirrorReflectionRate : mirrorReflectionRate;
}
[Serializable]
public class LiveTimelineKeyStageEnvironmentDataList : LiveTimelineKeyDataListTemplate<LiveTimelineKeyStageEnvironmentData> { }
[Serializable]
public class LiveTimelineStageEnvironmentData : ILiveTimelineGroupDataWithName
{
public LiveTimelineKeyStageEnvironmentDataList keys;
}
[Serializable]
public class LiveTimelineKeyFacialToonData : LiveTimelineKeyWithInterpolate
{
public float CheekPretenseThreshold;
public float NosePretenseThreshold;
public float CylinderBlend;
public float HairNormalBlend;
public int UseOriginalDirectionalLight;
public Vector3 OriginalDirectionalLightDir;
public float EyeToonStep;
public float EyeToonFeather;
public float EyeSaturation;
}
[Serializable]
public class LiveTimelineKeyFacialToonDataList : LiveTimelineKeyDataListTemplate<LiveTimelineKeyFacialToonData> { }
[Serializable]
public class LiveTimelineFacialToonData
{
public LiveTimelineKeyFacialToonDataList centerKeys;
public LiveTimelineKeyFacialToonDataList left1Keys;
public LiveTimelineKeyFacialToonDataList right1Keys;
public LiveTimelineKeyFacialToonDataList left2Keys;
public LiveTimelineKeyFacialToonDataList right2Keys;
public LiveTimelineKeyFacialToonDataList motion5Keys;
public LiveTimelineKeyFacialToonDataList motion6Keys;
public LiveTimelineKeyFacialToonDataList motion7Keys;
public LiveTimelineKeyFacialToonDataList motion8Keys;
public LiveTimelineKeyFacialToonDataList motion9Keys;
public LiveTimelineKeyFacialToonDataList motion10Keys;
public LiveTimelineKeyFacialToonDataList motion11Keys;
public LiveTimelineKeyFacialToonDataList motion12Keys;
public LiveTimelineKeyFacialToonDataList motion13Keys;
public LiveTimelineKeyFacialToonDataList motion14Keys;
public LiveTimelineKeyFacialToonDataList motion15Keys;
public LiveTimelineKeyFacialToonDataList motion16Keys;
public LiveTimelineKeyFacialToonDataList motion17Keys;
public LiveTimelineKeyFacialToonDataList motion18Keys;
public LiveTimelineKeyFacialToonDataList motion19Keys;
public LiveTimelineKeyFacialToonDataList GetKeys(int index)
{
switch (index)
{
case 0: return centerKeys; case 1: return left1Keys; case 2: return right1Keys;
case 3: return left2Keys; case 4: return right2Keys; case 5: return motion5Keys;
case 6: return motion6Keys; case 7: return motion7Keys; case 8: return motion8Keys;
case 9: return motion9Keys; case 10: return motion10Keys; case 11: return motion11Keys;
case 12: return motion12Keys; case 13: return motion13Keys; case 14: return motion14Keys;
case 15: return motion15Keys; case 16: return motion16Keys; case 17: return motion17Keys;
case 18: return motion18Keys; case 19: return motion19Keys; default: return null;
}
}
}
[Serializable]
public class LiveTimelineKeyCameraLayerData : LiveTimelineKeyWithInterpolate
{
public Vector3 offsetMaxPosition;
public Vector3 offsetMinPosition;
}
[Serializable]
public class LiveTimelineKeyCameraLayerDataList : LiveTimelineKeyDataListTemplate<LiveTimelineKeyCameraLayerData> { }
[Serializable]
public class LiveTimelineKeyPropsData : LiveTimelineKeyWithInterpolate
{
public int settingFlags;
public int propsID;
public int rendererEnable;
public int IsVisibleAttachedCharaLinked;
public int AutoSwitchLayerOnMirrorRendering;
public Color color = Color.white;
public Color rootColor = Color.white;
public Color tipColor = Color.white;
public float colorPower = 1f;
public int IsApplyAnimation;
public int IsApplyReserveWarming;
public AnimationClip AnimationClip;
public float StartAnimationTime;
public int AnimationHeadFrame;
public Color ToonDarkColor;
public Color ToonBrightColor;
public int IsCastShadow;
public int IsCastShadowForced;
public Vector3 _directionalLightAngle;
public int IsUpdateOutline;
public float OutlineWidth;
public Color OutlineColor;
public int IsEmissive;
public Color EmissiveColor;
public float EmissiveScrollTimeScale = 1f;
public float EmissiveScrollEnergyScale = 1f;
}
[Serializable] public class LiveTimelineKeyPropsDataList : LiveTimelineKeyDataListTemplate<LiveTimelineKeyPropsData> { }
[Serializable]
public class LiveTimelinePropsData : ILiveTimelineGroupDataWithName
{
public LiveTimelineKeyPropsDataList keys;
public bool _applyVariation;
public int _variationId;
}
[Serializable]
public class LiveTimelineKeyPropsAttachData : LiveTimelineKeyWithInterpolate
{
public string _attachJointName;
public int _attachJointHash;
public string _copyPositionJointName;
public int _copyPositionJointHash;
public int _settingFlags;
public int _propsId;
public Vector3 _offsetPosition;
public Vector3 OffsetRotate;
public Vector3 OffsetScale = Vector3.one;
public int IsLinkAttachBone;
public int _attachType;
public int _attachPropId = -1;
public string _attachTargetPropNodeName;
}
[Serializable] public class LiveTimelineKeyPropsAttachDataList : LiveTimelineKeyDataListTemplate<LiveTimelineKeyPropsAttachData> { }
[Serializable]
public class LiveTimelinePropsAttachData : ILiveTimelineGroupDataWithName
{
public LiveTimelineKeyPropsAttachDataList keys;
public bool _applyVariation;
public int _variationId;
}
[Serializable]
public class LiveTimelineKeyCharaFootLightData : LiveTimelineKeyWithInterpolate
{
public int positionFlag;
public float[] hightMax = Array.Empty<float>();
public Color[] lightColor = Array.Empty<Color>();
public int[] LightBlendModeArray = Array.Empty<int>();
public int[] EasingArray = Array.Empty<int>();
}
[Serializable] public class LiveTimelineKeyCharaFootLightDataList : LiveTimelineKeyDataListTemplate<LiveTimelineKeyCharaFootLightData> { }
[Serializable]
public class LiveTimelineKeyData_AdditionalLight : LiveTimelineKeyWithInterpolate
{
public Vector3 Position;
public Vector3 Rotate;
public int IsEnable;
public int Type;
public float Range;
public int SpotAngle;
public int IndirectMultiplier;
public int ShadowType;
public float Strength = 1f;
public float Bias = 0.05f;
public float NormalBias = 0.4f;
public float NearPlane = 0.2f;
}
[Serializable] public class LiveTimelineKeyAdditionalLightList : LiveTimelineKeyDataListTemplate<LiveTimelineKeyData_AdditionalLight> { }
[Serializable]
public class LiveTimelineAdditionalLight : ILiveTimelineGroupDataWithName
{
public LiveTimelineKeyAdditionalLightList keys;
}
[Serializable]
public class LiveTimelineLightProjectionAnimationParam
{
public int TextureId = -1;
public int DivisionNumberX;
public int DivisionNumberY;
public int MaxCut;
public float AnimationTime;
public Vector2 ScaleUV = Vector2.one;
public Vector2 OffsetUV;
}
[Serializable]
public class LiveTimelineKeyLightProjectionData : LiveTimelineKeyWithInterpolate
{
public int IsEnable;
public int OverrideIgnoreLayer;
public LayerMask OverrideLayerMask;
public int BacksideOff;
public float MirrorBallBacksideFeather;
public int ProjectionToCharacterModelOnly;
public int TextureId;
public Color Color = Color.white;
public Vector3 Position;
public Vector3 Angle;
public Vector3 Scale = Vector3.one;
public int Orthographic;
public float OrthographicSize = 10f;
public float NearClipPlane = 0.1f;
public float FarClipPlane = 100f;
public float FieldOfView = 60f;
public float ColorPower = 1f;
public int LightBlendMode;
public int CharacterAttach;
public int CharacterAttachPosition;
public string BlinkLightName;
public int BlinkLightNameHash;
public int BlinkLightContainerIndex;
public float BlinkLightBrightnessPower = 1f;
public int IsAdjustedBlinkLightColor;
public Vector3 MirrorBallRotateAxis = Vector3.up;
public float MirrorBallRotateValue;
public float MirrorBallProjectionRadius = 20f;
public float MirrorBallFallOffPower = 2f;
public int MirrorBallIsLoopRotation;
public float MirrorBallLoopRotationSpeed;
public Vector2 MirrorBallUVOffset;
public Vector2 MirrorBallUVScale = Vector2.one;
public int MirrorBallUseCubeMap;
public LiveTimelineLightProjectionAnimationParam AnimationParam;
}
[Serializable] public class LiveTimelineKeyLightProjectionDataList : LiveTimelineKeyDataListTemplate<LiveTimelineKeyLightProjectionData> { }
[Serializable]
public class LiveTimelineLightProjectionData : ILiveTimelineGroupDataWithName
{
public LiveTimelineKeyLightProjectionDataList keys;
public int ContentType;
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 7ae1a98a234410e4d87de597cb89e723
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,52 @@
using System;
using UnityEngine;
namespace Gallop.Live.Cutt
{
[Serializable]
public class LiveTimelineKeyMobCyalumeControlData : LiveTimelineKeyWithInterpolate
{
public Vector3 position;
public Vector3 angle;
public Vector3 scale = Vector3.one;
[NonSerialized] private Quaternion _rotationCache = Quaternion.identity;
[NonSerialized] private bool _hasRotationCache;
public Quaternion GetRotation()
{
if (!_hasRotationCache)
{
_rotationCache = Quaternion.Euler(angle);
_hasRotationCache = true;
}
return _rotationCache;
}
}
[Serializable]
public class LiveTimelineKeyMobCyalumeControlDataList : LiveTimelineKeyDataListTemplate<LiveTimelineKeyMobCyalumeControlData>
{
public int unk48;
}
[Serializable]
public class LiveTimelineMobCyalumeControlData : ILiveTimelineGroupDataWithName
{
public LiveTimelineKeyMobCyalumeControlDataList keys;
}
public delegate void MobCyalumeUpdateInfoDelegate(ref MobCyalumeUpdateInfo updateInfo);
public struct MobCyalumeUpdateInfo
{
public LiveTimelineMobCyalumeControlData data;
public int unk0;
public Vector3 position;
public Quaternion rotation;
public Vector3 scale;
public float currentFrame;
public float currentLiveTime;
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 280ab9377b79c584084b467f575dcf5e
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,94 @@
using System;
using UnityEngine;
namespace Gallop.Live.Cutt
{
[Serializable]
public class LiveTimelineMonitorDressCondition
{
public int IsCheck;
public int CharaId;
public int DressId;
public bool IsEnabled => IsCheck != 0;
}
[Serializable]
public class LiveTimelineMonitorChangeUVSetting
{
public int IsChangeUVSetting;
public int DispID = -1;
public LiveTimelineMonitorDressCondition[] ConditionArray = Array.Empty<LiveTimelineMonitorDressCondition>();
public bool IsEnabled => IsChangeUVSetting != 0;
}
[Serializable]
public class LiveTimelineKeyMonitorControlData : LiveTimelineKeyWithInterpolate
{
public Vector2 position;
public Vector2 size = Vector2.one;
public int dispID;
public float speed = 1f;
public string outputTextureLabel = string.Empty;
public int playStartOffsetFrame;
public float blendFactor = 1f;
public Color colorFade = Color.clear;
public Color BaseColor = Color.clear;
public int SrcBlendMode = 1;
public int DstBlendMode;
public int RenderQueueNo = 2000;
public int IsRenderQueue;
public int DispID2 = -1;
public float CrossFadeRate;
public int LightImageNo;
public int LightImageNo2;
public float FilterTexScale = 1f;
public LiveTimelineMonitorChangeUVSetting[] ChangeUVSettingArray = Array.Empty<LiveTimelineMonitorChangeUVSetting>();
public int extraContent;
private const int AttrMulti = 65536;
private const int AttrPlayReverse = 131072;
private const int AttrUseMonitorCamera = 262144;
private const int AttrForcedUseMonitorCamera = 524288;
private const int AttrEnableBlendMode = 1048576;
public bool IsMultiFlag() => (((int)attribute) & AttrMulti) != 0;
public bool IsReversePlayFlag() => (((int)attribute) & AttrPlayReverse) != 0;
public bool IsMonitorCameraFlag() => (((int)attribute) & AttrUseMonitorCamera) != 0;
public bool IsForcedUseMonitorCamera => (((int)attribute) & AttrForcedUseMonitorCamera) != 0;
public bool IsEnabledBlendMode => (((int)attribute) & AttrEnableBlendMode) != 0;
}
[Serializable]
public class LiveTimelineKeyMonitorControlDataList : LiveTimelineKeyDataListTemplate<LiveTimelineKeyMonitorControlData> { }
[Serializable]
public class LiveTimelineMonitorControlData : ILiveTimelineGroupDataWithName
{
public LiveTimelineKeyMonitorControlDataList keys;
public string SafeName => string.IsNullOrWhiteSpace(name) ? "Monitor" : name;
}
[Serializable]
public class LiveTimelineKeyMonitorCameraPositionData : LiveTimelineKeyCameraPositionData { }
[Serializable]
public class LiveTimelineKeyMonitorCameraPositionDataList : LiveTimelineKeyDataListTemplate<LiveTimelineKeyMonitorCameraPositionData> { }
[Serializable]
public class LiveTimelineMonitorCameraPositionData : ILiveTimelineGroupDataWithName
{
public LiveTimelineKeyMonitorCameraPositionDataList keys;
}
[Serializable]
public class LiveTimelineKeyMonitorCameraLookAtData : LiveTimelineKeyCameraLookAtData { }
[Serializable]
public class LiveTimelineKeyMonitorCameraLookAtDataList : LiveTimelineKeyDataListTemplate<LiveTimelineKeyMonitorCameraLookAtData> { }
[Serializable]
public class LiveTimelineMonitorCameraLookAtData : ILiveTimelineGroupDataWithName
{
public LiveTimelineKeyMonitorCameraLookAtDataList keys;
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: cfdc8a520019e8544b2904b4954b6182
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -952,6 +952,22 @@ namespace Gallop.Live.Cutt
[SerializeField] public List<LiveTimelineHdrBloomData> hdrBloomKeys;
[SerializeField] public List<LiveTimelineColorCorrectionData> colorCorrectionDataLists;
// Names verified against the cutt TypeTree. The capital letters are required for Unity deserialization.
[SerializeField] public List<LiveTimelineMobCyalumeControlData> MobControlKeys;
[SerializeField] public List<LiveTimelineMobCyalumeControlData> CyalumeControlKeys;
[SerializeField] public List<LiveTimelineMonitorControlData> monitorControlList;
[SerializeField] public List<LiveTimelineMonitorCameraPositionData> monitorCameraPosKeys;
[SerializeField] public List<LiveTimelineMonitorCameraLookAtData> monitorCameraLookAtKeys;
[SerializeField] public List<LiveTimelineLensFlareData> lensFlareList;
[SerializeField] public List<LiveTimelineStageEnvironmentData> environmentDataLists;
[SerializeField] public LiveTimelineFacialToonData facialToonSet;
[SerializeField] public LiveTimelineKeyCameraLayerDataList cameraLayerKeys;
[SerializeField] public List<LiveTimelinePropsData> propsList;
[SerializeField] public List<LiveTimelinePropsAttachData> propsAttachList;
[SerializeField] public LiveTimelineKeyCharaFootLightDataList charaFootLightKeys;
[SerializeField] public List<LiveTimelineAdditionalLight> AdditionalLightList;
[SerializeField] public List<LiveTimelineLightProjectionData> lightProjectionList;
// PostFilm (39):全语料 ~20200 keys / 59 首,是未实现轨道里数据量最大的一块。
// 旧扫描把它记成「58/58 首存在但全部 0 keyframe(空占位)」,实测是错的
// (song 1177 分别有 100 / 161 / 87 keys)。字段名取自 bundle TypeTree。
@@ -1039,4 +1055,3 @@ namespace Gallop.Live.Cutt
}
}
}
@@ -0,0 +1,8 @@
using UnityEngine;
// Compatibility component for scenes authored with the master4 helper. Runtime setup is now
// owned by Director.InitializeCyalumeStage, so this type intentionally has no behavior.
[DisallowMultipleComponent]
public sealed class CyalumeAutoBinder : MonoBehaviour
{
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 76a8f506f7e580449b2e8dee47ca1120
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -1,55 +1,524 @@
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Gallop.Cyalume;
using Gallop.Live.Cutt;
using UnityEngine;
namespace Gallop.Live.Cyalume
{
[DisallowMultipleComponent]
public class CyalumeController3D : CyalumeControllerBase
{
float passedtime = 0.0f;
float intervaltime = 0.033333333f;
float y_offset = 0.0f;
GameObject CyalumeObj;
Material CyalumeMaterial;
private void Awake()
[SerializeField] private bool _initializedObjects;
[SerializeField] private bool _autoSetupOnStart;
[SerializeField] private float _forceReplaceWarmupSeconds = 1.5f;
private readonly List<Renderer> _defaultRenderers = new List<Renderer>();
private readonly List<Renderer> _randomRenderers = new List<Renderer>();
private GameObject _defaultInstance;
private GameObject _randomInstance;
private bool _usingRandomTarget;
private Coroutine _setupCoroutine;
private Coroutine _forceReplaceCoroutine;
[Header("Mob")]
[SerializeField] private bool _enableMobController = true;
[SerializeField] private string _mobPrefabName = "mob";
[SerializeField] private GameObject _mobRootOverride;
[SerializeField] private Color _mobColor = Color.white;
[SerializeField] private Color _mobAmbientColor = Color.white;
[SerializeField] private bool _mobVerboseLog;
private GameObject _mobInstance;
private MobShadowController _mobShadowController;
private LiveTimelineControl _mobTimelineControl;
private readonly Vector3[] _mobPositions = new Vector3[11];
private readonly Quaternion[] _mobRotations = new Quaternion[11];
private readonly Vector3[] _mobScales = new Vector3[11];
private readonly bool[] _mobDirtySlots = new bool[11];
private bool _mobHasDirty;
public bool UsingRandomTarget => _usingRandomTarget;
protected override void Awake()
{
InitializeCyalume();
gameObject.GetComponent<CyalumeController3D>().enabled = true;
base.Awake();
if (_autoSetupOnStart)
InitializeCyalumeObjectsOnly();
}
private void FixedUpdate()
private void Start()
{
if (CyalumeMaterial == null)
if (_autoSetupOnStart)
StartOfficialLikeSetup();
}
private void OnEnable()
{
TryBindGroupTimeline();
MarkGroupMatrixDirty();
}
private void OnDisable()
{
UnbindGroupTimeline();
}
public void StartOfficialLikeSetup(bool forceRebuild = false)
{
if (_setupCoroutine != null)
{
StopCoroutine(_setupCoroutine);
_setupCoroutine = null;
}
_setupCoroutine = StartCoroutine(SetupOfficialLike(forceRebuild));
}
public IEnumerator SetupOfficialLike(bool forceRebuild = false)
{
ResetRuntimeState();
InitializeCyalumeObjectsOnly(forceRebuild);
int musicId = ResolveMusicId();
if (musicId <= 0)
{
Debug.LogWarning("[CyalumeController3D] musicId is unavailable.");
yield break;
}
var provider = ResolvePlaybackProvider();
provider?.InitializeForMusicId(musicId);
string liveKey = CyalumePlaybackProvider.BuildPrimaryLiveKey(musicId);
LoadTextureBundlesFromIndex(liveKey);
ApplyTargetSelection(false, true);
EnsureCustomShaderOnCurrentTargets();
CollectTargetMeshAndMaterials();
CreateVertexColorCacheOfficialConservative();
yield return null;
_textureSet.Clear();
foreach (var pair in ResolveTexSetFromLoadedBundles(liveKey))
_textureSet[pair.Key] = pair.Value;
yield return null;
InitializeAudienceUvSpreadOfficialConservative();
RefreshRendererEnabledState();
_isInitialized = _targetRendererList.Count > 0 && _materialArray.Length > 0;
ForceReplaceOfficialMaterialsInMemory();
ForceReplaceCustomShaderInHierarchy(transform, true);
ForceReplaceOfficialShaderAcrossLoadedScene();
LogTargetShaderSummary("SetupOfficialLike");
WriteCyalumeSceneSnapshot("SetupOfficialLike");
if (_verboseLog)
{
Debug.Log($"[CyalumeController3D] Setup complete. liveKey={liveKey}, targets={_targetRendererList.Count}, meshes={_meshFilters.Length}, materials={_materialArray.Length}, patterns={_textureSet.Count}");
}
StartForceReplaceWarmup();
_setupCoroutine = null;
}
private void Update()
{
TryBindGroupTimeline();
if (!_isEnabledCyalume || !_initializedObjects || !_isInitialized)
return;
if (passedtime >= intervaltime)
if (!TryGetCurrentPlayback(out int patternId, out _, out _, out _, out _))
return;
bool preferRandom = _playbackProvider != null && _playbackProvider.IsRandomPattern(patternId);
bool targetChanged = ApplyTargetSelection(preferRandom);
bool shaderChanged = EnsureCustomShaderOnCurrentTargets() > 0;
bool memoryChanged = ForceReplaceOfficialMaterialsInMemory() > 0;
if (targetChanged)
{
y_offset += (float)0.03125;
if (y_offset >= 1.0f)
{
y_offset = 0.0f;
}
CyalumeMaterial.SetTextureOffset("_MainTex", new Vector2(0, y_offset));
passedtime = 0;
ForceReplaceOfficialMaterialsInMemory();
ForceReplaceCustomShaderInHierarchy(transform, true);
ForceReplaceOfficialShaderAcrossLoadedScene();
CollectTargetMeshAndMaterials();
CreateVertexColorCacheOfficialConservative();
InitializeAudienceUvSpreadOfficialConservative();
}
passedtime += Time.fixedDeltaTime;
else if (shaderChanged || memoryChanged)
{
CollectTargetMeshAndMaterials();
ForceReplaceOfficialShaderAcrossLoadedScene();
LogTargetShaderSummary(memoryChanged ? "ForceReplaceOfficialMaterialsInMemory" : "EnsureCustomShaderOnCurrentTargets");
}
UpdateCyalumeOfficialConservative(false);
}
public void InitializeCyalume()
private void LateUpdate()
{
AssetHolder CyalumeAssetHolder = gameObject.GetComponent<AssetHolder>();
List<StringObjectPair> CyalumeObject = CyalumeAssetHolder._assetTable.list;
foreach (StringObjectPair obj in CyalumeObject)
TryBindGroupTimeline();
FlushGroupMatrix();
FlushMobShadowIfNeeded();
}
private void StartForceReplaceWarmup()
{
if (_forceReplaceCoroutine != null)
{
GameObject tempObj = Instantiate((GameObject)obj.Value, transform);
if (obj.Value.name.Contains("cyalume")){
CyalumeObj = tempObj;
CyalumeMaterial = gameObject.GetComponentInChildren<MeshRenderer>().material;
StopCoroutine(_forceReplaceCoroutine);
_forceReplaceCoroutine = null;
}
if (_forceReplaceWarmupSeconds <= 0f)
return;
_forceReplaceCoroutine = StartCoroutine(ForceReplaceWarmupCoroutine());
}
private IEnumerator ForceReplaceWarmupCoroutine()
{
float endTime = Time.unscaledTime + _forceReplaceWarmupSeconds;
while (Time.unscaledTime < endTime)
{
int replacedCount = 0;
replacedCount += ForceReplaceOfficialMaterialsInMemory();
replacedCount += ForceReplaceCustomShaderInHierarchy(transform, true);
replacedCount += ForceReplaceOfficialShaderAcrossLoadedScene();
if (replacedCount > 0)
{
CollectTargetMeshAndMaterials();
LogTargetShaderSummary("ForceReplaceWarmup");
WriteCyalumeSceneSnapshot("ForceReplaceWarmup");
}
yield return null;
}
_forceReplaceCoroutine = null;
}
public override void InitializeCyalumeObjectsOnly(bool forceRebuild = false)
{
if (_initializedObjects && !forceRebuild)
return;
_initializedObjects = false;
_usingRandomTarget = false;
_defaultInstance = null;
_randomInstance = null;
_allRendererList.Clear();
_targetRendererList.Clear();
_defaultRenderers.Clear();
_randomRenderers.Clear();
var assetHolder = GetComponent<AssetHolder>();
if (assetHolder == null || assetHolder._assetTable == null)
{
Debug.LogWarning("[CyalumeController3D] AssetHolder or asset table is missing.");
return;
}
DestroyExistingCyalumeChildren();
TryInstantiateNamedPrefab(assetHolder, "default", out _defaultInstance, _defaultRenderers);
TryInstantiateNamedPrefab(assetHolder, "random", out _randomInstance, _randomRenderers);
InitializeMobController(assetHolder);
_allRendererList.AddRange(_defaultRenderers);
_allRendererList.AddRange(_randomRenderers);
RefreshRendererEnabledState();
_initializedObjects = true;
ApplyTargetSelection(false, true);
}
public override bool ApplyTargetSelection(bool preferRandom, bool forceRefresh = false)
{
if (!_initializedObjects)
{
InitializeCyalumeObjectsOnly();
if (!_initializedObjects)
return false;
}
bool useRandom = preferRandom || _defaultRenderers.Count == 0;
if (!useRandom && _defaultRenderers.Count == 0 && _randomRenderers.Count > 0)
useRandom = true;
bool changed = forceRefresh || _usingRandomTarget != useRandom || _targetRendererList.Count == 0;
_usingRandomTarget = useRandom;
_targetRendererList.Clear();
if (_usingRandomTarget)
_targetRendererList.AddRange(_randomRenderers.Count > 0 ? _randomRenderers : _defaultRenderers);
else
_targetRendererList.AddRange(_defaultRenderers.Count > 0 ? _defaultRenderers : _randomRenderers);
RefreshRendererEnabledState();
if (changed && _verboseLog)
{
Debug.Log($"[CyalumeController3D] Target selection -> {(_usingRandomTarget ? "random" : "default")}, count={_targetRendererList.Count}");
}
return changed;
}
public bool ContainsTargetRenderer(Renderer renderer)
{
if (renderer == null)
return false;
for (int i = 0; i < _targetRendererList.Count; i++)
{
if (_targetRendererList[i] == renderer)
return true;
}
return false;
}
public bool ContainsAnyRenderer(Renderer renderer)
{
if (renderer == null)
return false;
for (int i = 0; i < _allRendererList.Count; i++)
{
if (_allRendererList[i] == renderer)
return true;
}
return false;
}
private void TryBindGroupTimeline()
{
var director = Gallop.Live.Director.instance;
if (director == null)
return;
var timeline = director._liveTimelineControl;
if (timeline == null || ReferenceEquals(_mobTimelineControl, timeline))
return;
UnbindGroupTimeline();
_mobTimelineControl = timeline;
_mobTimelineControl.OnUpdateCyalumeControl += OnCyalumeControl;
if (_enableMobController)
_mobTimelineControl.OnUpdateMobControl += OnMobControl;
}
private void UnbindGroupTimeline()
{
if (_mobTimelineControl == null)
return;
_mobTimelineControl.OnUpdateCyalumeControl -= OnCyalumeControl;
_mobTimelineControl.OnUpdateMobControl -= OnMobControl;
_mobTimelineControl = null;
}
private void InitializeMobController(AssetHolder assetHolder)
{
_mobInstance = null;
_mobShadowController = null;
_mobHasDirty = false;
Array.Clear(_mobDirtySlots, 0, _mobDirtySlots.Length);
if (!_enableMobController)
return;
GameObject mobRoot = _mobRootOverride;
if (!mobRoot && assetHolder != null && !string.IsNullOrEmpty(_mobPrefabName))
{
TryInstantiateNamedPrefab(assetHolder, _mobPrefabName, out _mobInstance, null);
mobRoot = _mobInstance;
}
if (!mobRoot)
{
if (_mobVerboseLog)
Debug.LogWarning($"[CyalumeController3D] Mob root not found. prefabName='{_mobPrefabName}'");
return;
}
_mobShadowController = mobRoot.GetComponent<MobShadowController>();
if (_mobShadowController == null)
_mobShadowController = mobRoot.AddComponent<MobShadowController>();
_mobShadowController.Initialize(mobRoot);
_mobShadowController.SetMobColor(_mobColor);
_mobShadowController.SetAmbientColor(_mobAmbientColor);
if (_mobVerboseLog)
Debug.Log($"[CyalumeController3D] Mob controller initialized on '{mobRoot.name}'.");
}
private void OnCyalumeControl(ref MobCyalumeUpdateInfo info)
{
int index = ResolveGroupIndex(info);
if ((uint)index >= 11u)
return;
var position = info.position;
var rotation = info.rotation;
var scale = info.scale;
SetGroupTRS(index, ref position, ref rotation, ref scale);
}
private void OnMobControl(ref MobCyalumeUpdateInfo info)
{
int index = ResolveGroupIndex(info);
if ((uint)index >= 11u)
return;
_mobPositions[index] = info.position;
_mobRotations[index] = info.rotation;
_mobScales[index] = info.scale;
_mobDirtySlots[index] = true;
_mobHasDirty = true;
}
private static int ResolveGroupIndex(MobCyalumeUpdateInfo info)
{
if ((uint)info.unk0 < 11u)
return info.unk0;
if (info.data != null && info.data.keys != null && (uint)info.data.keys.unk48 < 11u)
return info.data.keys.unk48;
return -1;
}
private void FlushMobShadowIfNeeded()
{
if (!_mobHasDirty)
return;
if (_mobShadowController == null)
{
var assetHolder = GetComponent<AssetHolder>();
InitializeMobController(assetHolder);
if (_mobShadowController == null)
return;
}
for (int i = 0; i < _mobDirtySlots.Length; i++)
{
if (!_mobDirtySlots[i])
continue;
var position = _mobPositions[i];
var rotation = _mobRotations[i];
var scale = _mobScales[i];
_mobShadowController.SetGroupTRS(i, ref position, ref rotation, ref scale);
_mobDirtySlots[i] = false;
}
_mobShadowController.FlushGroupMatrix();
_mobHasDirty = false;
}
private void DestroyExistingCyalumeChildren()
{
for (int i = transform.childCount - 1; i >= 0; i--)
{
var child = transform.GetChild(i);
if (child == null)
continue;
string childName = child.name ?? string.Empty;
bool isKnownCyalumeChild =
childName.Equals("default", StringComparison.OrdinalIgnoreCase) ||
childName.Equals("random", StringComparison.OrdinalIgnoreCase) ||
(!string.IsNullOrEmpty(_mobPrefabName) && childName.Equals(_mobPrefabName, StringComparison.OrdinalIgnoreCase)) ||
childName.IndexOf("cyalume", StringComparison.OrdinalIgnoreCase) >= 0;
if (isKnownCyalumeChild)
Destroy(child.gameObject);
}
}
private bool TryInstantiateNamedPrefab(AssetHolder assetHolder, string assetName, out GameObject instance, List<Renderer> destination)
{
instance = null;
destination?.Clear();
if (!TryResolvePrefab(assetHolder, assetName, out var prefab) || !prefab)
{
if (_verboseLog)
Debug.LogWarning($"[CyalumeController3D] Missing cyalume prefab: {assetName}");
return false;
}
instance = Instantiate(prefab, transform);
instance.name = prefab.name;
SetLayerRecursively(instance, gameObject.layer);
if (destination != null)
{
var renderers = instance.GetComponentsInChildren<Renderer>(true);
ApplyCustomShaderOverrideToRenderers(renderers);
destination.AddRange(renderers);
}
if (_verboseLog)
{
Debug.Log($"[CyalumeController3D] Spawned cyalume prefab '{assetName}' renderers={destination?.Count ?? 0}");
}
return true;
}
private static void SetLayerRecursively(GameObject root, int layer)
{
if (!root)
return;
root.layer = layer;
for (int i = 0; i < root.transform.childCount; i++)
{
var child = root.transform.GetChild(i);
if (child)
SetLayerRecursively(child.gameObject, layer);
}
}
private static bool TryResolvePrefab(AssetHolder assetHolder, string assetName, out GameObject prefab)
{
prefab = null;
if (assetHolder == null || assetHolder._assetTable == null || assetHolder._assetTable.list == null)
return false;
foreach (var pair in assetHolder._assetTable.list)
{
var candidate = pair.Value as GameObject;
if (!candidate)
continue;
if (candidate.name.Equals(assetName, StringComparison.OrdinalIgnoreCase))
{
prefab = candidate;
return true;
}
string keyText = pair.Key != null ? pair.Key.ToString() : string.Empty;
if (!string.IsNullOrEmpty(keyText) && keyText.Equals(assetName, StringComparison.OrdinalIgnoreCase))
{
prefab = candidate;
return true;
}
}
return false;
}
}
}
@@ -0,0 +1,903 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using UnityEngine;
using Gallop.Live;
namespace Gallop.Live.Cyalume
{
[Serializable]
public class CyalumePlaybackData
{
public int DataNo;
public int PatternId;
public float StartTime;
public float PlaySpeed = 1f;
public int ChoreographyType;
}
[Serializable]
public class CyalumePatternDefinition
{
public int PatternId;
public string MoveType;
public string ColorPattern;
public string[] Colors = Array.Empty<string>();
public string[] Widths = Array.Empty<string>();
public string Signature;
}
[Serializable]
public class CyalumePartFrame
{
public float StartTime;
public int[] States = new int[7];
public float[] Volumes = new float[7];
public float[] Pans = new float[7];
public float VolumeRate = 1f;
public int GetState(int zoneIndex)
{
if (States == null || zoneIndex < 0 || zoneIndex >= States.Length)
return 0;
return States[zoneIndex];
}
public bool IsActive(int zoneIndex)
{
return GetState(zoneIndex) != 0;
}
}
[DisallowMultipleComponent]
public class CyalumePlaybackProvider : MonoBehaviour
{
[Header("Loaded playback data used by the controller.")]
public List<CyalumePlaybackData> data = new List<CyalumePlaybackData>();
[Header("Debug: unique choreography pattern definitions built from the CSV.")]
public List<CyalumePatternDefinition> patternDefinitions = new List<CyalumePatternDefinition>();
[Header("Loaded part data.")]
public List<CyalumePartFrame> partFrames = new List<CyalumePartFrame>();
[Header("Auto-load mXXXX_cyalume.csv for the current live.")]
public bool autoLoadForCurrentLive = true;
public bool preferResourcesCsv = true;
public bool tryLoadBundleEntryFromIndex = true;
public bool verboseLog = true;
public float bpmReference = 140f;
public bool keepPreviousPatternOnPause = true;
[SerializeField] private int _loadedMusicId;
[SerializeField] private string _loadedCsvSource;
[SerializeField] private string _loadedPartCsvSource;
public int LoadedMusicId => _loadedMusicId;
public string LoadedCsvSource => _loadedCsvSource;
public string LoadedPartCsvSource => _loadedPartCsvSource;
private void Start()
{
if (!autoLoadForCurrentLive || _loadedMusicId > 0 || (data != null && data.Count > 0))
return;
if (Director.instance != null && Director.instance.live != null)
{
int resolvedMusicId = Director.instance.live.MusicId;
if (resolvedMusicId > 0)
InitializeForMusicId(resolvedMusicId);
}
}
public bool InitializeForMusicId(int musicId, bool forceReload = false)
{
if (musicId <= 0)
{
if (verboseLog)
Debug.LogWarning("[CyalumePlaybackProvider] InitializeForMusicId failed: invalid musicId.");
return false;
}
if (!forceReload && _loadedMusicId == musicId && data.Count > 0)
return true;
data.Clear();
patternDefinitions.Clear();
partFrames.Clear();
_loadedMusicId = musicId;
_loadedCsvSource = null;
_loadedPartCsvSource = null;
if (!TryLoadCsvText(musicId, out string csvText, out string sourceName) || string.IsNullOrEmpty(csvText))
{
if (verboseLog)
Debug.LogWarning($"[CyalumePlaybackProvider] Failed to resolve cyalume CSV for musicId={musicId}.");
return false;
}
_loadedCsvSource = sourceName;
BuildPlaybackDataFromCsv(csvText);
if (TryLoadPartCsvText(musicId, out string partCsvText, out string partSource) && !string.IsNullOrEmpty(partCsvText))
{
_loadedPartCsvSource = partSource;
BuildPartFramesFromCsv(partCsvText);
if (verboseLog)
Debug.Log($"[CyalumePlaybackProvider] Loaded part CSV: musicId={musicId}, source={partSource}, rows={partFrames.Count}");
}
if (verboseLog)
{
Debug.Log($"[CyalumePlaybackProvider] Loaded cyalume CSV: musicId={musicId}, source={sourceName}, rows={data.Count}, uniquePatterns={patternDefinitions.Count}");
}
return data.Count > 0;
}
public bool TryGetCurrent(float liveTime, out CyalumePlaybackData current)
{
current = null;
if (data == null || data.Count == 0)
return false;
for (int i = 0; i < data.Count; i++)
{
var item = data[i];
if (item == null)
continue;
if (item.StartTime <= liveTime)
current = item;
else
break;
}
return current != null;
}
public bool TryGetCurrentPartFrame(float liveTime, out CyalumePartFrame current)
{
current = null;
if (partFrames == null || partFrames.Count == 0)
return false;
for (int i = 0; i < partFrames.Count; i++)
{
var item = partFrames[i];
if (item == null)
continue;
if (item.StartTime <= liveTime)
current = item;
else
break;
}
return current != null;
}
public bool TryGetCurrentState(float liveTime, out int patternId, out float patternStartTime, out float playSpeed, out int choreographyType)
{
if (TryGetCurrent(liveTime, out var current) && current != null)
{
patternId = Mathf.Max(0, current.PatternId);
patternStartTime = current.StartTime;
playSpeed = current.PlaySpeed > 0f ? current.PlaySpeed : 1f;
choreographyType = current.ChoreographyType;
return true;
}
patternId = 0;
patternStartTime = 0f;
playSpeed = 1f;
choreographyType = 0;
return false;
}
public bool TryGetPatternDefinition(int patternId, out CyalumePatternDefinition definition)
{
definition = null;
if (patternDefinitions == null || patternDefinitions.Count == 0)
return false;
for (int i = 0; i < patternDefinitions.Count; i++)
{
var item = patternDefinitions[i];
if (item != null && item.PatternId == patternId)
{
definition = item;
return true;
}
}
return false;
}
public bool IsRandomPattern(int patternId)
{
return TryGetPatternDefinition(patternId, out var definition) &&
!string.IsNullOrEmpty(definition.ColorPattern) &&
definition.ColorPattern.StartsWith("Random", StringComparison.OrdinalIgnoreCase);
}
public static string BuildPrimaryLiveKey(int musicId)
{
return $"m{musicId}";
}
public static string[] BuildLiveKeyCandidates(int musicId)
{
var keys = new List<string>();
string raw = $"m{musicId}";
keys.Add(raw);
string padded4 = $"m{musicId:D4}";
if (!keys.Any(x => string.Equals(x, padded4, StringComparison.OrdinalIgnoreCase)))
{
keys.Add(padded4);
}
return keys.ToArray();
}
private bool TryLoadCsvText(int musicId, out string csvText, out string sourceName)
{
csvText = null;
sourceName = null;
string[] liveKeys = BuildLiveKeyCandidates(musicId);
string[] resourcePaths = BuildResourcePathCandidates(liveKeys);
if (preferResourcesCsv)
{
for (int i = 0; i < resourcePaths.Length; i++)
{
string resourcePath = resourcePaths[i];
if (string.IsNullOrEmpty(resourcePath))
continue;
TextAsset asset = null;
try
{
asset = Resources.Load<TextAsset>(resourcePath);
}
catch
{
asset = null;
}
if (asset != null && !string.IsNullOrEmpty(asset.text))
{
csvText = asset.text;
sourceName = $"Resources/{resourcePath}";
return true;
}
}
}
if (TryFindCsvInLoadedBundles(liveKeys, out csvText, out sourceName))
return true;
if (tryLoadBundleEntryFromIndex)
{
TryLoadCsvBundlesFromIndex(liveKeys);
if (TryFindCsvInLoadedBundles(liveKeys, out csvText, out sourceName))
return true;
}
return false;
}
private bool TryLoadPartCsvText(int musicId, out string csvText, out string sourceName)
{
csvText = null;
sourceName = null;
string[] liveKeys = BuildLiveKeyCandidates(musicId);
for (int i = 0; i < liveKeys.Length; i++)
{
string liveKey = liveKeys[i];
string[] candidates =
{
$"live/musicscores/{liveKey}/{liveKey}_part",
$"live/musicscores/{liveKey}/{liveKey}_part.csv",
$"{liveKey}_part"
};
for (int j = 0; j < candidates.Length; j++)
{
TextAsset asset = null;
try
{
asset = Resources.Load<TextAsset>(candidates[j]);
}
catch
{
asset = null;
}
if (asset != null && !string.IsNullOrEmpty(asset.text))
{
csvText = asset.text;
sourceName = $"Resources/{candidates[j]}";
return true;
}
}
}
if (TryFindAuxCsvInLoadedBundles(liveKeys, "part", out csvText, out sourceName))
return true;
if (tryLoadBundleEntryFromIndex)
{
TryLoadAuxCsvBundlesFromIndex(liveKeys, "part");
if (TryFindAuxCsvInLoadedBundles(liveKeys, "part", out csvText, out sourceName))
return true;
}
return false;
}
private static string[] BuildResourcePathCandidates(string[] liveKeys)
{
var candidates = new List<string>();
for (int i = 0; i < liveKeys.Length; i++)
{
string liveKey = liveKeys[i];
candidates.Add($"live/musicscores/{liveKey}/{liveKey}_cyalume");
candidates.Add($"live/musicscores/{liveKey}/{liveKey}_cyalume.csv");
candidates.Add($"{liveKey}_cyalume");
}
return candidates.Distinct(StringComparer.OrdinalIgnoreCase).ToArray();
}
private void TryLoadCsvBundlesFromIndex(string[] liveKeys)
{
var main = FindObjectOfType<UmaViewerMain>();
if (main == null || main.AbList == null)
return;
var entries = main.AbList.Values
.Where(e => e != null && !string.IsNullOrEmpty(e.Name))
.Where(e => liveKeys.Any(liveKey =>
e.Name.IndexOf($"live/musicscores/{liveKey}/", StringComparison.OrdinalIgnoreCase) >= 0 &&
e.Name.IndexOf($"{liveKey}_cyalume", StringComparison.OrdinalIgnoreCase) >= 0))
.ToList();
foreach (var entry in entries)
{
try
{
UmaAssetManager.LoadAssetBundle(entry, neverUnload: true, isRecursive: true);
}
catch (Exception ex)
{
if (verboseLog)
{
Debug.LogWarning($"[CyalumePlaybackProvider] Failed to load CSV bundle entry {entry?.Name}: {ex.Message}");
}
}
}
}
private void TryLoadAuxCsvBundlesFromIndex(string[] liveKeys, string suffix)
{
var main = FindObjectOfType<UmaViewerMain>();
if (main == null || main.AbList == null)
return;
var entries = main.AbList.Values
.Where(e => e != null && !string.IsNullOrEmpty(e.Name))
.Where(e => liveKeys.Any(liveKey =>
e.Name.IndexOf($"live/musicscores/{liveKey}/", StringComparison.OrdinalIgnoreCase) >= 0 &&
e.Name.IndexOf($"{liveKey}_{suffix}", StringComparison.OrdinalIgnoreCase) >= 0))
.ToList();
foreach (var entry in entries)
{
try
{
UmaAssetManager.LoadAssetBundle(entry, neverUnload: true, isRecursive: true);
}
catch (Exception ex)
{
if (verboseLog)
{
Debug.LogWarning($"[CyalumePlaybackProvider] Failed to load {suffix} CSV bundle entry {entry?.Name}: {ex.Message}");
}
}
}
}
private bool TryFindCsvInLoadedBundles(string[] liveKeys, out string csvText, out string sourceName)
{
csvText = null;
sourceName = null;
foreach (var assetBundle in AssetBundle.GetAllLoadedAssetBundles())
{
if (!assetBundle)
continue;
string[] names;
try
{
names = assetBundle.GetAllAssetNames();
}
catch
{
continue;
}
if (names == null || names.Length == 0)
continue;
for (int i = 0; i < names.Length; i++)
{
string path = names[i];
if (!IsMatchingCyalumeCsvPath(path, liveKeys))
continue;
TextAsset textAsset = null;
try
{
textAsset = assetBundle.LoadAsset<TextAsset>(path);
}
catch
{
textAsset = null;
}
if (textAsset == null || string.IsNullOrEmpty(textAsset.text))
continue;
csvText = textAsset.text;
sourceName = path;
return true;
}
}
return false;
}
private bool TryFindAuxCsvInLoadedBundles(string[] liveKeys, string suffix, out string csvText, out string sourceName)
{
csvText = null;
sourceName = null;
foreach (var assetBundle in AssetBundle.GetAllLoadedAssetBundles())
{
if (!assetBundle)
continue;
string[] names;
try
{
names = assetBundle.GetAllAssetNames();
}
catch
{
continue;
}
if (names == null || names.Length == 0)
continue;
for (int i = 0; i < names.Length; i++)
{
string path = names[i];
if (!IsMatchingAuxCsvPath(path, liveKeys, suffix))
continue;
TextAsset textAsset = null;
try
{
textAsset = assetBundle.LoadAsset<TextAsset>(path);
}
catch
{
textAsset = null;
}
if (textAsset == null || string.IsNullOrEmpty(textAsset.text))
continue;
csvText = textAsset.text;
sourceName = path;
return true;
}
}
return false;
}
private static bool IsMatchingCyalumeCsvPath(string path, string[] liveKeys)
{
if (string.IsNullOrEmpty(path) || liveKeys == null || liveKeys.Length == 0)
return false;
string normalized = path.Replace('\\', '/');
for (int i = 0; i < liveKeys.Length; i++)
{
string liveKey = liveKeys[i];
if (normalized.IndexOf($"live/musicscores/{liveKey}/", StringComparison.OrdinalIgnoreCase) < 0)
continue;
string fileName = Path.GetFileNameWithoutExtension(normalized);
if (string.Equals(fileName, $"{liveKey}_cyalume", StringComparison.OrdinalIgnoreCase))
return true;
}
return false;
}
private static bool IsMatchingAuxCsvPath(string path, string[] liveKeys, string suffix)
{
if (string.IsNullOrEmpty(path) || liveKeys == null || liveKeys.Length == 0)
return false;
string normalized = path.Replace('\\', '/');
for (int i = 0; i < liveKeys.Length; i++)
{
string liveKey = liveKeys[i];
if (normalized.IndexOf($"live/musicscores/{liveKey}/", StringComparison.OrdinalIgnoreCase) < 0)
continue;
string fileName = Path.GetFileNameWithoutExtension(normalized);
if (string.Equals(fileName, $"{liveKey}_{suffix}", StringComparison.OrdinalIgnoreCase))
return true;
}
return false;
}
private void BuildPlaybackDataFromCsv(string csvText)
{
data.Clear();
patternDefinitions.Clear();
if (string.IsNullOrEmpty(csvText))
return;
var rows = ParseCsvRows(csvText);
if (rows.Count <= 1)
return;
var headerMap = BuildHeaderMap(rows[0]);
if (!headerMap.ContainsKey("time"))
{
if (verboseLog)
Debug.LogWarning("[CyalumePlaybackProvider] CSV missing time column.");
return;
}
var patternMap = new Dictionary<string, int>(StringComparer.Ordinal);
int lastVisualPatternId = 0;
bool hasLastVisualPattern = false;
for (int rowIndex = 1; rowIndex < rows.Count; rowIndex++)
{
var row = rows[rowIndex];
if (row == null || row.Count == 0)
continue;
if (!TryParseTimeSeconds(GetCell(row, headerMap, "time"), out float startTime))
continue;
string moveType = NormalizeCell(GetCell(row, headerMap, "move_type"));
string colorPattern = NormalizeCell(GetCell(row, headerMap, "color_pattern"));
string[] colors = GetSeriesCells(row, headerMap, "color", 5);
string[] widths = GetSeriesCells(row, headerMap, "width", 5);
int choreographyType = ComputeChoreographyType(moveType);
bool isPauseLike = keepPreviousPatternOnPause && choreographyType >= 8;
int patternId;
if (isPauseLike)
{
patternId = hasLastVisualPattern ? lastVisualPatternId : 0;
}
else
{
string signature = BuildPatternSignature(moveType, colorPattern, colors, widths);
if (!patternMap.TryGetValue(signature, out patternId))
{
patternId = patternMap.Count;
patternMap[signature] = patternId;
patternDefinitions.Add(new CyalumePatternDefinition
{
PatternId = patternId,
MoveType = moveType,
ColorPattern = colorPattern,
Colors = colors.ToArray(),
Widths = widths.ToArray(),
Signature = signature,
});
}
lastVisualPatternId = patternId;
hasLastVisualPattern = true;
}
float playSpeed = ComputePlaySpeed(GetCell(row, headerMap, "bpm"));
data.Add(new CyalumePlaybackData
{
DataNo = data.Count,
PatternId = patternId,
StartTime = startTime,
PlaySpeed = playSpeed,
ChoreographyType = choreographyType,
});
}
data.Sort((a, b) => a.StartTime.CompareTo(b.StartTime));
for (int i = 0; i < data.Count; i++)
{
data[i].DataNo = i;
}
}
private void BuildPartFramesFromCsv(string csvText)
{
partFrames.Clear();
if (string.IsNullOrEmpty(csvText))
return;
var rows = ParseCsvRows(csvText);
if (rows.Count <= 1)
return;
var headerMap = BuildHeaderMap(rows[0]);
if (!headerMap.ContainsKey("time"))
{
if (verboseLog)
Debug.LogWarning("[CyalumePlaybackProvider] Part CSV missing time column.");
return;
}
string[] zoneNames = { "left3", "left2", "left", "center", "right", "right2", "right3" };
for (int rowIndex = 1; rowIndex < rows.Count; rowIndex++)
{
var row = rows[rowIndex];
if (row == null || row.Count == 0)
continue;
if (!TryParseTimeSeconds(GetCell(row, headerMap, "time"), out float startTime))
continue;
var frame = new CyalumePartFrame();
frame.StartTime = startTime;
for (int i = 0; i < zoneNames.Length; i++)
{
frame.States[i] = ParseIntOrDefault(GetCell(row, headerMap, zoneNames[i]), 0);
frame.Volumes[i] = ParseFloatOrSentinel(GetCell(row, headerMap, zoneNames[i] + "_vol"));
frame.Pans[i] = ParseFloatOrSentinel(GetCell(row, headerMap, zoneNames[i] + "_pan"));
}
frame.VolumeRate = ParseFloatOrDefault(GetCell(row, headerMap, "volume_rate"), 1f);
partFrames.Add(frame);
}
partFrames.Sort((a, b) => a.StartTime.CompareTo(b.StartTime));
}
private static Dictionary<string, int> BuildHeaderMap(List<string> header)
{
var map = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
if (header == null)
return map;
for (int i = 0; i < header.Count; i++)
{
string key = NormalizeHeader(header[i]);
if (string.IsNullOrEmpty(key) || map.ContainsKey(key))
continue;
map[key] = i;
}
return map;
}
private static string NormalizeHeader(string value)
{
return (value ?? string.Empty).Trim().Trim('\ufeff').ToLowerInvariant();
}
private static string GetCell(List<string> row, Dictionary<string, int> headerMap, string key)
{
if (row == null || headerMap == null || !headerMap.TryGetValue(key, out int index))
return null;
return index >= 0 && index < row.Count ? row[index] : null;
}
private static string[] GetSeriesCells(List<string> row, Dictionary<string, int> headerMap, string prefix, int count)
{
var values = new string[count];
for (int i = 0; i < count; i++)
{
values[i] = NormalizeCell(GetCell(row, headerMap, $"{prefix}{i + 1}"));
}
return values;
}
private static string NormalizeCell(string value)
{
return (value ?? string.Empty).Trim();
}
private static string BuildPatternSignature(string moveType, string colorPattern, string[] colors, string[] widths)
{
var parts = new List<string>(2 + (colors?.Length ?? 0) + (widths?.Length ?? 0))
{
NormalizeSignatureValue(moveType),
NormalizeSignatureValue(colorPattern)
};
if (colors != null)
{
for (int i = 0; i < colors.Length; i++)
parts.Add(NormalizeSignatureValue(colors[i]));
}
if (widths != null)
{
for (int i = 0; i < widths.Length; i++)
parts.Add(NormalizeSignatureValue(widths[i]));
}
return string.Join("|", parts);
}
private static string NormalizeSignatureValue(string value)
{
return string.IsNullOrWhiteSpace(value) ? "-" : value.Trim().ToLowerInvariant();
}
private bool TryParseTimeSeconds(string rawValue, out float seconds)
{
seconds = 0f;
if (string.IsNullOrWhiteSpace(rawValue))
return false;
rawValue = rawValue.Trim();
if (int.TryParse(rawValue, out int milliseconds))
{
seconds = milliseconds / 1000f;
return true;
}
if (float.TryParse(rawValue, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out float value))
{
seconds = value > 1000f ? (value / 1000f) : value;
return true;
}
return false;
}
private float ComputePlaySpeed(string bpmValue)
{
float fallback = 1f;
if (string.IsNullOrWhiteSpace(bpmValue))
return fallback;
if (!float.TryParse(bpmValue, System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out float bpm))
return fallback;
if (bpm <= 0f)
return fallback;
float reference = bpmReference > 0f ? bpmReference : 140f;
return Mathf.Max(0.01f, bpm / reference);
}
private static int ComputeChoreographyType(string moveType)
{
if (string.IsNullOrWhiteSpace(moveType))
return 0;
return string.Equals(moveType.Trim(), "Pause", StringComparison.OrdinalIgnoreCase) ? 8 : 0;
}
private static int ParseIntOrDefault(string raw, int fallback)
{
if (string.IsNullOrWhiteSpace(raw))
return fallback;
return int.TryParse(raw.Trim(), out int value) ? value : fallback;
}
private static float ParseFloatOrDefault(string raw, float fallback)
{
if (string.IsNullOrWhiteSpace(raw))
return fallback;
return float.TryParse(raw.Trim(), System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out float value)
? value
: fallback;
}
private static float ParseFloatOrSentinel(string raw)
{
float value = ParseFloatOrDefault(raw, 999f);
return Mathf.Approximately(value, 999f) ? float.NaN : value;
}
private static List<List<string>> ParseCsvRows(string text)
{
var rows = new List<List<string>>();
if (string.IsNullOrEmpty(text))
return rows;
var row = new List<string>();
var cell = new StringBuilder();
bool inQuotes = false;
for (int i = 0; i < text.Length; i++)
{
char c = text[i];
if (inQuotes)
{
if (c == '"')
{
bool escapedQuote = i + 1 < text.Length && text[i + 1] == '"';
if (escapedQuote)
{
cell.Append('"');
i++;
}
else
{
inQuotes = false;
}
}
else
{
cell.Append(c);
}
continue;
}
switch (c)
{
case '"':
inQuotes = true;
break;
case ',':
row.Add(cell.ToString());
cell.Length = 0;
break;
case '\r':
break;
case '\n':
row.Add(cell.ToString());
cell.Length = 0;
rows.Add(row);
row = new List<string>();
break;
default:
cell.Append(c);
break;
}
}
row.Add(cell.ToString());
if (row.Count > 1 || !string.IsNullOrEmpty(row[0]))
rows.Add(row);
return rows;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 2acb0c9d0ca50ba45b7d9a2ae70d860d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,140 @@
using UnityEngine;
namespace Gallop.Cyalume
{
[AddComponentMenu("")]
public class MobShadowController : MonoBehaviour
{
private static readonly int MobGroupMatrixPropertyId = Shader.PropertyToID("_MobGroupMatrix");
[SerializeField] private string _mobColorPropertyName = "_MobColor";
[SerializeField] private string _maskPosArrayPropertyName = "_MaskPosArray";
[SerializeField] private string _groupMatrixArrayPropertyName = "_MobGroupMatrix";
private GameObject _targetObject;
private Transform _targetObjectTransform;
private Vector3 _targetObjectInitPosition;
private bool _isInitialized;
private Renderer[] _renderers;
private Color _mobColor;
private Color _ambientColor;
private int _mobColorPropertyId;
private int _maskPosArrayPropertyId;
private int _groupMatrixArrayPropertyId;
private Vector4[] _maskPosArray;
private Matrix4x4[] _groupMatrixArray;
private bool _isUpdateGroupMatrix;
public Vector4[] MaskPosArray
{
set
{
_maskPosArray = value;
}
}
public MobShadowController()
{
_mobColor = Color.white;
_ambientColor = Color.white;
_mobColorPropertyId = -1;
_maskPosArrayPropertyId = -1;
_groupMatrixArrayPropertyId = -1;
_groupMatrixArray = new Matrix4x4[11];
}
public void Initialize(GameObject targetObject)
{
_mobColorPropertyId = string.IsNullOrEmpty(_mobColorPropertyName) ? -1 : Shader.PropertyToID(_mobColorPropertyName);
_maskPosArrayPropertyId = string.IsNullOrEmpty(_maskPosArrayPropertyName) ? -1 : Shader.PropertyToID(_maskPosArrayPropertyName);
_groupMatrixArrayPropertyId = string.IsNullOrEmpty(_groupMatrixArrayPropertyName) ? -1 : Shader.PropertyToID(_groupMatrixArrayPropertyName);
_targetObject = targetObject;
if (_targetObject == null)
return;
_targetObjectTransform = _targetObject.transform;
if (_targetObjectTransform == null)
return;
_targetObjectInitPosition = _targetObjectTransform.position;
_renderers = _targetObject.GetComponentsInChildren<Renderer>();
if (_groupMatrixArray == null)
_groupMatrixArray = new Matrix4x4[11];
for (int i = 0; i < _groupMatrixArray.Length; i++)
{
_groupMatrixArray[i] = Matrix4x4.identity;
}
_isUpdateGroupMatrix = true;
_isInitialized = true;
}
public void SetMobColor(Color color)
{
_mobColor = color;
_UpdateShaderParamColor();
}
public void SetAmbientColor(Color color)
{
_ambientColor = color;
_UpdateShaderParamColor();
}
public void SetGroupTRS(int index, ref Vector3 position, ref Quaternion rotation, ref Vector3 scale)
{
if (!_isInitialized)
return;
if (_groupMatrixArray == null)
return;
if ((uint)index >= (uint)_groupMatrixArray.Length)
return;
_groupMatrixArray[index].SetTRS(position, rotation, scale);
_isUpdateGroupMatrix = true;
}
public void FlushGroupMatrix()
{
if (!_isInitialized || !_isUpdateGroupMatrix || _groupMatrixArray == null)
return;
_isUpdateGroupMatrix = false;
int propertyId = _groupMatrixArrayPropertyId >= 0
? _groupMatrixArrayPropertyId
: MobGroupMatrixPropertyId;
Shader.SetGlobalMatrixArray(propertyId, _groupMatrixArray);
}
private void _UpdateShaderParamColor()
{
if (_renderers == null)
return;
Color finalColor = new Color(
_ambientColor.r * _mobColor.r,
_ambientColor.g * _mobColor.g,
_ambientColor.b * _mobColor.b,
_ambientColor.a * _mobColor.a
);
if (_mobColorPropertyId >= 0)
Shader.SetGlobalColor(_mobColorPropertyId, finalColor);
if (_maskPosArray != null && _maskPosArrayPropertyId >= 0)
Shader.SetGlobalVectorArray(_maskPosArrayPropertyId, _maskPosArray);
}
private void OnDestroy()
{
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ab50ba8b988b5cd43823243f52c6136b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -6,6 +6,8 @@ using System.Linq;
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;
using Gallop.Live.Cyalume;
using Gallop.RenderPipeline;
namespace Gallop.Live
{
@@ -54,6 +56,9 @@ namespace Gallop.Live
private Dictionary<string, Vector2> _uvScrollAccum = new Dictionary<string, Vector2>();
private Volume _postProcessVolume;
private readonly Dictionary<int, GameObject> _liveProps = new Dictionary<int, GameObject>();
private readonly Dictionary<LiveTimelineAdditionalLight, Light> _additionalLights = new Dictionary<LiveTimelineAdditionalLight, Light>();
private readonly Dictionary<LiveTimelineLightProjectionData, Light> _projectionLights = new Dictionary<LiveTimelineLightProjectionData, Light>();
public UmaViewerAudio.CuteAudioSource liveMusic = new UmaViewerAudio.CuteAudioSource();
@@ -80,6 +85,12 @@ namespace Gallop.Live
public Transform MainCameraTransform => _mainCameraTransform;
private Transform _mainCameraTransform;
private bool _playbackPaused;
private bool _freeCameraActive;
private FreeCam _freeCamera;
public bool PlaybackPaused => _playbackPaused;
public bool FreeCameraActive => _freeCameraActive;
public bool isTimelineControlled
{
@@ -111,6 +122,10 @@ namespace Gallop.Live
if (live != null)
{
_instance = this;
if (GetComponent<SkirtCollisionRuntimeTuner>() == null)
gameObject.AddComponent<SkirtCollisionRuntimeTuner>();
if (GetComponent<LivePlaybackToolbar>() == null)
gameObject.AddComponent<LivePlaybackToolbar>();
Debug.Log(string.Format(CUTT_PATH, live.MusicId));
Builder.LoadAssetPath(string.Format(CUTT_PATH, live.MusicId), transform);
if (RequireStage)
@@ -119,6 +134,8 @@ namespace Gallop.Live
Builder.LoadAssetPath(string.Format(STAGE_PATH, live.BackGroundId), transform);
_liveTimelineControl.StageObjectMap = _stageController.StageObjectMap;
_liveTimelineControl.StageObjectUnitMap = _stageController.StageObjectUnitMap;
InitializeCyalumeStage();
InitializeStageMonitor();
}
//Make CharacterObject
@@ -231,6 +248,14 @@ namespace Gallop.Live
_liveTimelineControl.OnUpdateChromaticAberration += OnChromaticAberrationUpdate;
_liveTimelineControl.OnUpdateColorCorrection += OnColorCorrectionUpdate;
_liveTimelineControl.OnUpdatePostFilm += OnPostFilmUpdate;
_liveTimelineControl.OnUpdateLensFlare += OnLensFlareUpdate;
_liveTimelineControl.OnUpdateEnvironment += OnEnvironmentUpdate;
_liveTimelineControl.OnUpdateFacialToon += OnFacialToonUpdate;
_liveTimelineControl.OnUpdateProps += OnPropsUpdate;
_liveTimelineControl.OnUpdatePropsAttach += OnPropsAttachUpdate;
_liveTimelineControl.OnUpdateCharaFootLight += OnCharaFootLightUpdate;
_liveTimelineControl.OnUpdateAdditionalLight += OnAdditionalLightUpdate;
_liveTimelineControl.OnUpdateLightProjection += OnLightProjectionUpdate;
PostFilmRendererFeature.ResetLayers();
// 获取或创建摄像机上的 Volume 组件,供后处理 handler 使用
@@ -252,6 +277,39 @@ namespace Gallop.Live
}
}
private void InitializeCyalumeStage()
{
if (_stageController == null) return;
foreach (var holder in _stageController.GetComponentsInChildren<AssetHolder>(true))
{
if (holder == null || holder._assetTable == null || holder._assetTable.list == null) continue;
bool hasDefault = false;
bool hasRandom = false;
foreach (var entry in holder._assetTable.list)
{
if (string.Equals(entry.Key, "default", StringComparison.OrdinalIgnoreCase)) hasDefault = true;
if (string.Equals(entry.Key, "random", StringComparison.OrdinalIgnoreCase)) hasRandom = true;
}
if (!hasDefault && !hasRandom) continue;
var controller = holder.GetComponent<CyalumeController3D>();
if (controller == null) controller = holder.gameObject.AddComponent<CyalumeController3D>();
controller.StartOfficialLikeSetup();
return;
}
Debug.Log("[Cyalume] no audience AssetHolder was present on this stage.");
}
private void InitializeStageMonitor()
{
if (_stageController == null) return;
if (_stageController.GetComponent<StageMonitorDriver>() == null)
_stageController.gameObject.AddComponent<StageMonitorDriver>();
}
private void OnEffectUpdate(LiveTimelineEffectData effectData, LiveTimelineKeyEffectData keyData)
{
if (keyData == null) return;
@@ -280,6 +338,195 @@ namespace Gallop.Live
_activeEffects[effectData] = (keyData.frame, instance);
}
private void OnLensFlareUpdate(LiveTimelineLensFlareData data, LiveTimelineKeyLensFlareData key)
{
if (key == null || _stageController == null) return;
bool enabled = key.enableParameter == 0 || key.enableFlare != 0;
foreach (var flare in _stageController.GetComponentsInChildren<CustomLensFlare>(true))
{
if (flare == null) continue;
flare.ApplyTimeline(enabled,
key.IsOverridePosition != 0 ? key.offset : flare.transform.localPosition,
key.color, key.brightness, key.fadeSpeed);
}
}
private void OnEnvironmentUpdate(LiveTimelineStageEnvironmentData data, LiveTimelineKeyStageEnvironmentData key)
{
if (key == null || _stageController == null) return;
foreach (var mirror in _stageController.GetComponentsInChildren<MirrorReflection>(true))
mirror.ApplyTimeline(key.MirrorIsValid, key.MirrorEnabled, key.ReflectionRate);
}
private void OnFacialToonUpdate(int characterIndex, LiveTimelineKeyFacialToonData key)
{
if (key == null || characterIndex < 0 || characterIndex >= CharaContainerScript.Count) return;
var container = CharaContainerScript[characterIndex];
if (container == null) return;
var block = PropBlock;
foreach (var renderer in container.GetComponentsInChildren<Renderer>(true))
{
if (renderer == null) continue;
renderer.GetPropertyBlock(block);
foreach (var material in renderer.sharedMaterials)
{
if (material == null) continue;
if (material.HasProperty("_CheekPretenseThreshold")) block.SetFloat("_CheekPretenseThreshold", key.CheekPretenseThreshold);
if (material.HasProperty("_NosePretenseThreshold")) block.SetFloat("_NosePretenseThreshold", key.NosePretenseThreshold);
if (material.HasProperty("_CylinderBlend")) block.SetFloat("_CylinderBlend", key.CylinderBlend);
if (material.HasProperty("_HairNormalBlend")) block.SetFloat("_HairNormalBlend", key.HairNormalBlend);
if (material.HasProperty("_UseOriginalDirectionalLight")) block.SetFloat("_UseOriginalDirectionalLight", key.UseOriginalDirectionalLight);
if (material.HasProperty("_OriginalDirectionalLightDir")) block.SetVector("_OriginalDirectionalLightDir", key.OriginalDirectionalLightDir);
if (material.HasProperty("_EyeToonStep")) block.SetFloat("_EyeToonStep", key.EyeToonStep);
if (material.HasProperty("_EyeToonFeather")) block.SetFloat("_EyeToonFeather", key.EyeToonFeather);
if (material.HasProperty("_EyeSaturation")) block.SetFloat("_EyeSaturation", key.EyeSaturation);
}
renderer.SetPropertyBlock(block);
}
}
private GameObject ResolveLiveProp(int propsId)
{
if (_liveProps.TryGetValue(propsId, out var existing) && existing != null) return existing;
var settings = _liveTimelineControl?.data?.propsSettings;
if (settings?.propsDataGroup == null || propsId < 0 || propsId >= settings.propsDataGroup.Length) return null;
var group = settings.propsDataGroup[propsId];
if (group == null || string.IsNullOrEmpty(group.propsName) || !UmaViewerMain.Instance.AbList.TryGetValue(group.propsName, out var entry)) return null;
var prefab = entry.Get<GameObject>();
if (prefab == null) return null;
var instance = Instantiate(prefab, transform);
instance.name = prefab.name;
_liveProps[propsId] = instance;
return instance;
}
private void OnPropsUpdate(LiveTimelinePropsData data, LiveTimelineKeyPropsData key)
{
if (key == null) return;
var prop = ResolveLiveProp(key.propsID);
if (prop == null) return;
foreach (var renderer in prop.GetComponentsInChildren<Renderer>(true))
{
renderer.enabled = key.rendererEnable != 0;
var block = PropBlock;
renderer.GetPropertyBlock(block);
foreach (var material in renderer.sharedMaterials)
{
if (material == null) continue;
if (material.HasProperty("_Color")) block.SetColor("_Color", key.color);
if (material.HasProperty("_RootColor")) block.SetColor("_RootColor", key.rootColor);
if (material.HasProperty("_TipColor")) block.SetColor("_TipColor", key.tipColor);
if (material.HasProperty("_ColorPower")) block.SetFloat("_ColorPower", key.colorPower);
if (key.IsUpdateOutline != 0 && material.HasProperty("_OutlineWidth")) block.SetFloat("_OutlineWidth", key.OutlineWidth);
if (key.IsUpdateOutline != 0 && material.HasProperty("_OutlineColor")) block.SetColor("_OutlineColor", key.OutlineColor);
if (key.IsEmissive != 0 && material.HasProperty("_EmissiveColor")) block.SetColor("_EmissiveColor", key.EmissiveColor);
}
renderer.SetPropertyBlock(block);
}
}
private void OnPropsAttachUpdate(LiveTimelinePropsAttachData data, LiveTimelineKeyPropsAttachData key)
{
if (key == null) return;
var prop = ResolveLiveProp(key._propsId);
if (prop == null) return;
Transform target = null;
for (int i = 0; i < _liveTimelineControl.liveCharactorLocators.Length && target == null; i++)
{
if (_liveTimelineControl.liveCharactorLocators[i] is LiveTimelineCharaLocator locator &&
locator.Bones != null && locator.Bones.TryGetValue(key._attachJointName, out var bone)) target = bone;
}
if (target == null) return;
prop.transform.SetParent(target, false);
prop.transform.localPosition = key._offsetPosition;
prop.transform.localRotation = Quaternion.Euler(key.OffsetRotate);
prop.transform.localScale = key.OffsetScale;
}
private void OnCharaFootLightUpdate(LiveTimelineKeyCharaFootLightData key)
{
if (key == null) return;
for (int i = 0; i < CharaContainerScript.Count; i++)
{
if (key.positionFlag != 0 && (key.positionFlag & (1 << i)) == 0) continue;
if (i >= key.lightColor.Length || i >= key.hightMax.Length) continue;
var container = CharaContainerScript[i];
if (container == null) continue;
float blend = i < key.LightBlendModeArray.Length ? key.LightBlendModeArray[i] : 0f;
float easing = i < key.EasingArray.Length ? key.EasingArray[i] : 0f;
var block = PropBlock;
foreach (var renderer in container.GetComponentsInChildren<Renderer>(true))
{
if (renderer == null) continue;
bool supported = false;
foreach (var material in renderer.sharedMaterials)
supported |= material != null && material.HasProperty("_HightLightColor") && material.HasProperty("_HightLightParam");
if (!supported) continue;
renderer.GetPropertyBlock(block);
block.SetColor("_HightLightColor", key.lightColor[i]);
block.SetVector("_HightLightParam", new Vector4(key.hightMax[i], blend, easing, 0f));
renderer.SetPropertyBlock(block);
}
}
}
private void OnAdditionalLightUpdate(LiveTimelineAdditionalLight data, LiveTimelineKeyData_AdditionalLight key)
{
if (data == null || key == null) return;
if (!_additionalLights.TryGetValue(data, out var light) || light == null)
{
var go = new GameObject(string.IsNullOrEmpty(data.name) ? "AdditionalLight" : data.name);
go.transform.SetParent(_stageController != null ? _stageController.transform : transform, false);
light = go.AddComponent<Light>();
_additionalLights[data] = light;
}
light.gameObject.SetActive(key.IsEnable != 0);
light.transform.localPosition = key.Position;
light.transform.localRotation = Quaternion.Euler(key.Rotate);
light.type = (LightType)Mathf.Clamp(key.Type, 0, 2);
light.range = Mathf.Max(0f, key.Range);
light.spotAngle = Mathf.Clamp(key.SpotAngle, 1f, 179f);
light.intensity = Mathf.Max(0f, key.Strength);
light.bounceIntensity = Mathf.Max(0f, key.IndirectMultiplier);
light.shadows = (LightShadows)Mathf.Clamp(key.ShadowType, 0, 2);
light.shadowStrength = Mathf.Clamp01(key.Strength);
light.shadowBias = Mathf.Max(0f, key.Bias);
light.shadowNormalBias = Mathf.Max(0f, key.NormalBias);
light.shadowNearPlane = Mathf.Max(0.01f, key.NearPlane);
}
private void OnLightProjectionUpdate(LiveTimelineLightProjectionData data, LiveTimelineKeyLightProjectionData key)
{
if (data == null || key == null || _stageController == null) return;
if (!_projectionLights.TryGetValue(data, out var light) || light == null)
{
CustomProjector authored = null;
foreach (var candidate in _stageController.GetComponentsInChildren<CustomProjector>(true))
{
if (candidate != null && string.Equals(candidate.name, data.name, StringComparison.OrdinalIgnoreCase))
{ authored = candidate; break; }
}
var host = authored != null ? authored.gameObject : new GameObject(data.name ?? "LightProjection");
if (authored == null) host.transform.SetParent(_stageController.transform, false);
light = host.GetComponent<Light>();
if (light == null) light = host.AddComponent<Light>();
light.type = LightType.Spot;
if (authored != null) light.cookie = authored.ProjectionTexture;
_projectionLights[data] = light;
}
light.gameObject.SetActive(key.IsEnable != 0);
light.transform.localPosition = key.Position;
light.transform.localRotation = Quaternion.Euler(key.Angle);
light.transform.localScale = key.Scale;
light.range = Mathf.Max(0.01f, key.FarClipPlane);
light.spotAngle = Mathf.Clamp(key.FieldOfView, 1f, 179f);
light.color = key.Color;
light.intensity = Mathf.Max(0f, key.ColorPower * key.BlinkLightBrightnessPower);
light.shadowNearPlane = Mathf.Max(0.01f, key.NearClipPlane);
if (key.OverrideIgnoreLayer != 0) light.cullingMask = key.OverrideLayerMask.value;
}
private static GameObject LoadEffectPrefab(string effectName)
{
string path = string.Format(EFFECT_PATH, effectName);
@@ -1311,9 +1558,15 @@ namespace Gallop.Live
private void UpdateMainCamera()
{
if (_cameraObjects == null) return;
if (_cameraObjects == null || _cameraNodes == null || _cameraTransforms == null) return;
if (_freeCameraActive && _freeCamera != null)
{
_mainCameraTransform = _freeCamera.transform;
return;
}
for (int i = 0; i < _cameraNodes.Length; i++)
{
if (_cameraNodes[i] == null) continue;
bool activeSelf = _cameraNodes[i].activeSelf;
bool flag = i == _activeCameraIndex;
_cameraNodes[i].SetActive(flag);
@@ -1322,7 +1575,77 @@ namespace Gallop.Live
_cameraLookAt.ActivationUpdate();
}
}
_mainCameraTransform = _cameraTransforms[_activeCameraIndex];
if (_activeCameraIndex >= 0 && _activeCameraIndex < _cameraTransforms.Length)
_mainCameraTransform = _cameraTransforms[_activeCameraIndex];
}
public void SetPlaybackPaused(bool paused)
{
if (_playbackPaused == paused || IsRecordVMD)
return;
_playbackPaused = paused;
SetAudioPaused(liveMusic, paused);
foreach (var vocal in liveVocal)
SetAudioPaused(vocal, paused);
}
public void SeekPlayback(float time)
{
_liveCurrentTime = Mathf.Clamp(time, 0f, Mathf.Max(0f, totalTime - 0.001f));
UI.ProgressBar.SetValueWithoutNotify(totalTime > 0f ? _liveCurrentTime / totalTime : 0f);
if (liveMusic != null && liveMusic.sourceList != null)
UmaViewerAudio.SetTime(liveMusic, _liveCurrentTime);
foreach (var vocal in liveVocal)
if (vocal != null && vocal.sourceList != null)
UmaViewerAudio.SetTime(vocal, _liveCurrentTime);
OnTimelineUpdate(_liveCurrentTime);
if (_liveTimelineControl != null)
_liveTimelineControl.AlterLateUpdate();
_syncTime = !_playbackPaused;
}
public void SetFreeCameraEnabled(bool enabled)
{
if (_freeCameraActive == enabled)
return;
if (_freeCamera == null)
_freeCamera = Resources.FindObjectsOfTypeAll<FreeCam>().FirstOrDefault(cam => cam.gameObject.scene.IsValid());
if (_freeCamera == null)
return;
_freeCameraActive = enabled;
if (enabled)
{
Transform source = _cameraTransforms != null && _activeCameraIndex >= 0 && _activeCameraIndex < _cameraTransforms.Length
? _cameraTransforms[_activeCameraIndex] : null;
if (source != null)
_freeCamera.transform.SetPositionAndRotation(source.position, source.rotation);
_freeCamera.gameObject.SetActive(true);
if (_cameraNodes != null)
{
for (int i = 0; i < _cameraNodes.Length; i++)
if (_cameraNodes[i] != null) _cameraNodes[i].SetActive(false);
}
_mainCameraTransform = _freeCamera.transform;
}
else
{
_freeCamera.gameObject.SetActive(false);
UpdateMainCamera();
}
}
private static void SetAudioPaused(UmaViewerAudio.CuteAudioSource source, bool paused)
{
if (source == null || source.sourceList == null)
return;
foreach (var audioSource in source.sourceList)
{
if (paused) audioSource.Pause();
else audioSource.UnPause();
}
}
private void SetupCharacterLocator()
@@ -1371,6 +1694,13 @@ namespace Gallop.Live
public void Play()
{
// Play can be invoked after scene/UI setup has been rebuilt; ensure the
// debugging overlay exists and is enabled for the whole playback.
LivePlaybackToolbar toolbar = GetComponent<LivePlaybackToolbar>();
if (toolbar == null)
toolbar = gameObject.AddComponent<LivePlaybackToolbar>();
toolbar.enabled = true;
foreach (var vocal in liveVocal)
{
UmaViewerAudio.Play(vocal);
@@ -1448,6 +1778,10 @@ namespace Gallop.Live
OnTimelineUpdate(_liveCurrentTime);
_liveTimelineControl.AlterLateUpdate();
}
else if (_playbackPaused)
{
// The toolbar can still seek while paused; the timeline itself remains frozen.
}
else if (sliderControl.is_Outed)
{
_liveCurrentTime = UI.ProgressBar.value * totalTime;
@@ -1612,4 +1946,4 @@ namespace Gallop.Live
}
}
}
}
@@ -0,0 +1,56 @@
using UnityEngine;
namespace Gallop.Live
{
/// <summary>Always-visible live debugging controls independent of the scene's auto-hidden UI.</summary>
public sealed class LivePlaybackToolbar : MonoBehaviour
{
private bool _draggingTimeline;
private void Update()
{
if (Director.instance == null || !Director.instance._isLiveSetup)
return;
if (Input.GetKeyDown(KeyCode.Space))
Director.instance.SetPlaybackPaused(!Director.instance.PlaybackPaused);
if (Input.GetKeyDown(KeyCode.F6))
Director.instance.SetFreeCameraEnabled(!Director.instance.FreeCameraActive);
}
private void OnGUI()
{
Director director = Director.instance;
if (director == null || !director._isLiveSetup)
return;
// Keep this overlay above the scene's Canvas/UI, which may cover the lower edge.
int oldDepth = GUI.depth;
GUI.depth = 1000;
GUILayout.BeginArea(new Rect(16, Mathf.Max(8, Screen.height - 68), 560, 52), GUI.skin.window);
GUILayout.BeginHorizontal();
if (GUILayout.Button(director.PlaybackPaused ? "Play" : "Pause", GUILayout.Width(62)))
director.SetPlaybackPaused(!director.PlaybackPaused);
bool freeCamera = GUILayout.Toggle(director.FreeCameraActive, "Free Camera", GUILayout.Width(105));
if (freeCamera != director.FreeCameraActive)
director.SetFreeCameraEnabled(freeCamera);
float time = GUILayout.HorizontalSlider(director._liveCurrentTime, 0f, Mathf.Max(0.01f, director.totalTime), GUILayout.Width(245));
Rect sliderRect = GUILayoutUtility.GetLastRect();
Event currentEvent = Event.current;
if (currentEvent.type == EventType.MouseDown && sliderRect.Contains(currentEvent.mousePosition))
_draggingTimeline = true;
if (_draggingTimeline && (currentEvent.type == EventType.MouseDrag || currentEvent.type == EventType.MouseDown))
director.SeekPlayback(time);
if (currentEvent.type == EventType.MouseUp)
_draggingTimeline = false;
GUILayout.Label(director._liveCurrentTime.ToString("0.00") + " / " + director.totalTime.ToString("0.00"), GUILayout.Width(84));
GUILayout.EndHorizontal();
GUILayout.EndArea();
GUI.depth = oldDepth;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 5307220bda8922d4ea2911ec6f0dd3e1
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: b86c3dce210ee2e4bbd36c6c48abfccb
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 5b3a700e79167fe49bb840bdda6f8d34
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,2 @@
// Kept intentionally empty so stale Unity-generated project files that referenced the
// superseded temporary driver remain buildable until Unity regenerates the project.
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 3049c310353c9934a8b7c3f938f32bfc
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 56c2bfe19657a4c4bb1708407b98fa28
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,357 @@
using System.Collections.Generic;
using UnityEngine;
namespace Gallop.Live
{
/// <summary>Runtime tuning for hair, cloth, tail, and accessory spring motion.</summary>
public sealed class SkirtCollisionRuntimeTuner : MonoBehaviour
{
private enum MotionCategory
{
Skirt,
Other
}
private sealed class ParticleDefaults
{
public DynamicBone.Particle Particle;
public MotionCategory Category;
public float Damping;
public float Elasticity;
public float Stiffness;
public float Inert;
}
private sealed class ColliderDefaults
{
public DynamicBoneCollider Collider;
public float Radius;
}
private readonly List<ParticleDefaults> _particles = new List<ParticleDefaults>();
private readonly HashSet<DynamicBone.Particle> _knownParticles = new HashSet<DynamicBone.Particle>();
private readonly List<ColliderDefaults> _hipColliders = new List<ColliderDefaults>();
private readonly HashSet<DynamicBoneCollider> _knownHipColliders = new HashSet<DynamicBoneCollider>();
private readonly List<ColliderDefaults> _legColliders = new List<ColliderDefaults>();
private readonly HashSet<DynamicBoneCollider> _knownLegColliders = new HashSet<DynamicBoneCollider>();
private bool _visible;
private bool _initialized;
private float _dampingScale = 3f;
private float _elasticityScale = 1.5f;
private float _stiffnessScale = 2f;
private float _inertScale = 1.1f;
private float _hipRadiusScale = 0.2f;
private float _legRadiusScale = 0.8f;
private float _horizontalStiffness = 0.5f;
private float _otherDampingScale = 3f;
private float _otherElasticityScale = 1.5f;
private float _otherStiffnessScale = 2f;
private float _otherInertScale = 1.1f;
private float _otherRigidity = 0.85f;
private float _monitorBrightness = 1f;
private bool _showSkirtColliders;
private bool _skirtBodyCollisionEnabled = true;
private void Update()
{
if (Input.GetKeyDown(KeyCode.F8))
_visible = !_visible;
if (Director.instance == null || Director.instance.CharaContainerScript.Count == 0)
return;
bool added = false;
bool addedCollider = false;
foreach (UmaContainerCharacter character in Director.instance.CharaContainerScript)
{
if (character == null)
continue;
foreach (DynamicBone dynamicBone in character.GetComponentsInChildren<DynamicBone>(true))
{
MotionCategory category = GetMotionCategory(dynamicBone);
foreach (DynamicBone.Particle particle in dynamicBone.Particles)
{
if (!_knownParticles.Add(particle))
continue;
_particles.Add(new ParticleDefaults
{
Particle = particle,
Category = category,
Damping = particle.m_Damping,
Elasticity = particle.m_Elasticity,
Stiffness = particle.m_Stiffness,
Inert = particle.m_Inert
});
added = true;
}
}
foreach (DynamicBoneCollider collider in character.GetComponentsInChildren<DynamicBoneCollider>(true))
{
if (IsHipSkirtCollider(collider) && _knownHipColliders.Add(collider))
{
_hipColliders.Add(new ColliderDefaults
{
Collider = collider,
Radius = collider.m_Radius
});
addedCollider = true;
}
if (IsLegSkirtCollider(collider) && _knownLegColliders.Add(collider))
{
_legColliders.Add(new ColliderDefaults
{
Collider = collider,
Radius = collider.m_Radius
});
addedCollider = true;
}
}
}
_initialized = _particles.Count > 0;
if (added || addedCollider)
Apply();
}
private void OnGUI()
{
if (!_visible || !_initialized)
return;
GUILayout.BeginArea(new Rect(16, 16, 430, 720), GUI.skin.window);
GUILayout.Label("Secondary Motion Tuning");
bool changed = false;
changed |= SliderRow("Damping", ref _dampingScale, 0.25f, 4f);
changed |= SliderRow("Elasticity", ref _elasticityScale, 0f, 1.5f);
changed |= SliderRow("Stiffness", ref _stiffnessScale, 0.25f, 2f);
changed |= SliderRow("Inertia", ref _inertScale, 0f, 1.5f);
changed |= SliderRow("Hip radius", ref _hipRadiusScale, 0.1f, 1.5f);
changed |= SliderRow("Leg radius", ref _legRadiusScale, 0.5f, 1.5f);
changed |= SliderRow("Horizontal stiffness", ref _horizontalStiffness, 0f, 1f);
GUILayout.Space(5);
GUILayout.Label("All non-skirt physics (relative to skirt base)");
changed |= SliderRow("Other damping", ref _otherDampingScale, 0.25f, 4f);
changed |= SliderRow("Other elasticity", ref _otherElasticityScale, 0f, 1.5f);
changed |= SliderRow("Other stiffness", ref _otherStiffnessScale, 0.25f, 2f);
changed |= SliderRow("Other inertia", ref _otherInertScale, 0f, 1.5f);
changed |= SliderRow("Other rigidity", ref _otherRigidity, 0f, 1f);
GUILayout.Space(5);
GUILayout.Label("Live display");
changed |= SliderRow("Screen brightness", ref _monitorBrightness, 0f, 1.5f);
if (GUILayout.Button("Restore defaults", GUILayout.Width(165)))
{
_dampingScale = 3f;
_elasticityScale = 1.5f;
_stiffnessScale = 2f;
_inertScale = 1.1f;
_hipRadiusScale = 0.2f;
_legRadiusScale = 0.8f;
_horizontalStiffness = 0.5f;
_otherDampingScale = 3f;
_otherElasticityScale = 1.5f;
_otherStiffnessScale = 2f;
_otherInertScale = 1.1f;
_otherRigidity = 0.85f;
_monitorBrightness = 1f;
changed = true;
}
if (changed)
Apply();
bool showColliders = GUILayout.Toggle(_showSkirtColliders, "Show skirt colliders");
if (showColliders != _showSkirtColliders)
{
_showSkirtColliders = showColliders;
foreach (SkirtCollisionVisualizer visualizer in Object.FindObjectsOfType<SkirtCollisionVisualizer>(true))
visualizer.SetVisible(_showSkirtColliders);
}
bool bodyCollisionEnabled = GUILayout.Toggle(_skirtBodyCollisionEnabled, "Enable skirt body collision");
if (bodyCollisionEnabled != _skirtBodyCollisionEnabled)
{
_skirtBodyCollisionEnabled = bodyCollisionEnabled;
SetSkirtBodyCollisionEnabled(_skirtBodyCollisionEnabled);
}
UmaContainerCharacter firstCharacter = Director.instance.CharaContainerScript[0];
SkirtSurfaceCollisionSolver solver = firstCharacter != null
? firstCharacter.GetComponent<SkirtSurfaceCollisionSolver>() : null;
GUILayout.Label(solver != null
? "Skirt rings: " + solver.RingCount + " colliders: " + solver.ColliderCount +
" contacts: " + solver.LastContactCount
: "Skirt surface solver: not initialized");
if (firstCharacter != null)
{
foreach (DynamicBoneCollider collider in firstCharacter.GetComponentsInChildren<DynamicBoneCollider>(true))
{
if (!IsBodySkirtCollider(collider))
continue;
GUILayout.Label(collider.ColliderName + " r=" + collider.m_Radius.ToString("0.000") +
" h=" + collider.m_Height.ToString("0.000"));
}
}
GUILayout.Label("F8: close");
GUILayout.EndArea();
}
private static bool SliderRow(string label, ref float value, float min, float max)
{
GUILayout.BeginHorizontal();
GUILayout.Label(label, GUILayout.Width(105));
float updated = GUILayout.HorizontalSlider(value, min, max, GUILayout.Width(165));
GUILayout.Label(updated.ToString("0.00") + "x", GUILayout.Width(55));
GUILayout.EndHorizontal();
bool changed = !Mathf.Approximately(value, updated);
value = updated;
return changed;
}
private void Apply()
{
foreach (ParticleDefaults defaults in _particles)
{
DynamicBone.Particle particle = defaults.Particle;
if (particle == null)
continue;
float categoryDamping = 1f;
float categoryElasticity = 1f;
float categoryStiffness = 1f;
float categoryInert = 1f;
if (defaults.Category == MotionCategory.Other)
{
categoryDamping = _otherDampingScale;
categoryElasticity = _otherElasticityScale;
categoryStiffness = _otherStiffnessScale;
categoryInert = _otherInertScale;
}
particle.m_Damping = Mathf.Clamp01(defaults.Damping * _dampingScale * categoryDamping);
particle.m_Elasticity = Mathf.Clamp01(defaults.Elasticity * _elasticityScale * categoryElasticity);
particle.m_Stiffness = Mathf.Clamp01(defaults.Stiffness * _stiffnessScale * categoryStiffness);
particle.m_Inert = Mathf.Clamp01(defaults.Inert * _inertScale * categoryInert);
if (defaults.Category == MotionCategory.Other && _otherRigidity > 0f)
{
// Multipliers alone cannot make a low-value source spring rigid.
// This is a direct, non-skirt constraint preset: high damping and
// stiffness, with limited rebound and body-motion lag.
particle.m_Damping = Mathf.Max(particle.m_Damping, Mathf.Lerp(0f, 0.9f, _otherRigidity));
particle.m_Stiffness = Mathf.Max(particle.m_Stiffness, Mathf.Lerp(0f, 0.98f, _otherRigidity));
particle.m_Elasticity = Mathf.Min(particle.m_Elasticity, Mathf.Lerp(1f, 0.05f, _otherRigidity));
particle.m_Inert = Mathf.Min(particle.m_Inert, Mathf.Lerp(1f, 0f, _otherRigidity));
}
}
foreach (ColliderDefaults defaults in _hipColliders)
{
if (defaults.Collider != null)
defaults.Collider.m_Radius = defaults.Radius * _hipRadiusScale;
}
foreach (ColliderDefaults defaults in _legColliders)
{
if (defaults.Collider != null)
defaults.Collider.m_Radius = defaults.Radius * _legRadiusScale;
}
if (Director.instance == null)
return;
foreach (UmaContainerCharacter character in Director.instance.CharaContainerScript)
{
if (character == null)
continue;
SkirtSurfaceCollisionSolver solver = character.GetComponent<SkirtSurfaceCollisionSolver>();
if (solver != null)
solver.HorizontalStiffness = _horizontalStiffness;
}
foreach (StageMonitorDriver monitorDriver in Object.FindObjectsOfType<StageMonitorDriver>(true))
monitorDriver.brightnessMultiplier = _monitorBrightness;
}
private void SetSkirtBodyCollisionEnabled(bool enabled)
{
foreach (UmaContainerCharacter character in Director.instance.CharaContainerScript)
{
if (character == null)
continue;
foreach (DynamicBoneCollider collider in character.GetComponentsInChildren<DynamicBoneCollider>(true))
{
if (IsBodySkirtCollider(collider))
collider.enabled = enabled;
}
SkirtSurfaceCollisionSolver solver = character.GetComponent<SkirtSurfaceCollisionSolver>();
if (solver != null)
solver.enabled = enabled;
character.ResetDynamicBone();
}
}
private static bool IsBodySkirtCollider(DynamicBoneCollider collider)
{
if (collider == null || string.IsNullOrEmpty(collider.ColliderName) ||
collider.ColliderName.IndexOf("Skirt", System.StringComparison.OrdinalIgnoreCase) < 0)
return false;
if (collider.ColliderName.IndexOf("Thigh", System.StringComparison.OrdinalIgnoreCase) >= 0)
return true;
bool isSideHip = collider.ColliderName.EndsWith("_L", System.StringComparison.OrdinalIgnoreCase) ||
collider.ColliderName.EndsWith("_R", System.StringComparison.OrdinalIgnoreCase);
return collider.ColliderName.IndexOf("Hip", System.StringComparison.OrdinalIgnoreCase) >= 0 &&
isSideHip && collider.m_Radius <= 0.25f;
}
private static bool IsHipSkirtCollider(DynamicBoneCollider collider)
{
return collider != null && collider.ColliderName != null &&
collider.ColliderName.IndexOf("Hip", System.StringComparison.OrdinalIgnoreCase) >= 0 &&
collider.ColliderName.IndexOf("Skirt", System.StringComparison.OrdinalIgnoreCase) >= 0 &&
(collider.ColliderName.EndsWith("_L", System.StringComparison.OrdinalIgnoreCase) ||
collider.ColliderName.EndsWith("_R", System.StringComparison.OrdinalIgnoreCase));
}
private static bool IsLegSkirtCollider(DynamicBoneCollider collider)
{
return collider != null && collider.ColliderName != null &&
collider.ColliderName.IndexOf("Thigh", System.StringComparison.OrdinalIgnoreCase) >= 0 &&
collider.ColliderName.IndexOf("MSkirt", System.StringComparison.OrdinalIgnoreCase) >= 0;
}
private static MotionCategory GetMotionCategory(DynamicBone dynamicBone)
{
string name = GetMotionName(dynamicBone);
return dynamicBone != null && (dynamicBone.m_ContinuousCollision ||
name.IndexOf("Skirt", System.StringComparison.OrdinalIgnoreCase) >= 0)
? MotionCategory.Skirt : MotionCategory.Other;
}
private static string GetMotionName(DynamicBone dynamicBone)
{
string name = dynamicBone != null && dynamicBone.m_Root != null
? dynamicBone.m_Root.name : string.Empty;
for (Transform transform = dynamicBone != null ? dynamicBone.transform.parent : null;
transform != null; transform = transform.parent)
{
name += "/" + transform.name;
}
return name;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 65925dd2ac8aec44a9998f1d12dd2b2d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -57,6 +57,20 @@ namespace Gallop
private static readonly int kReflectionRate = Shader.PropertyToID("_ReflectionRate");
private static readonly int kColor = Shader.PropertyToID("_Color");
public void ApplyTimeline(bool valid, bool enabled, float reflectionRate)
{
if (!valid) return;
_mirrorReflectionRate = Mathf.Max(0f, reflectionRate);
this.enabled = enabled;
if (_renderer != null)
{
_block ??= new MaterialPropertyBlock();
_renderer.GetPropertyBlock(_block);
_block.SetFloat(kReflectionRate, _mirrorReflectionRate);
_renderer.SetPropertyBlock(_block);
}
}
// URP 下不能用 Camera.Render()(SRP 会直接报错、什么都不画),
// 必须挂 beginCameraRendering 拿到 ScriptableRenderContext 再 RenderSingleCamera。
private void OnEnable() => RenderPipelineManager.beginCameraRendering += OnBeginCamera;
@@ -0,0 +1,141 @@
using System;
using UnityEngine;
using UnityEngine.Rendering;
namespace Gallop.RenderPipeline
{
[DisallowMultipleComponent]
public class CustomProjector : MonoBehaviour
{
public float _nearClipPlane = 0.1f;
public float _farClipPlane = 60f;
public float _fieldOfView = 30f;
public float _aspectRatio = 1f;
public Material _material;
public Texture ProjectionTexture
{
get
{
if (_material == null) return null;
string[] names = { "_MainTex", "_BaseMap", "_ProjectionTex" };
foreach (string name in names)
if (_material.HasProperty(name) && _material.GetTexture(name) != null) return _material.GetTexture(name);
return null;
}
}
}
[Serializable]
public class LensFlareElement
{
public int ImageIndex;
public float Position;
public float Size = 1f;
public Color Color = Color.white;
public bool UseLightColor;
public bool Rotate;
public bool Zoom;
public bool Fade;
}
public class LensFlareData : ScriptableObject
{
public int TextureLayout;
public Texture2D Texture;
public LensFlareElement[] ElementArray = Array.Empty<LensFlareElement>();
public bool UseFog;
}
[DisallowMultipleComponent]
public class CustomLensFlare : MonoBehaviour
{
public LensFlareData Flare;
public float Brightness = 1f;
public float FadeSpeed = 1f;
public Color Color = Color.white;
public LayerMask IgnoreLayers;
public bool IsDirectional;
private LensFlareComponentSRP _component;
private LensFlareDataSRP _runtimeData;
private void OnEnable()
{
BuildRuntimeFlare();
ApplyRuntimeParameters();
}
private void OnDisable()
{
if (_component != null) _component.enabled = false;
}
private void OnDestroy()
{
if (_runtimeData != null) Destroy(_runtimeData);
}
public void ApplyTimeline(bool enabled, Vector3 offset, Color color, float brightness, float fadeSpeed)
{
if (_component == null || _runtimeData == null) BuildRuntimeFlare();
transform.localPosition = offset;
Color = color;
Brightness = Mathf.Max(0f, brightness);
FadeSpeed = Mathf.Max(0f, fadeSpeed);
ApplyRuntimeParameters();
if (_component != null) _component.enabled = enabled;
}
private void BuildRuntimeFlare()
{
if (Flare == null) return;
_component = GetComponent<LensFlareComponentSRP>();
if (_component == null) _component = gameObject.AddComponent<LensFlareComponentSRP>();
if (_runtimeData != null) Destroy(_runtimeData);
_runtimeData = ScriptableObject.CreateInstance<LensFlareDataSRP>();
var source = Flare.ElementArray ?? Array.Empty<LensFlareElement>();
var elements = new LensFlareDataElementSRP[source.Length];
for (int i = 0; i < source.Length; i++)
{
LensFlareElement item = source[i] ?? new LensFlareElement();
var element = new LensFlareDataElementSRP
{
flareType = SRPLensFlareType.Image,
lensFlareTexture = Flare.Texture,
position = item.Position,
uniformScale = Mathf.Max(0.001f, item.Size),
sizeXY = Vector2.one,
tint = item.Color,
autoRotate = item.Rotate,
modulateByLightColor = item.UseLightColor,
blendMode = SRPLensFlareBlendMode.Additive,
preserveAspectRatio = true
};
elements[i] = element;
}
_runtimeData.elements = elements;
_component.lensFlareData = _runtimeData;
_component.useOcclusion = true;
_component.allowOffScreen = false;
_component.attenuationByLightShape = false;
}
private void ApplyRuntimeParameters()
{
if (_component == null || _runtimeData == null) return;
_component.intensity = Mathf.Max(0f, Brightness);
for (int i = 0; i < _runtimeData.elements.Length; i++)
{
LensFlareElement source = Flare != null && Flare.ElementArray != null && i < Flare.ElementArray.Length
? Flare.ElementArray[i]
: null;
Color sourceColor = source != null ? source.Color : UnityEngine.Color.white;
_runtimeData.elements[i].tint = sourceColor * Color;
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: da09622a71394244b822f4f9d136b344
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,128 @@
using System.Collections.Generic;
using UnityEngine;
/// <summary>Runtime wireframe display for generated skirt collision volumes.</summary>
public sealed class SkirtCollisionVisualizer : MonoBehaviour
{
private const int Segments = 24;
private readonly List<LineRenderer> _lines = new List<LineRenderer>();
private DynamicBoneCollider _collider;
private bool _visible;
private float _lastRadius = -1f;
private float _lastHeight = -1f;
private void Awake()
{
_collider = GetComponent<DynamicBoneCollider>();
CreateLines();
SetVisible(false);
}
private void LateUpdate()
{
if (_visible && _collider != null &&
(!Mathf.Approximately(_lastRadius, _collider.m_Radius) ||
!Mathf.Approximately(_lastHeight, _collider.m_Height)))
UpdateGeometry();
}
public void SetVisible(bool visible)
{
_visible = visible;
foreach (LineRenderer line in _lines)
line.enabled = visible;
if (visible)
UpdateGeometry();
}
private void CreateLines()
{
Color color = name.Contains("Hip") ? new Color(1f, 0.35f, 0.2f) : new Color(0.2f, 0.85f, 1f);
for (int i = 0; i < 6; i++)
{
GameObject lineObject = new GameObject("Wire");
lineObject.transform.SetParent(transform, false);
LineRenderer line = lineObject.AddComponent<LineRenderer>();
line.useWorldSpace = false;
line.loop = i < 3;
line.widthMultiplier = 0.006f;
line.numCornerVertices = 2;
line.numCapVertices = 2;
line.material = SkirtCollisionWireMaterial.Instance;
line.startColor = color;
line.endColor = color;
_lines.Add(line);
}
}
private void UpdateGeometry()
{
if (_collider == null)
return;
float radius = _collider.m_Radius;
_lastRadius = radius;
_lastHeight = _collider.m_Height;
float halfSegment = Mathf.Max(0f, _collider.m_Height * 0.5f - radius);
if (halfSegment == 0f)
{
SetCircle(_lines[0], radius, 0f, 0);
SetCircle(_lines[1], radius, 0f, 1);
SetCircle(_lines[2], radius, 0f, 2);
for (int i = 3; i < _lines.Count; i++)
_lines[i].positionCount = 0;
return;
}
SetCircle(_lines[0], radius, -halfSegment, 0);
SetCircle(_lines[1], radius, halfSegment, 0);
_lines[2].positionCount = 0;
SetLine(_lines[3], new Vector3(radius, 0f, -halfSegment), new Vector3(radius, 0f, halfSegment));
SetLine(_lines[4], new Vector3(-radius, 0f, -halfSegment), new Vector3(-radius, 0f, halfSegment));
SetLine(_lines[5], new Vector3(0f, radius, -halfSegment), new Vector3(0f, radius, halfSegment));
}
private static void SetCircle(LineRenderer line, float radius, float axisOffset, int plane)
{
line.positionCount = Segments;
for (int i = 0; i < Segments; i++)
{
float angle = Mathf.PI * 2f * i / Segments;
float a = Mathf.Cos(angle) * radius;
float b = Mathf.Sin(angle) * radius;
switch (plane)
{
case 0: line.SetPosition(i, new Vector3(a, b, axisOffset)); break;
case 1: line.SetPosition(i, new Vector3(a, axisOffset, b)); break;
default: line.SetPosition(i, new Vector3(axisOffset, a, b)); break;
}
}
}
private static void SetLine(LineRenderer line, Vector3 from, Vector3 to)
{
line.positionCount = 2;
line.SetPosition(0, from);
line.SetPosition(1, to);
}
}
internal static class SkirtCollisionWireMaterial
{
private static Material _instance;
public static Material Instance
{
get
{
if (_instance == null)
{
Shader shader = Shader.Find("Universal Render Pipeline/Unlit");
if (shader == null)
shader = Shader.Find("Sprites/Default");
_instance = new Material(shader);
}
return _instance;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: dc01f9558187f96469b7bd90a47e5ebc
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,46 @@
using UnityEngine;
/// <summary>
/// Keeps a DynamicBone capsule aligned with the animated hip-to-knee segment.
/// </summary>
[DefaultExecutionOrder(-1000)]
public sealed class SkirtLegCollisionProxy : MonoBehaviour
{
public Transform StartBone;
public Transform EndBone;
public DynamicBoneCollider Collider;
public float RadiusScale;
private void Update()
{
UpdateColliderTransform();
}
private void LateUpdate()
{
// Refresh after the character animation has written the current leg pose,
// before DynamicBone performs its late-frame collision solve.
UpdateColliderTransform();
}
public void UpdateColliderTransform()
{
if (StartBone == null || EndBone == null || Collider == null)
return;
Vector3 segment = EndBone.position - StartBone.position;
float length = segment.magnitude;
if (length <= Mathf.Epsilon)
return;
transform.SetPositionAndRotation(
StartBone.position + segment * 0.5f,
Quaternion.LookRotation(segment));
if (RadiusScale > 0f)
Collider.m_Radius = length * RadiusScale;
// DynamicBone measures capsule height including both rounded ends.
Collider.m_Height = length + Collider.m_Radius * 2f;
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 73a040a00b60d4243b83dc5eda9511fe
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,305 @@
using System;
using System.Collections.Generic;
using UnityEngine;
/// <summary>
/// Resolves collisions across the horizontal spans between adjacent skirt chains.
/// DynamicBone itself only checks each independent vertical chain.
/// </summary>
[DefaultExecutionOrder(1000)]
public sealed class SkirtSurfaceCollisionSolver : MonoBehaviour
{
private sealed class HorizontalLink
{
public DynamicBone.Particle Left;
public DynamicBone.Particle Right;
public float RestLength;
}
private readonly List<List<DynamicBone>> _rings = new List<List<DynamicBone>>();
private readonly List<DynamicBoneColliderBase> _colliders = new List<DynamicBoneColliderBase>();
private readonly List<HorizontalLink> _horizontalLinks = new List<HorizontalLink>();
private Transform _hip;
public int RingCount => _rings.Count;
public int ColliderCount => _colliders.Count;
public int LastContactCount { get; private set; }
public float HorizontalStiffness { get; set; } = 0.25f;
public void Initialize(DynamicBone[] dynamics, Transform hip, IEnumerable<DynamicBoneColliderBase> colliders)
{
_hip = hip;
_rings.Clear();
_colliders.Clear();
_horizontalLinks.Clear();
foreach (DynamicBoneColliderBase collider in colliders)
{
if (IsSurfaceCollider(collider) && !_colliders.Contains(collider))
_colliders.Add(collider);
}
var layers = new Dictionary<string, List<DynamicBone>>(StringComparer.OrdinalIgnoreCase);
foreach (DynamicBone dynamicBone in dynamics)
{
if (dynamicBone == null || !dynamicBone.m_ContinuousCollision ||
dynamicBone.m_Root == null || dynamicBone.Particles.Count < 2)
continue;
string layer = GetSkirtLayer(dynamicBone.m_Root.name);
if (!layers.TryGetValue(layer, out List<DynamicBone> ring))
{
ring = new List<DynamicBone>();
layers.Add(layer, ring);
}
ring.Add(dynamicBone);
}
foreach (List<DynamicBone> ring in layers.Values)
{
if (ring.Count < 3)
continue;
ring.Sort((left, right) => GetAngle(left).CompareTo(GetAngle(right)));
_rings.Add(ring);
AddHorizontalLinks(ring);
}
}
private void LateUpdate()
{
LastContactCount = 0;
SolveHorizontalLinks();
for (int iteration = 0; iteration < 1; ++iteration)
{
foreach (List<DynamicBone> ring in _rings)
LastContactCount += ResolveRing(ring, _colliders);
}
foreach (List<DynamicBone> ring in _rings)
{
foreach (DynamicBone dynamicBone in ring)
dynamicBone.ApplyParticlesToTransformsNow();
}
}
private void AddHorizontalLinks(List<DynamicBone> ring)
{
for (int chainIndex = 0; chainIndex < ring.Count; ++chainIndex)
{
DynamicBone leftChain = ring[chainIndex];
DynamicBone rightChain = ring[(chainIndex + 1) % ring.Count];
int depthCount = Mathf.Min(leftChain.Particles.Count, rightChain.Particles.Count);
for (int depth = 1; depth < depthCount; ++depth)
{
DynamicBone.Particle left = leftChain.Particles[depth];
DynamicBone.Particle right = rightChain.Particles[depth];
_horizontalLinks.Add(new HorizontalLink
{
Left = left,
Right = right,
RestLength = Vector3.Distance(left.m_Position, right.m_Position)
});
}
}
}
private void SolveHorizontalLinks()
{
float strength = Mathf.Clamp01(HorizontalStiffness);
if (strength <= 0f)
return;
foreach (HorizontalLink link in _horizontalLinks)
{
Vector3 delta = link.Right.m_Position - link.Left.m_Position;
float length = delta.magnitude;
if (length <= Mathf.Epsilon)
continue;
Vector3 correction = delta * ((length - link.RestLength) / length * strength * 0.5f);
link.Left.m_Position += correction;
link.Left.m_PrevPosition += correction;
link.Right.m_Position -= correction;
link.Right.m_PrevPosition -= correction;
}
}
private static int ResolveRing(List<DynamicBone> ring, List<DynamicBoneColliderBase> colliders)
{
int contacts = 0;
for (int chainIndex = 0; chainIndex < ring.Count; ++chainIndex)
{
DynamicBone leftChain = ring[chainIndex];
DynamicBone rightChain = ring[(chainIndex + 1) % ring.Count];
int depthCount = Mathf.Min(leftChain.Particles.Count, rightChain.Particles.Count);
for (int depth = 1; depth < depthCount; ++depth)
{
DynamicBone.Particle left = leftChain.Particles[depth];
DynamicBone.Particle right = rightChain.Particles[depth];
float objectScale = Mathf.Abs(leftChain.transform.lossyScale.x);
float particleRadius = Mathf.Max(left.m_Radius, right.m_Radius) * objectScale;
float maxCorrection = Mathf.Max(
Vector3.Distance(leftChain.Particles[depth - 1].m_Position, left.m_Position),
Vector3.Distance(rightChain.Particles[depth - 1].m_Position, right.m_Position)) * 0.75f;
maxCorrection = Mathf.Max(maxCorrection, 0.005f * objectScale);
contacts += ResolveSpan(left, right, colliders, particleRadius, maxCorrection);
contacts += ResolvePanel(
leftChain.Particles[depth - 1], rightChain.Particles[depth - 1],
left, right, depth > 1, colliders, particleRadius, maxCorrection);
}
}
return contacts;
}
private static int ResolvePanel(
DynamicBone.Particle leftTop,
DynamicBone.Particle rightTop,
DynamicBone.Particle leftBottom,
DynamicBone.Particle rightBottom,
bool topIsMovable,
List<DynamicBoneColliderBase> colliders,
float particleRadius,
float maxCorrection)
{
if (colliders.Count == 0)
return 0;
int contacts = 0;
for (int verticalIndex = 1; verticalIndex <= 2; ++verticalIndex)
{
float vertical = verticalIndex / 3f;
for (int horizontalIndex = 1; horizontalIndex <= 2; ++horizontalIndex)
{
float horizontal = horizontalIndex / 3f;
Vector3 top = Vector3.Lerp(leftTop.m_Position, rightTop.m_Position, horizontal);
Vector3 bottom = Vector3.Lerp(leftBottom.m_Position, rightBottom.m_Position, horizontal);
Vector3 sample = Vector3.Lerp(top, bottom, vertical);
foreach (DynamicBoneColliderBase collider in colliders)
{
if (!IsSurfaceCollider(collider))
continue;
Vector3 projected = sample;
if (!collider.Collide(ref projected, particleRadius))
continue;
++contacts;
float leftTopWeight = (1f - horizontal) * (1f - vertical);
float rightTopWeight = horizontal * (1f - vertical);
float leftBottomWeight = (1f - horizontal) * vertical;
float rightBottomWeight = horizontal * vertical;
float normalization = leftBottomWeight * leftBottomWeight + rightBottomWeight * rightBottomWeight;
if (topIsMovable)
normalization += leftTopWeight * leftTopWeight + rightTopWeight * rightTopWeight;
Vector3 correction = projected - sample;
if (topIsMovable)
{
ApplyCorrection(leftTop, correction * (leftTopWeight / normalization), maxCorrection);
ApplyCorrection(rightTop, correction * (rightTopWeight / normalization), maxCorrection);
}
ApplyCorrection(leftBottom, correction * (leftBottomWeight / normalization), maxCorrection);
ApplyCorrection(rightBottom, correction * (rightBottomWeight / normalization), maxCorrection);
sample = projected;
}
}
}
return contacts;
}
private static int ResolveSpan(
DynamicBone.Particle left,
DynamicBone.Particle right,
List<DynamicBoneColliderBase> colliders,
float particleRadius,
float maxCorrection)
{
if (colliders.Count == 0)
return 0;
int contacts = 0;
for (int sampleIndex = 1; sampleIndex <= 1; ++sampleIndex)
{
float t = 0.5f;
Vector3 sample = Vector3.Lerp(left.m_Position, right.m_Position, t);
foreach (DynamicBoneColliderBase collider in colliders)
{
if (!IsSurfaceCollider(collider))
continue;
Vector3 projected = sample;
if (!collider.Collide(ref projected, particleRadius))
continue;
++contacts;
Vector3 correction = projected - sample;
float leftWeight = 1f - t;
float rightWeight = t;
float normalization = leftWeight * leftWeight + rightWeight * rightWeight;
Vector3 leftCorrection = correction * (leftWeight / normalization);
Vector3 rightCorrection = correction * (rightWeight / normalization);
ApplyCorrection(left, leftCorrection, maxCorrection);
ApplyCorrection(right, rightCorrection, maxCorrection);
sample = projected;
}
}
return contacts;
}
private static bool IsSurfaceCollider(DynamicBoneColliderBase collider)
{
return collider != null && collider.enabled &&
IsBodySkirtCollider(collider);
}
private static bool IsBodySkirtCollider(DynamicBoneColliderBase collider)
{
if (collider == null || string.IsNullOrEmpty(collider.ColliderName) ||
collider.ColliderName.IndexOf("Skirt", StringComparison.OrdinalIgnoreCase) < 0)
return false;
if (collider.ColliderName.IndexOf("Thigh", StringComparison.OrdinalIgnoreCase) >= 0)
return true;
DynamicBoneCollider dynamicCollider = collider as DynamicBoneCollider;
return collider.ColliderName.IndexOf("Hip", StringComparison.OrdinalIgnoreCase) >= 0 &&
(dynamicCollider == null || dynamicCollider.m_Radius <= 0.25f);
}
private static void ApplyCorrection(DynamicBone.Particle particle, Vector3 correction, float maxCorrection)
{
correction = Vector3.ClampMagnitude(correction, maxCorrection);
particle.m_Position += correction;
particle.m_PrevPosition += correction;
particle.m_isCollide = true;
}
private float GetAngle(DynamicBone dynamicBone)
{
Vector3 center = _hip != null ? _hip.position : transform.position;
Vector3 direction = dynamicBone.m_Root.position - center;
if (_hip != null)
direction = _hip.InverseTransformDirection(direction);
return Mathf.Atan2(direction.x, direction.z);
}
private static string GetSkirtLayer(string boneName)
{
int marker = boneName.IndexOf("MSkirt", StringComparison.OrdinalIgnoreCase);
if (marker < 0)
return boneName;
int end = marker + "MSkirt".Length;
while (end < boneName.Length && char.IsDigit(boneName[end]))
++end;
return boneName.Substring(marker, end - marker);
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: dae4cd93a4add8345b1a3a77b32a83fa
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
File diff suppressed because one or more lines are too long
+17 -17
View File
@@ -5,7 +5,7 @@
"depth": 1,
"source": "registry",
"dependencies": {},
"url": "https://packages.unity.com"
"url": "https://packages.unity.cn"
},
"com.unity.2d.sprite": {
"version": "1.0.0",
@@ -20,7 +20,7 @@
"dependencies": {
"com.unity.modules.ai": "1.0.0"
},
"url": "https://packages.unity.com"
"url": "https://packages.unity.cn"
},
"com.unity.burst": {
"version": "1.8.21",
@@ -30,14 +30,14 @@
"com.unity.mathematics": "1.2.1",
"com.unity.modules.jsonserialize": "1.0.0"
},
"url": "https://packages.unity.com"
"url": "https://packages.unity.cn"
},
"com.unity.ext.nunit": {
"version": "1.0.6",
"depth": 1,
"source": "registry",
"dependencies": {},
"url": "https://packages.unity.com"
"url": "https://packages.unity.cn"
},
"com.unity.formats.fbx": {
"version": "5.0.0",
@@ -47,7 +47,7 @@
"com.unity.timeline": "1.7.1",
"com.autodesk.fbx": "5.0.0"
},
"url": "https://packages.unity.com"
"url": "https://packages.unity.cn"
},
"com.unity.ide.rider": {
"version": "3.0.36",
@@ -56,7 +56,7 @@
"dependencies": {
"com.unity.ext.nunit": "1.0.6"
},
"url": "https://packages.unity.com"
"url": "https://packages.unity.cn"
},
"com.unity.ide.visualstudio": {
"version": "2.0.22",
@@ -65,28 +65,28 @@
"dependencies": {
"com.unity.test-framework": "1.1.9"
},
"url": "https://packages.unity.com"
"url": "https://packages.unity.cn"
},
"com.unity.ide.vscode": {
"version": "1.2.5",
"depth": 0,
"source": "registry",
"dependencies": {},
"url": "https://packages.unity.com"
"url": "https://packages.unity.cn"
},
"com.unity.mathematics": {
"version": "1.2.6",
"depth": 1,
"source": "registry",
"dependencies": {},
"url": "https://packages.unity.com"
"url": "https://packages.unity.cn"
},
"com.unity.nuget.newtonsoft-json": {
"version": "3.2.1",
"depth": 0,
"source": "registry",
"dependencies": {},
"url": "https://packages.unity.com"
"url": "https://packages.unity.cn"
},
"com.unity.postprocessing": {
"version": "3.4.0",
@@ -95,7 +95,7 @@
"dependencies": {
"com.unity.modules.physics": "1.0.0"
},
"url": "https://packages.unity.com"
"url": "https://packages.unity.cn"
},
"com.unity.recorder": {
"version": "4.0.3",
@@ -104,7 +104,7 @@
"dependencies": {
"com.unity.timeline": "1.0.0"
},
"url": "https://packages.unity.com"
"url": "https://packages.unity.cn"
},
"com.unity.render-pipelines.core": {
"version": "14.0.12",
@@ -142,7 +142,7 @@
"depth": 2,
"source": "registry",
"dependencies": {},
"url": "https://packages.unity.com"
"url": "https://packages.unity.cn"
},
"com.unity.shadergraph": {
"version": "14.0.12",
@@ -162,7 +162,7 @@
"com.unity.modules.imgui": "1.0.0",
"com.unity.modules.jsonserialize": "1.0.0"
},
"url": "https://packages.unity.com"
"url": "https://packages.unity.cn"
},
"com.unity.textmeshpro": {
"version": "3.0.7",
@@ -171,19 +171,19 @@
"dependencies": {
"com.unity.ugui": "1.0.0"
},
"url": "https://packages.unity.com"
"url": "https://packages.unity.cn"
},
"com.unity.timeline": {
"version": "1.7.7",
"depth": 0,
"source": "registry",
"dependencies": {
"com.unity.modules.audio": "1.0.0",
"com.unity.modules.director": "1.0.0",
"com.unity.modules.animation": "1.0.0",
"com.unity.modules.audio": "1.0.0",
"com.unity.modules.particlesystem": "1.0.0"
},
"url": "https://packages.unity.com"
"url": "https://packages.unity.cn"
},
"com.unity.ugui": {
"version": "1.0.0",
+9 -16
View File
@@ -12,32 +12,25 @@ MonoBehaviour:
m_Script: {fileID: 13964, guid: 0000000000000000e000000000000000, type: 0}
m_Name:
m_EditorClassIdentifier:
m_EnablePreviewPackages: 0
m_EnablePackageDependencies: 0
m_EnablePreReleasePackages: 0
m_AdvancedSettingsExpanded: 1
m_ScopedRegistriesSettingsExpanded: 1
m_SeeAllPackageVersions: 0
m_DismissPreviewPackagesInUse: 0
oneTimeWarningShown: 0
m_Registries:
- m_Id: main
m_Name:
m_Url: https://packages.unity.com
m_Url: https://packages.unity.cn
m_Scopes: []
m_IsDefault: 1
m_Capabilities: 7
m_ConfigSource: 0
m_UserSelectedRegistryName:
m_UserAddingNewScopedRegistry: 0
m_RegistryInfoDraft:
m_ErrorMessage:
m_Original:
m_Id:
m_Name:
m_Url:
m_Scopes: []
m_IsDefault: 0
m_Capabilities: 0
m_Modified: 0
m_Name:
m_Url:
m_Scopes:
-
m_SelectedScopeIndex: 0
m_ErrorMessage:
m_UserModificationsInstanceId: -850
m_OriginalInstanceId: -852
m_LoadAssets: 0
+2 -2
View File
@@ -1,2 +1,2 @@
m_EditorVersion: 2022.3.62f1
m_EditorVersionWithRevision: 2022.3.62f1 (4af31df58517)
m_EditorVersion: 2022.3.62f1c1
m_EditorVersionWithRevision: 2022.3.62f1c1 (b0109b07edb8)
Binary file not shown.

After

Width:  |  Height:  |  Size: 434 KiB

+302
View File
@@ -0,0 +1,302 @@
---
name: Live 轨道全览
description: UmaViewer Live 时间轴全部103条轨道的ID、名称、作用、实现状态及已知Bug(2026-05-07核查)
type: reference
originSessionId: 319ae839-0292-4b1e-80de-2f3c91b1f40e
---
# Live 轨道全览(LiveTimelineKeyDataType 枚举,ID 0–102)
数据来源:LiveTimelineDefine.cs 枚举 + WorkSheet CS字段核查 + scan_full.py全量扫描(58首歌,2026-05-07)+ Director/Control代码审查
> **术语**:下文「WorkSheet 有/无 XXX 字段」指的是**游戏 bundle 的 TypeTree**,不是本仓库的 C# `LiveTimelineWorkSheet` 类。两者是分开的:`postFilmKeys`、`radialBlurKeys`、`tiltShiftKeys`、`flashPlayerKeys`、`charaFootLightKeys`、`MultiLightShadowKeys`、`lightProjectionList`、`postFilm1MultiCameraKeys`、`postEffectDOFKeys` 在 bundle 里有,在 C# 里**都还没声明**。
>
> **覆盖率数字同理**——「N/58 首」量的是 bundle 里有没有数据,不代表 C# 收得到。分发层的过滤可能让有数据的轨道照样落空(BgColor1 就是如此,见 ID 8)。
>
> ## ⚠️ 2026-08-04:旧扫描数据不可信,已用新工具全量重测
>
> `tools/` 下的 dump 工具链已重建(见 `CLAUDE.md`)。cutt bundle 自带完整 TypeTree,**字段名和 keyframe 数是权威的**。全 59 首、70 张 worksheet 共享同一套 schema(128 字段),无版本漂移。
>
> 重测结果与本文件原有记录冲突严重,**下表中带「✅实测」标记的才是可信数据**,其余未标记的行仍是旧扫描结论,谨慎对待。两类系统性错误:
>
> 1. **「WorkSheet无XXX字段」全部是错的** —— 那些字段都存在,而且多数有大量数据(见 ID 26/27/51/52/59/70/77/84/85/98/99)
> 2. **「全0 keyframe空占位」多数是错的** —— RadialBlur/PostFilm/TiltShift/FlashPlayer/CharaFootLight/MultiLightShadow 实测都有真实数据(见 ID 15/39/63/66/72/83)
>
> 真正确认为空的只有:`hdrBloomKeys`、`other4EyeTrackKeys`、`ScreenCaptureDataList`、`tailMotionDataList`(0/59 首)。
>
> 复查命令:`~/.venvs/umatools/bin/python3 tools/dump_cutt_typetree.py --fields <songid>`;全量表在 `tools/out/scan_summary.txt`。
**实现状态图例**
- ✅ 完整
- ⚠️ 部分实现(有TODO)
- ⚠️🔇 仅数据(无视觉输出)
- 🔴 Bug(声明但从不触发)
- ❌ 未实现
**★** = song 1001 有真实数据(括号内条数)
---
## 摄像机 / 基础轨道
| ID | 名称 | 作用 | 状态 | Bug |
|----|------|------|------|-----|
| 0 | Timescale | 播放倍速 | ✅ Control内部 | — |
| 1 | CameraPos | 主摄像机位置 | ✅ | — |
| 2 | CameraLookAt | 主摄像机朝向 | ✅ | — |
| 3 | CameraFov | 视野角 | ✅ | — |
| 4 | CameraRoll | 摄像机滚转 | ✅ | — |
| 5 | HandShakeCamera | 手持抖动效果 | ❌ | ✅实测 `handShakeCameraKeys` 56/59首 / 594 keys |
| 6 | Event | 时间轴触发事件 | ❌ | — |
| 7 | CharaMotionSequence ★(5) | 角色动作序列切换 | ✅ | — |
| 8 | BgColor1 ★(13) | 角色**和舞台物件**的色调/轮廓/阴影颜色 | ⚠️ | 曾误标 ✅。`AlterUpdate_BgColor1` 原本用 `validBgColorNames` 只放行 4 个角色组,其余全丢——实测约 92% 的 key 被丢弃(全语料 43617 keys / 59首,是最大的轨道)。2026-08-04 已拆掉过滤并加舞台分支,**舞台侧写 `_MulColor0`**(运行时枚举 shader 确认,每个舞台 shader 都有;`_AmbientColor` 归 BgColor2,两者是同一 shader 上的独立通道)。song 1177 的 20 组里 16 组已正常写入,仍未解析:`BgBL`(256 keys)、`FollowSpotColor`(16)、`Shadow`(2)、`mob_00`(2) —— 这 4 个不是物件名,疑为全局通道 |
| 9 | BgColor2 ★(1) | 背景渐变/环境色(两色混合)【15/58首】 | ✅ | 写含 `_AmbientColor` 属性的舞台材质,颜色 = `Lerp(color1, color2, value)`;color2 恒为白色 |
| 10 | MonitorControl ★(2) | LED舞台屏幕内容控制 | ❌ | ✅实测 `monitorControlList` **55/59首 / 3911 keys**。**舞台上的黑色大屏就是它没实现导致的**:monitor 材质的 `_MainTex`/`_FadeTex`/`_FilterTex` 三个贴图槽在 bundle 里全未绑定,内容靠运行时喂。字段:`dispID` / `speed` / `outputTextureLabel` / `blendFactor` / `Src·DstBlendMode` / `RenderQueueNo` / `LightImageNo` / `CrossFadeRate` / `FilterTexScale`。**内容资源已定位**:`live/uvmovie/gal_uvmovie_<songid>_<001..017>`(图片序列,带 `_tex_NN`),另有 `gal_uvmovie_<songid>_light` 对应 `LightImageNo`。全库 219 个 uvmovie 目录。**但 dispID→资源的映射未解**:song 1177 的 key 是 `dispID=1`,却既没有 `gal_uvmovie_1177_001`,也没有 monitorCamera 数据(`monitorCameraPosKeys` 0 keys),两条路都不通 |
| 11 | CameraSwitcher | 切换活动摄像机 | ✅ | — |
| 12 | LipSync | 口型同步 | ✅ | — |
| 13 | PostEffectDOF | 景深模糊(单机) | ❌ | ✅实测 `postEffectDOFKeys` **59/59首 / 3879 keys**。URP 有内置 DepthOfField,属路径 A |
| 14 | PostEffectBloomDiffusion | 泛光扩散(单机) | ❌ | ✅实测 `postEffectBloomDiffusionKeys` **59/59首 / 1969 keys** |
| 15 | RadialBlur | 径向运动模糊(单机) | ❌ | ✅实测 `radialBlurKeys` **59/59首 / 1667 keys**(单曲最多95)。旧记录「全部0 keyframe」是错的 |
| 16 | CameraLayer | 摄像机渲染层遮罩 | ❌ | ✅实测 `cameraLayerKeys` **58/59首 / 3306 keys** |
| 17 | Projector ★(4) | 舞台投影仪(gobo图案) | ❌ | — |
---
## 角色表情 / 动作
| ID | 名称 | 作用 | 状态 | Bug |
|----|------|------|------|-----|
| 18 | FacialFace | 面部整体表情 | ✅ AlterUpdate_FacialData | — |
| 19 | FacialMouth | 嘴部表情 | ✅ | — |
| 20 | FacialCheek | 脸颊表情 | ✅ | — |
| 21 | FacialEye | 眼部表情 | ✅ | — |
| 22 | FacialEyebrow | 眉毛 | ✅ | — |
| 23 | FacialEyeTrack | 眼球追踪目标 | ✅ | — |
| 24 | FacialEar | 耳部动画 | ✅ | — |
| 25 | FacialEffect | 表情叠加特效 | ✅ | — |
| 26 | FacialNoise | 表情随机扰动 | ❌ | ✅实测 `facialNoiseKeys` **存在**,50/59首 / 127 keys。旧记录「无字段」是错的 |
| 27 | CharaMotionNoise | 动作随机扰动 | ❌ | ✅实测 `charaMotionNoiseKeys` **存在**,**59/59首** / 135 keys。旧记录「无字段」是错的 |
| 28 | FormationOffset | 角色队形位置偏移 | ✅ | — |
| 29 | Animation ★(1) | GameObject Animation组件序列 | ❌ | ✅实测 `animationList` 39/59首 / 1575 keys(单曲最多218),比 ★(1) 高得多 |
| 30 | TextureAnimation | 材质纹理帧动画 | ❌ | — |
---
## 舞台物件
| ID | 名称 | 作用 | 状态 | Bug |
|----|------|------|------|-----|
| 31 | Transform | 舞台物件位移/旋转/缩放 | ✅ StageController | — |
| 32 | Renderer | 舞台Renderer启用/禁用 | ❌ | — |
| 33 | Object ★(10) | 舞台GameObject激活/隐藏**+ 位移** | ✅ StageController | 2026-08-04 修了两个静默失效:① 组名可能是 `_stageObjectUnits` 的**单元名**而非 GameObject 名(live10149 的 `neonsign` 就是 unit,底下挂 3 个子对象;没有任何 GameObject 叫 neonsign,整条轨道此前从不触发,灯牌不会从吊顶降下);② `OffsetType` 字段分发层一直在填但 `UpdateObject` 从不读,`Add` 语义被当绝对坐标写,物件被挪到父物体原点。现按 `Direct`/`Add` 分别处理,`Add` 在物件原始 local TRS 上叠加 |
| 34 | Audience ★(12) | 观众人群动画参数 | ❌ | — |
| 35 | Props | 舞台道具激活控制 | ❌ | ✅实测 `propsList` 28/59首 / 1940 keys(单曲最多567) |
| 36 | PropsAttach | 角色附着道具 | ❌ | ✅实测 `propsAttachList` 28/59首 / 298 keys |
---
## 灯光 / 特效
| ID | 名称 | 作用 | 状态 | Bug |
|----|------|------|------|-----|
| 37 | VolumeLight ★(1) | 体积光/SunShafts | ⚠️🔇 | 无SunShafts组件,仅数据。✅实测 `volumeLightKeys` **58/59首 / 2048 keys** |
| 38 | HdrBloom | HDR泛光强度/阈值 | ⚠️🔇 | 0/58首有数据,**但已经实现了**(Director 有 handler,走 URP Bloom)→ 是本仓库里的死代码,建议删。song 1177 实测 `hdrBloomKeys` 为空 |
| 39 | PostFilm | 全屏后期叠加(晕影/颜色/UV电影) | ⚠️🔇 | ✅实测三字段全有大量数据:`postFilmKeys` 59/59首6689 keys、`postFilm2Keys` 59/59首**7290 keys**、`postFilm3Keys` 59/59首6240 keys(song 1177 为 100/161/87)。旧记录「全部0 keyframe」是错的。**2026-08-05 已完成数据层**:数据类字段按 TypeTree 顺序重排并补齐 5 个 `BlinkLight*`、新增 `LiveTimelineKeyPostFilmDataList`、WorkSheet 三字段、`AlterUpdate_PostFilm` 分发与插值、`PostFilmUpdateInfo`、`PostFilmRendererFeature` + `Resources/Shaders/PostFilm.shader`。**渲染层默认关闭**(`PostFilmRendererFeature._enableRendering = false`):song 1177 的 100 个 key 全是 Vignette 变体,而 `filmOptionParam`(取值如 (0.2,0.05)/(0,0.05)/(0.2,0.25))的几何语义**无法从资源中还原** —— 游戏自己的 PostFilm shader 不在 `shader` bundle 里(499 个 shader 按名字和属性签名都搜过),应在 app 本体的 resources.assets。要继续做只能对着参考视频标定 Width/Strength 两个系数 |
| 40 | Fade | 场景淡入淡出 | ❌ | ✅实测 `fadeKeys` **59/59首** / 243 keys。量小但覆盖全 |
| 41 | Particle ★(3) | 粒子系统发射速率 | ✅ | — |
| 42 | ParticleGroup ★(3) | 粒子组闪烁速率(双速率曲线) | ✅ | — |
| 43 | WashLight | 洗光灯 SetActive+颜色亮度 | ⚠️ | RaycastDistance/CameraProjectionSide/MulColor0未使用。**低优先级**:song 1177 仅 1 组 11 keys,真正的"洗光"效果由 ID 45 的 `*_wash_*` 组承担。字段名 `WashLightList`(大写 W)已用 A/B 探针确认;上游 katboi01 写作 `washLightList`,永远填不上,其 WashLightController 是死代码 |
| 44 | Laser | 激光灯变换+SetActive | ⚠️ | blinkRate/blinkOffset/rotateFollowCamera/RaycastDistance未实现。song 1177 该轨道为空 |
| 45 | BlinkLight ★(9) | 频闪灯亮灭周期/颜色/亮度 | ⚠️ | **灯光线最大权重**:全语料 19420 keys / 57首;song 1177 实测 27 组,一半以上组名是 `*_wash_*`。★(9) 是 song 1001 的数字,严重低估。2026-08-04 修:写入属性从 `_Color`(这些 shader 上不存在,写入是空操作,灯全渲染成黑)改为按存在性探测 `_BlinkLightColor`/`_MulColor0`/`_Color`。**仍未解**:`color0Array`/`powerArray` 恒为 10 项(调色板),与 renderer 数(可达 570)无关,灯→槽位的映射规则未逆出,暂统一取第 0 槽;`pattern` 语义未逆出,逐灯相位(U闪→M闪→A闪)未实现;`color1Array`/`LightBlendMode`/`isReverseHueArray` 未用。另注意部分 renderer 用 `StageTransmittedLightMask`/`StageMirrorBallShine`,**shader 里一个 Color 属性都没有**,BlinkLight 点不亮(对应未实现的 95/96 TransmittedLight 轨道) |
| 46 | UVScrollLight ★(1) | 材质UV滚动灯光效果 | ⚠️ | mulColor1/ColorType/CharacterIndex等未使用 |
| 47 | FacialToon | 角色卡通着色参数 | ❌ | ✅实测 `facialToonSet` **59/59首 / 6341 keys**(单曲最多600)。未实现轨道里数据量第二大 |
| 48 | GlobalLight ★(2) | 全局光方向/RimLight参数 | ✅ | — |
| 49 | GlobalFog ★(1) | 全局雾效(颜色/密度/范围) | ✅ | — |
| 50 | LightShafts ★(1) | 光轴/丁达尔效果 | ⚠️🔇 | 无LightShaftsController组件,仅数据 |
| 51 | MonitorCameraPos ★(1) | 舞台监控摄像机位置 | ❌ | ✅实测 `monitorCameraPosKeys` **存在**,34/59首 / 964 keys。旧记录「无字段」是错的。用途推测是 LED 大屏的副摄像机实时画面(IMAG),与 ID 10 配套 |
| 52 | MonitorCameraLookAt ★(1) | 舞台监控摄像机朝向 | ❌ | ✅实测 `monitorCameraLookAtKeys` **存在**,34/59首 / 971 keys。旧记录「无字段」是错的 |
| 53 | MultiCameraPos ★(2) | 多机位摄像机位置 | ✅ | — |
| 54 | MultiCameraLookAt ★(2) | 多机位摄像机朝向 | ✅ | — |
| 55 | EyeCameraPos | 眼部特写摄像机位置 | ❌ | — |
| 56 | EyeCameraLookAt | 眼部特写摄像机朝向 | ❌ | — |
| 57 | LensFlare | 镜头光晕 | ❌ | ✅实测 `lensFlareList` 45/59首 / 993 keys(单曲最多181)。舞台用 `CustomLensFlare` 脚本无源码;可先做 SetActive 层 |
| 58 | Environment ★(1) | 地板镜面反射/水面波纹/阴影/FovShift | ❌ | ✅实测 `environmentDataLists` 49/59首 / 1375 keys。**注意 schema 里另有独立字段 `MirrorReflectionDataList`**,两者不是一回事(上游 katboi01 把它俩混为一谈,按 name 分流)。核心是 Planar Reflection,工程量大 |
| 59 | SweatLocator ★(3) | 角色汗珠特效挂点 | ❌ | ✅实测 `sweatLocatorList` **存在**,32/59首 / 608 keys。旧记录「无字段」是错的 |
| 60 | Effect ★(8) | 特效Prefab加载/播放(3d/effect/live/) | ✅ | — |
| 61 | ColorCorrection | 色彩校正(饱和度/RGB曲线) | ⚠️ | depthRedCurve/Green/Blue/blendCurve/mode/selective/keyColor/targetColor无URP对应 |
| 62 | PreColorCorrection | 前置色彩校正 | ❌ | — |
| 63 | TiltShift | 移轴模糊 | ❌ | ✅实测 `tiltShiftKeys` **58/59首 / 1202 keys**(单曲最多127)。旧记录「全部0 keyframe」是错的 |
| 64 | A2U | 内部动作系统参数 | ❌ | 非视觉轨道 |
| 65 | A2UConfig | A2U配置 | ❌ | 非视觉轨道 |
| 66 | FlashPlayer | 全屏闪光播放 | ❌ | ✅实测 `flashPlayerKeys` 20/59首 / 50 keys。旧记录「全部0 keyframe」是错的,但确实量小 |
| 67 | Title | 歌曲名字幕显示 | ❌ | ✅实测 `titleKeys` 55/59首 / 137 keys |
| 68 | Spotlight3d ★(3) | 3D聚光灯 SetActive+颜色/强度 | ✅ | — |
| 69 | CharaNode | 角色骨骼节点位置控制 | ❌ | — |
| 70 | NodeScale ★(2) | 舞台节点缩放 | ❌ | ✅实测 `nodeScaleList` **存在**,**57/59首** / 541 keys。旧记录「无字段」是错的,且覆盖率远高于原估计 |
| 71 | Fluctuation | 画面波动/震颤效果 | ❌ | ✅实测 `FluctuationKeys` 51/59首 / 330 keys |
| 72 | CharaFootLight | 角色脚部补光灯 | ❌ | ✅实测 `charaFootLightKeys` **49/59首 / 340 keys**。旧记录「全部0 keyframe」是错的 |
| 73 | ChromaticAberration ★(1) | 色差后处理 | ⚠️ | redOffset/greenOffset/blueOffset(通道偏移)无URP内置对应;effectType未使用 |
| 74 | LightProjection ★(18!) | 投影灯(Gobo图案灯) | ❌ | ✅实测 `lightProjectionList` **37/59首 / 3793 keys**(单曲最多566)。URP 下 `Projector` 组件不工作,需 DecalProjector 或自写 |
| 75 | Billboard | 广告牌(始终面向摄像机的面片)【12/58首】 | ❌ | — |
---
## 多机位后处理
| ID | 名称 | 作用 | 状态 | Bug |
|----|------|------|------|-----|
| 76 | MultiCameraPostFilm ★(2) | 多机位全屏后期叠加 | ❌ | ✅实测 `postFilm1MultiCameraKeys` 51/59首 / 708 keys |
| 77 | MultiCameraPostEffectBloomDiffusion ★(2) | 多机位泛光 | ❌ | ✅实测 `postEffectBloomDiffusionMultiCameraKeys` **存在**,49/59首 / 381 keys。旧记录「无字段」是错的 |
| 78 | MultiCameraColorCorrection | 多机位色彩校正 | ❌ | — |
| 79 | MultiCameraTiltShift | 多机位移轴模糊 | ❌ | — |
| 80 | MultiCameraRadialBlur | 多机位径向模糊 | ❌ | — |
| 81 | MultiCameraPostEffectDOF | 多机位景深 | ❌ | — |
---
## 附加灯光 / 系统
| ID | 名称 | 作用 | 状态 | Bug |
|----|------|------|------|-----|
| 82 | AdditionalLight | 附加动态光源【22/58首】 | ❌ | — |
| 83 | MultiLightShadow | 多光源阴影 | ❌ | ✅实测 `MultiLightShadowKeys` 37/59首 / 183 keys。旧记录「全部0 keyframe」是错的 |
| 84 | MobControl ★(4) | 观众人群行为/动画控制 | ❌ | ✅实测 `MobControlKeys` **存在**,32/59首 / **2392 keys**(单曲最多495)。旧记录「无字段」是错的 |
| 85 | CyalumeControl ★(4) | 荧光棒颜色/同步控制 | ❌ | ✅实测 `CyalumeControlKeys` **存在**,33/59首 / **2372 keys**(单曲最多496)。旧记录「无字段」是错的。`CyalumeController3D.cs` 已存在,只差接时间轴 |
| 86 | CameraMotion | 摄像机动作曲线轨道 | ❌ | — |
| 87 | WaveObject | 波浪变形物体 | ❌ | — |
| 88 | CharaWind | 角色风效(头发/裙摆) | ❌ | ✅实测 `charaWind` 24/59首 / 744 keys |
| 89 | CharaParts | 角色部件显隐控制 | ❌ | — |
| 90 | CameraCutNo | 镜头编号标记(元数据) | ❌ | — |
| 91 | ToneCurve | 色调曲线(ACES/自定义) | ❌ | — |
| 92 | Exposure | 曝光值控制 | ❌ | — |
| 93 | Vortex | 旋涡/扭曲屏幕效果 | ❌ | — |
| 94 | CharaCollision | 角色碰撞辅助(非视觉) | ❌ | — |
| 95 | TransmittedLight | 透射光(次表面散射) | ❌ | — |
| 96 | TransmittedLightMask | 透射光遮罩 | ❌ | — |
| 97 | Voice | 语音切换控制 | ❌ | — |
| 98 | LipSyncPatternRange | 口型模式范围定义 | ❌ | ✅实测 `LipSyncPatternRangeKeys` **存在** |
| 99 | LipSyncPattern | 口型模式数据 | ❌ | ✅实测 `LipSyncPatternDataList` **存在** |
| 100 | LensDistortion | 镜头桶形/枕形畸变 | ❌ | — |
| 101 | CharaNodeOffset | 角色骨骼节点偏移 | ❌ | — |
| 102 | TransparentCamera | 透明度摄像机控制 | ❌ | — |
---
## 统计摘要
| 状态 | 数量 |
|------|------|
| ✅ 完整 | 21 |
| ⚠️ 部分实现 | 7 |
| ⚠️🔇 仅数据 | 2 |
| 🔴 Bug(已修)| 1 |
| ❌ 未实现 | 72 |
## 已知未解问题(2026-08-05)
按「是否有 ground truth」分类。**缺依据的一律不实现**,代码里保留 TODO 注释而不是猜一个值上去。
### 有依据、可以直接做
| 问题 | 依据 |
|---|---|
| ~~BlinkLight 调色板槽位映射~~ ✅ 2026-08-05 解决 | 槽位号 = 渲染器自身或最近祖先的 `lightNNN_`/`alphaNNN_` 名字前缀。live10149 全组实测「前缀种数 N」与「槽 0..N-1 的去重颜色数」精确相等,槽 N..9 一律白色填充,7 组无一例外。此前统一取第 0 槽,于是镜面球整个发粉红、truss wash 丢掉黄绿蓝紫四色 |
| BlinkLight `pattern` 语义 | 实测有 0 和 2 两种;相位公式是推测,`pattern != 0` 时按 `cycle * i / N` 错开 |
| BgColor1 剩余 4 组 | `BgBL`(256 keys) / `FollowSpotColor`(16) / `Shadow`(2) / `mob_00`(2) 不是物件名也不是材质名 |
| BgColor1 的 `power` → `_ColorPower`? | `_ColorPower` 属性确实存在,但映射关系无证据,已撤回 |
### 缺 ground truth,已禁用
| 问题 | 为什么查不到 |
|---|---|
| **舞台自带 Animation 的播放**(283 个组件,全曲全场景不播) | 见下方专条 |
| PostFilm 的 `filmOptionParam` 几何语义 | 游戏自己的 PostFilm shader 不在 `shader` bundle(499 个按名字和属性签名都搜过),应在 app 本体 `resources.assets` |
| MonitorControl 的 `dispID` → 资源 | 内容资源族已定位为 `live/uvmovie/gal_uvmovie_<songid>_<NNN>`(全库 219 个目录),但 song 1177 只有 `_light` 没有 `_001`,且 `monitorCameraPosKeys` 为 0,两条路都不通 |
#### 专条:舞台自带 Animation 为什么**不播**(2026-08-07 决定撤除实现)
**现状**:舞台上 283 个 legacy `Animation` 组件一个都不播,这是**有意的**,不是漏了。
迪斯科灯球不转、wash 灯不扫、霓虹灯牌不动,都属于这一条。
**机制**:这些组件全部 `playAutomatically = true`,但默认 clip(`m_Animation`)**全是空的**。
Unity 的 playAutomatically 播的是默认 clip,为空就什么都不播 —— 原版靠
`Gallop.Live.AnimationObjectController` 显式 `Play("clip名")`。
**为什么补不了**:`AnimationObjectController` 实测 **282 个实例、零序列化字段**。
它是纯行为类,「播哪个 clip / 该不该播 / 播多快」全在方法体里,而方法体随 IL2CPP 元数据
一起拿不到(见 `CLAUDE.md` → Blocked)。**按原签名重建它拿不到任何信息** ——
这条路是死的,别被 282 这个实例数勾回去。
**素材本身是全可读的**,这正是容易上当的地方:clip 曲线、时长、wrapMode 全能 dump。
例如镜面球那条是恒定 −179.50°/s 绕 Y、2.0 s 一圈;wash_ground 那条是 `swing_*` 节点
90°→0°→90°、2.0 s 一个来回、12 盏同步。**但「素材是什么」推不出「原版何时以何速播它」。**
**试过一版替代品 `StageAnimationPlayer`,已整体撤除。** 它的策略是「只有一个 clip 就播」,
错在把「这物件只有一个**状态**」当成了「这个状态**一直在播**」—— 后者恰恰是那个缺失的类
每帧做的决定。实机后果:一排地面 wash 灯 2 秒一个来回疯狂摆头(原版是不扫只闪),
镜面球虽然该转但转速无从校验。缩到最后它只让一颗球转,且转得对不对说不清 ——
**一个转错的替代品比不转更糟:既错,又让人以为这块已经做过了。**
**唯一值得留下的结论**(将来任何人重做都适用):
驱动舞台动画**不要用 `Animation.Play()`** —— 那跑 Unity 自己的时钟,live 暂停时舞台照转、
拖动进度条不跟随。正确做法是每帧把 `AnimationState.time` 设成
`LiveTimelineControl.currentLiveTime`(单位是秒)再 `Sample()`,与演出时间同源。
**什么条件下可以重做**:拿到参考视频逐首标定(属于经验值,不是 ground truth,且大概率
一舞台一套),或者 IL2CPP 元数据解锁(见 `CLAUDE.md` → Blocked)。在那之前**不做**。
### 与时间轴无关的渲染问题
| 现象 | 状态 |
|---|---|
| 舞台地板长期偏亮发白 | ✅ 2026-08-06 解决。**白的不是文档里记的那三个物件** —— 是与 `plane_000` 同位同尺度、叠在一起的 `mirror_a`(同在 `pfb_env_live10149_main000` 下,局部变换全为单位)。它用 `Cygames/MirrorAndShadow/ReceiveMirror`,`_ReflectionRate` 默认 1.0 而 `_ReflectionTex` 从没赋值,Unity 对未绑定采样器代入白贴图 = 满强度白反射。实现 `Gallop.MirrorReflection` 接上反射 RT 后恢复正常。**教训见 `CLAUDE.md`「现象描述不是 ground truth」**:整轮排查都围着文档里那三个名字转,那个「`_EnvRate` 归零无变化」的实验切的是另一批物件 |
| 小灯泡渲染成黑块 / 光柱变实心黑墙 | 🔧 2026-08-07 **找到根因并已修**(待实机确认):Gallop 发光类 shader 的混合状态写作 `Blend [_SrcBlend] [_DstBlend]`,从材质属性读;而 live10149 **26/26 个材质**都被盖上了 URP Lit 的属性表,`_DstBlend` 是 URP Lit 不透明预设的 Zero。全库 16 个声明该属性的 shader 里 **14 个作者默认是 One/One(加法)**,于是**凡是当前 shader 真读这两个属性的**光柱/闪光/投影都被按不透明画 —— 加法下「颜色为黑=不可见」变成了「实心黑墙」。`StageController.RestoreAuthoredBlendState()` 已把这两个属性恢复成 shader 声明的默认值,**实机确认修复**。⚠ 两个方向都别照抄数字:**受害材质**比存档表窄(31 个里只有 1 个真被 shader 读到 —— `mtl_env_live10149_blinklight000` / `LightBlinkBlend`,`_DstBlend` 0→1,其余存的是无人问津的过期条目);但**受害渲染器**远比「1 个材质」多 —— 材质高度共享,那一个铺满 27 个 BlinkLight 组约 1450 个渲染器,所以「小灯泡渲染成黑块」和地面 wash 黑楔子是同一个根因,实机确认一并修好。**注**:`StageTransmittedLightMask`/`StageMirrorBallShine` 无颜色属性是另一回事,仍未解 |
| LED 大屏黑屏 | MonitorControl (10) 未实现,见上 |
---
## 优先级排序
**2026-08-04 用 `tools/` 全量重测后重排。** 依据 = 实测数据量 × 覆盖率 × 工作性质。
全量表:`tools/out/scan_summary.txt`。
### 第一优先:数据早就在手,只是没用上(性价比最高)
| 轨道 | 实测数据量 | 难度 | 说明 |
|---|---|---|---|
| **BgColor1 (8) 舞台分支** | **43617 keys / 59首** | ★☆☆~★★☆ | 全语料最大的轨道。分发过滤已拆,剩下确认舞台侧 shader 属性 |
| **BlinkLight (45) 补字段** | **19420 keys / 57首** | ★★☆ | 灯光线最大权重,含全部 `*_wash_*` 效果 |
| Laser (44) / UVScrollLight (46) 补字段 | 2661 keys / 33首 | ★☆☆ | 纯管道 |
### 第二优先:URP 后处理 Feature(路径见 `live-shader-todo.md`)
| 轨道 | 实测数据量 | 路径 |
|---|---|---|
| **PostFilm (39)** | **~20200 keys / 59首**(三个字段合计) | B,最大的一块 |
| PostEffectDOF (13) | 3879 keys / 59首 | A(URP 内置 DepthOfField) |
| VolumeLight/SunShafts (37) | 2048 keys / 58首 | B,数据类已有,字段表齐全 |
| PostEffectBloomDiffusion (14) | 1969 keys / 59首 | B |
| RadialBlur (15) | 1667 keys / 59首 | B |
| TiltShift (63) | 1202 keys / 58首 | B |
### 第三优先:只差 C# 字段声明 + handler(bundle 里数据齐备)
FacialToon(47, **6341 keys/59首**) > MonitorControl(10, 3911/55首,dispID 语义仍未知) > CameraLayer(16, 3306/58首) > MobControl(84, 2392/32首) > CyalumeControl(85, 2372/33首,`CyalumeController3D.cs` 已存在) > Props(35, 1940/28首) > Animation(29, 1575/39首) > MonitorCameraPos/LookAt(51/52, ~1900/34首) > CharaWind(88, 744/24首) > AdditionalLight(82, 657/23首) > SweatLocator(59, 608/32首) > NodeScale(70, 541/57首) > Fluctuation(71, 330/51首) > CharaFootLight(72, 340/49首) > Fade(40, 243/59首) > Title(67, 137/55首) > FacialNoise(26)/CharaMotionNoise(27)
### 第四优先:要从零搭渲染系统 ★★★
**LensFlare(57, 993/45首) 应当从这一档移到第一档** —— 2026-08-07 dump 确认它不需要「从零搭」:`CustomLensFlare` 的 `Flare` 指向 `Gallop.RenderPipeline.LensFlareData`(可按签名重建的 ScriptableObject),字段是 Unity legacy Flare 的逐字翻版,URP 侧有 `LensFlareComponentSRP` 现成对应。
LightProjection(74, 3793/37首,URP 下 Projector 不工作) > Environment(58, 1375/49首,注意还有独立的 `MirrorReflectionDataList`;**Planar Reflection 本身已不用从零搭** —— `Gallop.MirrorReflection` 已实现,缺的是把轨道关键帧接上去)
> `MirrorBallProjector` 不在此列但值得记一笔:它是**卡住**而不是没做。shader 需要
> `_MirrorBallPosWS`/`_MirrorBallScale`/`_MirrorBallRotateValue`,而组件里没有任何指向灯球的
> 引用字段,4 个 projector 全在原点且共用一个材质 —— 绑定关系只存在于拿不到的方法体里。
> 顺带:灯球转速已不是未知(clip 恒定 −179.50°/s,见 `LIVE_DEV_MAP.md`)。
### 不该做
**HdrBloom (38)** —— 0/59 首,却已经实现了,是死代码,建议删。
其余确认为空:`other4EyeTrackKeys`、`ScreenCaptureDataList`、`tailMotionDataList`。
A2U / A2UConfig / CameraCutNo —— 非视觉轨道。
> 之前「PostFilm 到底有没有数据」的矛盾已解决:**`live-shader-todo.md` 是对的**,本文件旧版记的「全部 0 keyframe」是错的。