Live: implement MirrorReflection — and it resolves the stage floor blow-out

Rebuilt Gallop.MirrorReflection from its original signature, so Unity deserializes the real
fields out of the bundle rather than anything being inferred. All 13 field names are copied
verbatim from the TypeTree, including the original's typo `_mirrorCliipPlaneOffset` — spelling
it correctly would silently receive no data.

The receiving end was looked up, not guessed. mtl_env_liveNNNNN_mirror000 uses
Cygames/MirrorAndShadow/ReceiveMirror, which declares exactly four properties — _MainTex,
_ReflectionTex, _Color, _ReflectionRate — and the component's _mirrorReflectionColor and
_mirrorReflectionRate line up with the last two name for name. Texture size, cull mask, clear
colour and clip-plane offset all come straight from the serialized values, which differ per
stage (256/12544 on live10149, 512/12288 on live10132), confirming real data is arriving.

Deliberately not implemented, because there is no ground truth for them:
  * _direction is an enum whose meaning is unrecoverable — IL2CPP metadata is encrypted.
    The mirror plane is derived from the mesh's own normals instead, which is geometry rather
    than a guess, and yields (0,1,0) on both stages. The field's value is logged as unused.
  * _mirrorDistortionTileOffset / _mirrorDistortionPower belong to the ReceiveDistortionMirror
    variant; these materials use the plain one.
  * _useMirrorTextureScale / _mirrorTextureScaleForBaseCamera — semantics unknown.

Rendering goes through RenderPipelineManager.beginCameraRendering and RenderSingleCamera.
Camera.Render() is not supported under SRP and would have failed silently.

This also settles the long-standing floor blow-out, and the diagnosis I had been running was
aimed at the wrong objects the entire time. ReceiveMirror's _ReflectionRate defaults to 1.0
while _ReflectionTex had never been assigned, and Unity substitutes a white texture for an
unbound sampler — a full-strength white reflection, i.e. a solid white floor. Binding the
reflection RT fixes it.

CLAUDE.md recorded the blown-out objects as plane_000 / stage_object_001 / specular_002 and I
took that as given, spending four rounds eliminating mechanisms on those three. The actual
culprit is mirror_a, which sits at the same position and scale under the same parent — visually
indistinguishable, entirely different name. The earlier "_EnvRate = 0 changes nothing" result
was consistent all along: it was toggling a different set of objects.

Both files gain the lesson: field values and shader property tables are hard data, but a
document's description of which object looks wrong is not, and a visual bug should start by
confirming which object the symptom belongs to.

