You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Unity:无前置Update的FixedUpdate中带string的ref struct转换失败

技术分析与解决方案:Unity中带引用类型字段的RefStruct在无前置Update的FixedUpdate中转写失效问题

问题背景与现象

为网络回滚功能存储帧快照数据,采用泛型ref struct负责数据转写,非泛型ref struct存储数据(目标集合仅支持非托管数据)。

核心问题:当转换操作在未被Update前置的FixedUpdate中执行时,若被转换的ref struct包含string(或任意引用类型)字段,转写会失效。例如测试中Frame 0的KeyedRefStruct转写后得到值为0,而非预期的5。

测试验证结论

  • 控制台程序(目标.NET Standard 2.1,Release模式)执行转写无异常
  • 首次Update调用后,只要Update调用频率不低于FixedUpdate,转写功能恢复正常
  • 不含string字段的ref struct转写全程无问题
  • 仅在使用带string字段的KeyedRefStruct且FixedUpdate无前置Update时触发问题
  • 移除PlayerLoop中的FixedUpdateLoop并手动模拟物理时,转写功能正常

复现代码

基础非泛型RefStruct

using System;

public readonly ref struct RefStruct
{
    public RefStruct(Span<byte> span)
    {
        Span = span;
    }

    public readonly Span<byte> Span;
}

泛型RefStruct

using System;
using System.Runtime.InteropServices;

public readonly ref struct RefStruct<T> where T : unmanaged
{
    public RefStruct(T state)
    {
        State = state;
    }

    public readonly T State;

    public static implicit operator T(RefStruct<T> snapshot)
    {
        return snapshot.State;
    }

    public static implicit operator RefStruct(RefStruct<T> snapshot)
    {
        var state = snapshot.State;
        Span<byte> span = MemoryMarshal.Cast<T, byte>(MemoryMarshal.CreateSpan(ref state, 1));
        return new RefStruct(span);
    }

    public static implicit operator RefStruct<T>(RefStruct snapshot)
    {
        var span = snapshot.Span;
        T state = MemoryMarshal.Cast<byte, T>(span)[default];
        return new RefStruct<T>(state);
    }
}

带Key的非泛型KeyedRefStruct

using System;

public readonly ref struct KeyedRefStruct
{
    public KeyedRefStruct((int, string) key, Span<byte> span)
    {
        Key = key;
        Span = span;
    }

    public readonly (int, string) Key;
    public readonly Span<byte> Span;
}

带Key的泛型KeyedRefStruct

using System;
using System.Runtime.InteropServices;

public readonly ref struct KeyedRefStruct<T> where T : unmanaged
{
    public KeyedRefStruct((int, string) key, T state)
    {
        Key = key;
        State = state;
    }

    public readonly (int, string) Key;
    public readonly T State;

    public static implicit operator T(KeyedRefStruct<T> snapshot)
    {
        return snapshot.State;
    }

    public static implicit operator KeyedRefStruct(KeyedRefStruct<T> snapshot)
    {
        var state = snapshot.State;
        Span<byte> span = MemoryMarshal.Cast<T, byte>(MemoryMarshal.CreateSpan(ref state, 1));
        return new KeyedRefStruct(snapshot.Key, span);
    }

    public static implicit operator KeyedRefStruct<T>(KeyedRefStruct snapshot)
    {
        var span = snapshot.Span;
        T state = MemoryMarshal.Cast<byte, T>(span)[default];
        return new KeyedRefStruct<T>(snapshot.Key, state);
    }
}

测试用MonoBehaviour

using System;
using UnityEngine;

public class RefStructConverter : MonoBehaviour
{
    public float testValue = 5f;
    public bool InspectRefSpan;

    int frame = default;

    private void FixedUpdate()
    {
        if (frame <= 2)
        {
            RefStructTest(testValue);
            KeyedRefStructTest(testValue);
        }

        frame++;
    }

    void RefStructTest(float value)
    {
        float originalValue = value;
        RefStruct<float> originalRefStruct = new RefStruct<float>(originalValue);
        RefStruct castedRefStruct = originalRefStruct;
        string inspectionResults = InspectRefSpan ? " bytes: " + ContentsToString(castedRefStruct.Span) : default;
        RefStruct<float> recastedRefStruct = castedRefStruct;
        float castedValue = recastedRefStruct;
        Debug.Log($"Frame {frame} RefStruct Expected value: {originalValue} Actual value: {castedValue} {inspectionResults}");
    }