Water surfaces are absent, not broken — none of the three downloaded stages has a ReceiveWater
material; each has exactly one ReceiveMirror.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
ritenseki
2026-08-05 14:45:12 +08:00
parent 4d7a7c3373
commit 546d8ef7ba
5 changed files with 260 additions and 3 deletions
@@ -478,8 +478,11 @@ namespace Gallop.Live
// 材质原值上、或者对应完全不同的东西。2026-08-05 试写过一版,无法判断对错,
// 遂按「缺依据即不做」撤回。t.hasColorPower 保留,供将来确认后启用。
//
// (注:舞台地板 plane_000 / stage_object_001 / specular_002 长期偏亮发白
// 是**早于本改动就存在**的问题,与 _ColorPower 无关,另行排查。)
// (注:曾长期以为「地板 plane_000 / stage_object_001 / specular_002 发白」,
// 2026-08-05 查明白的其实是**叠在同一位置的 mirror_a** —— 它用
// Cygames/MirrorAndShadow/ReceiveMirror,_ReflectionRate 默认 1.0 而
// _ReflectionTex 从没赋值,Unity 代入白贴图 = 满强度白反射。
// 实现 Gallop.MirrorReflection 后已解决。与 _ColorPower 无关。)
t.renderer.SetPropertyBlock(_bgColor1Block);
}
@@ -0,0 +1,205 @@
using UnityEngine;
using UnityEngine.Rendering;
using UnityEngine.Rendering.Universal;
namespace Gallop
{
/// <summary>
/// 平面镜反射。live10149 上 1 个实例,宿主是 <c>mirror_a</c>。
///
/// 按原签名重建(bundle MonoScript: ClassName=MirrorReflection, Namespace=Gallop,
/// AssemblyName=umamusume),所以下面的字段由 Unity 从 bundle 直接反序列化,不是猜的。
/// 字段名逐字照抄 TypeTree —— 包括原作的拼写错误 `_mirrorCliipPlaneOffset`,
/// 改成正确拼写反而收不到数据。
///
/// **接收端是查出来的,不是猜的**:`mtl_env_live10149_mirror000` 用
/// `Cygames/MirrorAndShadow/ReceiveMirror`,该 shader 只有 4 个属性 ——
/// `_MainTex` / `_ReflectionTex` / `_Color` / `_ReflectionRate`,
/// 而组件字段 `_mirrorReflectionColor`、`_mirrorReflectionRate` 与后两者逐字对应。
///
/// live10149 实测值:textureSize=256、reflectionRate=1、reflectionColor=白、
/// renderLayers=12544、useBackgroundColor=1、backgroundColor=透明黑、
/// clipPlaneOffset=0、baseCamera=null(回落当前直播摄像机)。
///
/// ── 没有依据、因此没有实现的部分 ──
///
/// `_direction` 枚举,值为 0,**语义未知**。IL2CPP 元数据取不到(见 CLAUDE.md),
/// 所以无法知道它是不是在选镜面法线轴。这里不使用它,
/// 改为**从网格自身的法线推导镜面**——那是几何事实,不是猜测。
/// `_mirrorDistortion*` 属于 ReceiveDistortionMirror 变体,本材质用的是普通版,不参与。
/// `_useMirrorTextureScale` / `_mirrorTextureScaleForBaseCamera` 语义未知,未使用。
/// </summary>
public class MirrorReflection : MonoBehaviour
{
// ↓ 名字必须与 bundle TypeTree 逐字一致(含原作拼写错误)
[SerializeField] private LayerMask _renderLayers;
[SerializeField] private int _mirrorTextureSize = 256;
[SerializeField] private float _mirrorCliipPlaneOffset;
[SerializeField] private Camera _baseCamera;
[SerializeField] private float _mirrorReflectionRate = 1f;
[SerializeField] private Vector4 _mirrorDistortionTileOffset;
[SerializeField] private Vector4 _mirrorDistortionPower;
[SerializeField] private int _direction;
[SerializeField] private Color _mirrorReflectionColor = Color.white;
[SerializeField] private Color _backgroundColor;
[SerializeField] private bool _useBackgroundColor;
[SerializeField] private bool _useMirrorTextureScale;
[SerializeField] private float _mirrorTextureScaleForBaseCamera = 1f;
private Camera _mirrorCamera;
private RenderTexture _reflectionTexture;
private Renderer _renderer;
private MaterialPropertyBlock _block;
private bool _rendering;
private bool _logged;
private static readonly int kReflectionTex = Shader.PropertyToID("_ReflectionTex");
private static readonly int kReflectionRate = Shader.PropertyToID("_ReflectionRate");
private static readonly int kColor = Shader.PropertyToID("_Color");
// URP 下不能用 Camera.Render()(SRP 会直接报错、什么都不画),
// 必须挂 beginCameraRendering 拿到 ScriptableRenderContext 再 RenderSingleCamera。
private void OnEnable() => RenderPipelineManager.beginCameraRendering += OnBeginCamera;
private void OnDisable()
{
RenderPipelineManager.beginCameraRendering -= OnBeginCamera;
if (_mirrorCamera != null) { DestroyImmediate(_mirrorCamera.gameObject); _mirrorCamera = null; }
if (_reflectionTexture != null) { DestroyImmediate(_reflectionTexture); _reflectionTexture = null; }
}
private Camera ResolveBaseCamera()
{
if (_baseCamera != null) return _baseCamera;
if (Gallop.Live.Director.instance != null)
{
var t = Gallop.Live.Director.instance.MainCameraTransform;
if (t != null)
{
var c = t.GetComponent<Camera>();
if (c != null) return c;
}
}
return Camera.main;
}
/// <summary>
/// 镜面法线。`_direction` 的枚举含义拿不到,所以不用它,改从网格法线推导 ——
/// 平面镜的网格法线是确定的几何量,比猜枚举可靠。取不到网格时回落 transform.up。
/// </summary>
private Vector3 ResolveNormal()
{
var mf = GetComponent<MeshFilter>();
var mesh = mf != null ? mf.sharedMesh : null;
if (mesh != null && mesh.normals != null && mesh.normals.Length > 0)
return transform.TransformDirection(mesh.normals[0]).normalized;
return transform.up;
}
private void OnBeginCamera(ScriptableRenderContext ctx, Camera cam)
{
if (_rendering) return; // 防止镜中镜递归
if (cam == _mirrorCamera) return;
_renderer ??= GetComponent<Renderer>();
if (_renderer == null) return;
Camera baseCam = ResolveBaseCamera();
if (baseCam == null || cam != baseCam) return;
EnsureResources(baseCam);
if (_mirrorCamera == null || _reflectionTexture == null) return;
Vector3 normal = ResolveNormal();
Vector3 pos = transform.position;
float d = -Vector3.Dot(normal, pos) - _mirrorCliipPlaneOffset;
Vector4 plane = new Vector4(normal.x, normal.y, normal.z, d);
Matrix4x4 reflection = CalculateReflectionMatrix(plane);
_mirrorCamera.transform.position = reflection.MultiplyPoint(baseCam.transform.position);
_mirrorCamera.transform.rotation = baseCam.transform.rotation;
_mirrorCamera.worldToCameraMatrix = baseCam.worldToCameraMatrix * reflection;
// 斜投影:近裁剪面贴合镜面,避免映出镜子背后的东西
Vector4 clipPlane = CameraSpacePlane(_mirrorCamera, pos, normal, 1f);
_mirrorCamera.projectionMatrix = baseCam.CalculateObliqueMatrix(clipPlane);
_mirrorCamera.fieldOfView = baseCam.fieldOfView;
_mirrorCamera.nearClipPlane = baseCam.nearClipPlane;
_mirrorCamera.farClipPlane = baseCam.farClipPlane;
_rendering = true;
GL.invertCulling = true; // 镜像后绕序反了
#pragma warning disable CS0618
UniversalRenderPipeline.RenderSingleCamera(ctx, _mirrorCamera);
#pragma warning restore CS0618
GL.invertCulling = false;
_rendering = false;
_block ??= new MaterialPropertyBlock();
_renderer.GetPropertyBlock(_block);
_block.SetTexture(kReflectionTex, _reflectionTexture);
_block.SetFloat(kReflectionRate, _mirrorReflectionRate);
_block.SetColor(kColor, _mirrorReflectionColor);
_renderer.SetPropertyBlock(_block);
if (!_logged)
{
_logged = true;
Debug.Log($"[MirrorReflection] '{name}' size={_mirrorTextureSize} rate={_mirrorReflectionRate} " +
$"layers={(int)_renderLayers} baseCam={(baseCam != null ? baseCam.name : "<null>")} " +
$"normal={normal} | _direction={_direction}(枚举语义未知,未使用)");
}
}
private void EnsureResources(Camera baseCam)
{
int size = Mathf.Max(16, _mirrorTextureSize);
if (_reflectionTexture == null || _reflectionTexture.width != size)
{
if (_reflectionTexture != null) DestroyImmediate(_reflectionTexture);
_reflectionTexture = new RenderTexture(size, size, 24) { name = $"MirrorRT_{name}" };
_reflectionTexture.Create();
}
if (_mirrorCamera == null)
{
var go = new GameObject($"MirrorCamera_{name}") { hideFlags = HideFlags.HideAndDontSave };
_mirrorCamera = go.AddComponent<Camera>();
_mirrorCamera.enabled = false; // 只手动 Render()
}
_mirrorCamera.targetTexture = _reflectionTexture;
_mirrorCamera.cullingMask = _renderLayers;
if (_useBackgroundColor)
{
_mirrorCamera.clearFlags = CameraClearFlags.SolidColor;
_mirrorCamera.backgroundColor = _backgroundColor;
}
else
{
_mirrorCamera.clearFlags = baseCam.clearFlags;
_mirrorCamera.backgroundColor = baseCam.backgroundColor;
}
}
private static Matrix4x4 CalculateReflectionMatrix(Vector4 p)
{
Matrix4x4 m = Matrix4x4.identity;
m.m00 = 1f - 2f * p.x * p.x; m.m01 = -2f * p.x * p.y; m.m02 = -2f * p.x * p.z; m.m03 = -2f * p.w * p.x;
m.m10 = -2f * p.y * p.x; m.m11 = 1f - 2f * p.y * p.y; m.m12 = -2f * p.y * p.z; m.m13 = -2f * p.w * p.y;
m.m20 = -2f * p.z * p.x; m.m21 = -2f * p.z * p.y; m.m22 = 1f - 2f * p.z * p.z; m.m23 = -2f * p.w * p.z;
return m;
}
private Vector4 CameraSpacePlane(Camera cam, Vector3 pos, Vector3 normal, float sign)
{
Vector3 offsetPos = pos + normal * _mirrorCliipPlaneOffset;
Matrix4x4 m = cam.worldToCameraMatrix;
Vector3 cpos = m.MultiplyPoint(offsetPos);
Vector3 cnormal = m.MultiplyVector(normal).normalized * sign;
return new Vector4(cnormal.x, cnormal.y, cnormal.z, -Vector3.Dot(cpos, cnormal));
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 74ba506727844be2b5d4af3f9664c42c
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+14
View File
@@ -173,6 +173,20 @@ MonoBehaviour 实例脚本为空。**但这些脚本是可以「接管」的**
- **速度/系数一类的常量** —— 如果原版在代码里对动画做了调速,那个倍率无从得知。
- 未随包发布的 shader(PostFilm 的定义性 shader 就在 app 二进制里)。
### ⚠ 现象描述不是 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` 归零无变化」的实验因此也是误导:切的是另一批物件。
**排查视觉问题的第一步应该是确认现象归属于哪个物件**(让代码把可疑物件隔离/高亮),
而不是从文档里的名字出发。
### 间接手段(已被证明有效)
拿不到直接答案时,**相关性交叉验证**多次奏效,比猜测可靠得多:
+25 -1
View File
@@ -359,7 +359,31 @@ live10149 上的受害者:`light001_wash_ground_a`(×4)、`swing_wash_ground_a
跟着祖先一起显隐。容器照常关,子物件保持 prefab 状态。判重键也统一成 `tmp_name`。
纯增益,没有「某盏灯从此不亮」的倒退风险 —— 因为不再有子物件被单独关掉。
### C4. 舞台地板 / 高光面发白 —— 已定位到 shader,只剩一个运行时未知项
### C4. 舞台地板发白 —— ✅ 已解决,但**我整段都在查错的物件**
**结论**:发白的是 `mirror_a`,它用 `Cygames/MirrorAndShadow/ReceiveMirror`,
该 shader 的 `_ReflectionRate` 默认 **1.0**,而 `_ReflectionTex` 从来没有人赋值 ——
**Unity 对未绑定的贴图采样器代入白贴图**,于是满强度白色反射 = 一块纯白地板。
实现 `MirrorReflection`(把平面反射 RT 接上 `_ReflectionTex`)之后,地板变成了正常镜面。
**我错在哪**:CLAUDE.md 记着「地板 `plane_000` / `stage_object_001` / `specular_002` 发白」,
我**直接采信了这个前提**,整段排查都围绕这三个物件(它们用 `DefaultEnvMapNoAmbient`),
从没验证过屏幕上白的那块到底是哪个物件。
`mirror_a` 与 `plane_000` **同位同尺度**(同在 `pfb_env_live10149_main000` 下,
局部变换全为单位),是叠在一起的两层地面 —— 肉眼分不出,名字却完全不同。
这也解释了那个 F9 实验为什么"无变化":**我切的是另一批物件的 `_EnvRate`**,
自然影响不到 `mirror_a`。当时我把这个结果当成"env 反射项无关"的证据,
其实它只说明"那三个物件不是白的来源"。
**教训**:文档里的现象描述("哪个物件看起来不对")和字段值、shader 属性表不一样,
**它没有经过验证**。排查前先确认现象归属于哪个物件,再去查那个物件。
下次遇到"某某看起来不对",第一步应该是让代码把可疑物件高亮/隔离,而不是从名字出发。
以下为定位过程的记录(结论已被上面推翻,保留是因为排除法本身有效)。
### C4-旧. 曾经的排查路径
CLAUDE.md 长期记着「地板 `plane_000` / `stage_object_001` / `specular_002` 偏亮发白,
早于时间轴的改动就存在」。这次查清了它们的共同点。