    void KeyedRefStructTest(float value)
    {
        float originalValue = value;
        KeyedRefStruct<float> originalRefStruct = new KeyedRefStruct<float>(default, originalValue);
        KeyedRefStruct castedRefStruct = originalRefStruct;
        string inspectionResults = InspectRefSpan ? " bytes: " + ContentsToString(castedRefStruct.Span) : default;
        KeyedRefStruct<float> recastedRefStruct = castedRefStruct;
        float castedValue = recastedRefStruct;
        Debug.Log($"Frame {frame} KeyedRefStruct Expected value: {originalValue} Actual value: {castedValue} {inspectionResults}");
    }

    string ContentsToString<T>(Span<T> span)
    {
        string str = string.Empty;

        str += "[";

        for (int i = default; i < span.Length; i++)
        {
            str += $"{span[i]}";

            if (i < span.Length - 1)
            {
                str += ", ";
            }
            else
            {
                str += "]";
            }
        }

        return $"{span.Length} element(s): {str}";
    }
}

输出结果

Frame 0 RefStruct Expected value: 5 Actual value: 5
Frame 0 KeyedRefStruct Expected value: 5 Actual value: 0
Frame 1 RefStruct Expected value: 5 Actual value: 5
Frame 1 KeyedRefStruct Expected value: 5 Actual value: 5
Frame 2 RefStruct Expected value: 5 Actual value: 5
Frame 2 KeyedRefStruct Expected value: 5 Actual value: 5

技术分析

问题核心源于Unity PlayerLoop首次FixedUpdate(无前置Update)的栈帧行为差异,结合引用类型字段对栈内存管理的干扰:

  1. 转写逻辑中,MemoryMarshal.CreateSpan(ref state, 1)基于栈上临时变量state创建Span,该Span的生命周期完全绑定到临时变量的栈帧。
  2. 带引用类型字段的KeyedRefStruct内存布局包含引用指针,在Unity首次FixedUpdate的特殊栈环境下,临时变量state的栈内存会被提前回收/覆盖(比如被引用类型的相关操作占用),导致后续读取Span时得到无效值。
  3. 不含引用类型的RefStruct栈内存管理逻辑简单,不会出现提前覆盖;首次Update执行后,PlayerLoop栈环境恢复正常,内存管理逻辑稳定,转写功能随之正常。
  4. 控制台环境的栈管理逻辑与Unity PlayerLoop不同,因此不会触发该问题。

解决方案

方案1:确保首次FixedUpdate前执行Update

在游戏启动阶段,强制保证首次FixedUpdate前至少执行一次Update:

private bool hasRunFirstUpdate = false;

private void Update()
{
    hasRunFirstUpdate = true;
}

private void FixedUpdate()
{
    if (!hasRunFirstUpdate) return;
    // 执行转写逻辑
}

方案2:修改转写逻辑,使用固定栈空间创建Span

将临时变量存储到stackalloc分配的固定栈空间中,避免内存被提前覆盖:

// 修改KeyedRefStruct<T>到KeyedRefStruct的隐式转换
public static implicit operator KeyedRefStruct(KeyedRefStruct<T> snapshot)
{
    Span<T> stateSpan = stackalloc T[1];
    stateSpan[0] = snapshot.State;
    Span<byte> span = MemoryMarshal.Cast<T, byte>(stateSpan);
    return new KeyedRefStruct(snapshot.Key, span);
}

同理,将RefStruct<T>到RefStruct的转换逻辑也改为使用stackalloc,统一内存管理方式。

方案3:替换引用类型Key为非托管类型

若业务允许,将KeyedRefStruct中的(int, string)Key替换为纯非托管类型(如int或自定义unmanaged struct),消除引用类型对栈内存的干扰:

public readonly ref struct KeyedRefStruct
{
    public KeyedRefStruct(int key, Span<byte> span)
    {
        Key = key;
        Span = span;
    }

    public readonly int Key;
    public readonly Span<byte> Span;
}

内容的提问来源于stack exchange,提问作者Bug

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.17 13:17:48