Unity高效的DOTS动画方案Rukhanka深度剖析

Rukhanka 深度剖析

前言:一个常见的误解

很多人第一次接触 Rukhanka 时以为:"它把 SkinnedMeshRenderer 转成了 MeshRenderer,然后走 GPU 蒙皮。"

这个理解是错的。 Rukhanka 根本没有做"转换",它做的事更底层:接管骨骼矩阵的计算,然后把结果写进 Unity ECS 的 SkinMatrix buffer,剩下的交给 ECS 渲染管线。

没有 SkinnedMeshRenderer → MeshRenderer 的转换过程。在 ECS 世界里,压根就没有"Renderer 组件"这个概念,只有 Entity + Component。


一、Rukhanka 的完整管线(四个阶段)

复制代码
                     Baking 阶段(离线/编辑器)
    ┌────────────────────────────────────────────────────────────┐
    │  阶段一:SkinnedMeshBaker                                   │
    │  Baker<SkinnedMeshRenderer> 主线程执行                       │
    │  提取:bones[], bindposes[], rootBone                       │
    │  输出:临时 SkinnedMeshBakerData 组件                         │
    └────────────────────────────────────────────────────────────┘
    │
    ▼
    ┌────────────────────────────────────────────────────────────┐
    │  阶段二:SkinnedMeshConversionSystem                        │
    │  BakingSystem,JobSystem 并行执行                            │
    │  输出:BlobAssetReference<SkinnedMeshInfoBlob>              │
    │        + AnimatedSkinnedMeshComponent 组件                   │
    └────────────────────────────────────────────────────────────┘
    │
    ▼
                     Runtime 阶段(每帧执行,全 CPU + JobSystem)
    ┌────────────────────────────────────────────────────────────┐
    │  阶段三:AnimationsProcessSystem.OnUpdate                   │
    │  9个 Job 串联成管线                                          │
    │  核心输出:finalAllAnimatedBones (NativeList<BoneTransform>) │
    └────────────────────────────────────────────────────────────┘
    │
    ▼
    ┌────────────────────────────────────────────────────────────┐
    │  阶段四:ApplyAnimationToSkinnedMeshJob                     │
    │  读取骨骼动画结果 → 计算 SkinMatrix → 写入 DynamicBuffer    │
    └────────────────────────────────────────────────────────────┘
    │
    ▼
                     GPU(ECS Rendering 接管)
    ┌────────────────────────────────────────────────────────────┐
    │  Vertex Shader 读 SkinMatrix buffer → 顶点蒙皮变形 → 渲染   │
    └────────────────────────────────────────────────────────────┘

二、阶段一:Baker 提取骨骼数据

2.1 入口

csharp 复制代码
public class SkinnedMeshBaker : Baker<SkinnedMeshRenderer>

这是一个 Baker<SkinnedMeshRenderer>在主线程 执行。Unity ECS Baking 过程中遇到任何带有 SkinnedMeshRenderer 的 GameObject,都会触发它。

2.2 提取了什么

csharp 复制代码
// SkinnedMeshBaker.cs : 55-80
RTP.SkinnedMeshBoneData CreateSkinnedMeshBoneData(SkinnedMeshRenderer r)
{
    bnd.bones = new UnsafeList<SkinnedMeshBoneDefinition>(r.bones.Length, ...);
    for (int j = 0; j < r.bones.Length; ++j)
    {
        var b = r.bones[j];
        // ① 对骨骼名称计算 Hash128(后续 Job 里做 O(1) 哈希匹配)
        oneBoneInfo.hash = new FixedStringName(b.name).CalculateHash128();
        // ② 直接从 sharedMesh.bindposes 数组拷贝(Unity 预计算的逆矩阵)
        oneBoneInfo.bindPose = r.sharedMesh.bindposes[j];
        // ③ 从 Transform 组件读取参考姿势
        oneBoneInfo.refPose.pos    = b.localPosition;
        oneBoneInfo.refPose.scale  = b.localScale;
        oneBoneInfo.refPose.rot    = b.localRotation;
    }
}

三个关键数据来源:

数据 来源 类型 用途
hash Transform.name → Hash128 Hash128 运行时骨骼匹配
bindPose sharedMesh.bindposes[j] float4x4 最终蒙皮矩阵计算
refPose Transform.localPosition/Rotation/Scale BoneTransform 无动画时的参考姿势

注意 bindPoseUnity 在模型导入时预计算好的逆矩阵(骨骼空间→模型空间)。Rukhanka 不需要自己算,直接拿来用。

2.3 输出

Baker 创建一个临时实体 (不会进入运行时 World),挂上 SkinnedMeshBakerData

csharp 复制代码
var bd = new SkinnedMeshBakerData
{
    skinnedMeshBones = smbd,                    // 骨骼数据(UnsafeList)
    targetEntity = GetEntity(TransformUsageFlags.Dynamic),
    animatedRigEntity = GetEntity(FindParent<RigDefinitionAuthoring>()),
    rootBoneEntity = GetEntity(a.rootBone),
    hash = a.sharedMesh.GetHashCode(),          // 用于去重
};

三、阶段二:BakingSystem 转成 Blob Asset

3.1 SkinnedMeshConversionSystem

这是一个 [WorldSystemFilter(WorldSystemFilterFlags.BakingSystem)] 系统,在 Baking World 里运行。

3.2 核心逻辑------去重

csharp 复制代码
// 按 hash 排序,相同 mesh 只创建一个 Blob Asset
skinnedMeshesData.Sort(new SkinnedMeshBakerDataSorter());
for (int i = 1; i <= skinnedMeshesData.Length; ++i)
{
    if (rd.hash != startHash)
    {
        // 相同 hash 的实体 → 共享同一个 BlobAssetReference
        var j = new CreateBlobAssetsJob() {
            data = refSkinnedMesh,
            outBlobAssets = blobAssetsSlice,
        };
        j.Schedule();
    }
}

意义 :100 个怪物用同一个模型 → 骨骼数据只 Blob 一次,100 个实体共享一个 BlobAssetReference。运行时读同一块内存,不需要复制。

3.3 CreateBlobAssetsJob(IJob,Worker Thread 执行)

csharp 复制代码
public void Execute()
{
    var bb = new BlobBuilder(Allocator.Temp);
    ref var blobAsset = ref bb.ConstructRoot<SkinnedMeshInfoBlob>();
    var boneInfoArr = bb.Allocate(ref blobAsset.bones, data.skinnedMeshBones.bones.Length);
    for (int i = 0; i < data.skinnedMeshBones.bones.Length; ++i)
    {
        boneInfoArr[i].hash     = src.hash;
        boneInfoArr[i].bindPose = src.bindPose;    // float4x4
        boneInfoArr[i].refPose  = src.refPose;      // BoneTransform
    }
    // BlobAsset 分配在 Persistent 内存,Baking 结束后不会释放
    var rv = bb.CreateBlobAssetReference<SkinnedMeshInfoBlob>(Allocator.Persistent);
}

3.4 CreateComponentDatasJob(IJobParallelForBatch)

AnimatedSkinnedMeshComponent 通过 ECB 添加到目标 Entity:

csharp 复制代码
ecb.AddComponent(startIndex, e, new AnimatedSkinnedMeshComponent {
    boneInfos = blobAssets[i],            // BlobAssetReference<SkinnedMeshInfoBlob>
    rootBoneEntity = smb.rootBoneEntity,
    animatedRigEntity = smb.animatedRigEntity,
});

这个 AnimatedSkinnedMeshComponent 就是 Rukhanka 和 Unity ECS Rendering 之间的桥梁。 有了它,运行时系统才知道"这个 Entity 的动画由哪个 Rig 驱动、骨骼数据在哪"。同时 Unity ECS Baking 自动给这个 Entity 加上了 RenderMesh + SkinMatrix buffer,所以 ECS Rendering 也知道它是个蒙皮网格。


四、阶段三:运行时动画矩阵计算

这是每帧最核心的部分。所有代码在 AnimationsProcessSystem.OnUpdate 里串联,全部在 CPU Worker Thread 上通过 JobSystem 执行

4.1 整体调度

csharp 复制代码
// AnimationProcessSystem.cs : 333-374
public void OnUpdate(ref SystemState ss)
{
    entityToDataOffsetMap.Clear();
    var entityCount = animatedObjectQuery.CalculateEntityCount();
    if (entityCount == 0) return;

    // Step 0: 数据准备------计算骨骼偏移,压平 NativeArray
    var calcBoneOffsetsJH = PrepareComputationData(chunkBaseEntityIndices, entitiesArr, combinedJH);

    // Step 1: 计算骨骼动画(采样曲线 + 混合)
    var computeAnimationJobHandle = AnimationCalculation(ref ss, entitiesArr, calcBoneOffsetsJH);

    // Step 2: 处理用户曲线(Animator Controller 参数)
    var userCurveProcessJobHandle = ProcessUserCurves(ref ss, computeAnimationJobHandle);

    // Step 3: 应用 Root Motion
    var applyRootMotionJobHandle = ApplyRootMotion(ref ss, computeAnimationJobHandle);

    // Step 4: 骨骼 pose 写回 Entity 的 LocalTransform 组件
    var propagateTRSJobHandle = PropagateAnimatedBonesToEntitesTRS(ref ss, combinedJH);

    // Step 5: 计算 SkinMatrix 写入 DynamicBuffer
    var applySkinJobHandle = ApplySkinning(ref ss, propagateTRSJobHandle);

    ss.Dependency = applySkinJobHandle;
}

4.2 数据压平(PrepareComputationData)

ECS 的数据分散在 Chunk 里,不能直接当数组随机访问。Rukhanka 先做一次数据压平

csharp 复制代码
// ① CalculateBoneOffsetsJob → 统计每个 Rig 有多少根骨骼
// ② DoPrefixSumJob → 前缀和,算出每个 Rig 在平铺数组里的起始偏移
//                     [0, rig0.bones, rig0.bones+rig1.bones, ...]
// ③ CalculatePerBoneInfoJob → boneToEntityArr[globalBoneIndex] = (entityIndex, rigBoneIndex)

结果是一个扁平的 NativeList<BoneTransform> finalAllAnimatedBones

复制代码
| Rig0_bone0 | Rig0_bone1 | ... | Rig0_boneN | Rig1_bone0 | Rig1_bone1 | ... |
   ↑ entityToDataOffsetMap[Rig0]      ↑ entityToDataOffsetMap[Rig1]

这样 Job 里就能用 boneTransforms[globalIndex] 直接随机访问任意 Rig 的任意骨骼,无锁。

4.3 ComputeBoneAnimationJob(最重的 Job)

IJobParallelForBatch,Batch size = 16(一次处理 16 根骨骼):

csharp 复制代码
void ExecuteSingle(int globalBoneIndex)
{
    // ① 反查这根骨骼属于哪个 Entity、哪根 Rig 骨骼
    var (rigBoneIndex, entityIndex) = boneToEntityIndices[globalBoneIndex];

    // ② 拿到骨骼的参考姿势
    var boneRefPose = GetBoneRefPose(ref rb, rigBoneIndex, e);

    // ③ 没有动画在播放 → 直接返回参考姿势
    if (animationsToProcess.IsEmpty) { outBlendedBones[globalBoneIndex] = boneRefPose; }

    // ④ 计算各动画层权重
    var refPosWeight = CalculateFinalLayerWeights(layerWeights, animationsToProcess, ...);

    // ⑤ 遍历所有正在播放的动画
    for (int i = 0; i < animationsToProcess.Length; ++i)
    {
        // Hash128 在 boneMap 里做 O(1) 骨骼匹配
        var foundMatchedBone = atp.boneMap.TryGetValue(rb.hash, out var animationBoneIndex);

        // 贝塞尔曲线插值采样关键帧
        var (bonePose, flags) = SampleAnimation(ref boneAnimation, animTime, atp);

        // 按权重混合(Override / Additive)
        MixPoses(ref blendedBonePose, bonePose, modWeight, atp.blendMode);
    }

    outBlendedBones[globalBoneIndex] = blendedBonePose;
}

这一步最耗 CPU,因为每根骨骼、每层动画、每个通道(position/rotation/scale)都要做贝塞尔插值。但因为数据是扁平的 NativeArray,128 根骨骼 × 3 层动画可以充分利用多核 CPU。

贝塞尔插值的数学:

csharp 复制代码
static public float ProcessBezierCurve(KeyFrame f0, KeyFrame f1, float l)
{
    float dt = f1.time - f0.time;
    float m0 = f0.outTan * dt;
    float m1 = f1.inTan * dt;
    float t2 = l * l;
    float t3 = t2 * l;
    float a = 2 * t3 - 3 * t2 + 1;
    float b = t3 - 2 * t2 + l;
    float c = t3 - t2;
    float d = -2 * t3 + 3 * t2;
    return a * f0.v + b * m0 + c * m1 + d * f1.v;
}

五、阶段四:填 SkinMatrix Buffer

5.1 ApplyAnimationToSkinnedMeshJob

csharp 复制代码
// AnimationProcessSystem_Jobs.cs : 856-917
public void Execute(in ArchetypeChunk chunk, ...)
{
    while (cee.NextEntityIndex(out var i))
    {
        var skinMatrixBuf = skinMatrixBufferAccessor[i];

        // ① 骨骼重映射:Rig 骨骼 → SkinnedMesh 骨骼
        //    (两者骨骼排列顺序可能不同,用哈希匹配表做映射)
        ref var boneRemapTable = ref GetBoneRemapTable(skinnedMesh.boneInfos, rigDef.rigBlob);

        // ② 计算 entity→rootBone 变换矩阵
        var entityToRootBoneTransform = float4x4.identity;
        if (skinnedMesh.rootBoneEntity != Entity.Null) {
            var trs = ltLookup[skinnedMesh.rootBoneEntity];
            entityToRootBoneTransform = math.inverse(new BoneTransform(trs).ToFloat4x4());
        }

        // ③ 遍历 Rig 所有骨骼
        for (int animationBoneIndex = 0; animationBoneIndex < rigBones.Length; ++animationBoneIndex)
        {
            var skinnedMeshBoneIndex = boneRemapTable.rigBoneToSkinnedMeshBoneRemapIndices[animationBoneIndex];
            if (skinnedMeshBoneIndex < 0) continue;

            // ④ 计算绝对骨骼变换(从根节点到当前节点的完整变换链)
            var absBonePose = MakeAbsoluteTransform(absoluteBoneTransforms, animationBoneIndex, ...);
            var boneXForm = absBonePose.ToFloat4x4();

            // ⑤ 最终蒙皮矩阵
            var skinMatrix = MakeSkinMatrixForBone(ref boneInfo, boneXForm, entityToRootBoneTransform);

            // ⑥ 写入 DynamicBuffer<SkinMatrix>
            outSkinMatricesBuf[skinnedMeshBoneIndex] = skinMatrix;
        }
    }
}

5.2 骨骼层级展平(MakeAbsoluteTransform)

csharp 复制代码
BoneTransform MakeAbsoluteTransform(..., int boneIndex, ...)
{
    // 从当前骨骼一路乘到根
    while (boneIndex >= 0)
    {
        var animatedBoneTransform = absBoneTransforms[boneIndex];
        resultBoneTransform = BoneTransform.Multiply(animatedBoneTransform, resultBoneTransform);
        if (isHierarchyTreeCombined) break;
        boneIndex = boneDef.parentBoneIndex;  // 往上走
    }
    return resultBoneTransform;
}

5.3 最终蒙皮矩阵

csharp 复制代码
SkinMatrix MakeSkinMatrixForBone(ref SkinnedMeshBoneInfo boneInfo,
    in float4x4 boneXForm, in float4x4 entityToRootBoneTransform)
{
    var boneTransformMatrix = math.mul(entityToRootBoneTransform, boneXForm);
    boneTransformMatrix = math.mul(boneTransformMatrix, boneInfo.bindPose);
    return new SkinMatrix() {
        Value = new float3x4(
            boneTransformMatrix.c0.xyz,
            boneTransformMatrix.c1.xyz,
            boneTransformMatrix.c2.xyz,
            boneTransformMatrix.c3.xyz
        )
    };
}

数学本质最终蒙皮矩阵 = entityToRootBone × 骨骼动画绝对变换 × bindPose


六、Rukhanka 的 9 个 Job 总览

Job 名 类型 输入 输出 用途
CalculateBoneOffsetsJob IJobChunk RigDefinitionComponent bonePosesOffsets[] 统计每个 Rig 骨骼数
DoPrefixSumJob IJob bonePosesOffsets[] 前缀和数组 算平铺偏移
CalculatePerBoneInfoJob IJobChunk Rig 定义 + 实体 boneToEntityArr[] + entityToDataOffsetMap 压平数据映射
ComputeBoneAnimationJob IJobParallelForBatch 动画曲线 + Rig 定义 finalAllAnimatedBones[] 最重:采样 + 混合
ProcessUserCurvesJob IJobChunk 动画曲线 Animator 参数值 驱动 Animator Controller 参数
ApplyRootMotionJob IJobChunk 根骨骼动画 LocalTransform 根运动应用到 Entity
PropagateBoneTransformToEntityTRSJob IJobChunk 骨骼 pose LocalTransform 组件 骨骼 pose 写回 Transform
ApplyAnimationToSkinnedMeshJob IJobChunk 骨骼 pose + 骨骼数据 DynamicBuffer<SkinMatrix> 算蒙皮矩阵
FillRigToSkinBonesRemapTableCacheJob IJob Rig + SkinnedMesh BoneRemapTableBlob 哈希匹配骨骼映射

七、CPU 与 GPU 的分工

7.1 CPU 做的

复制代码
① 贝塞尔曲线采样(每根骨骼每个通道的浮点插值)
② 多动画层混合(Override/Additive,线性加权)
③ 骨骼层级树遍历(从子节点一路乘到根节点)
④ 计算最终 SkinMatrix(entityToRoot × absPose × bindPose)
⑤ 写入 DynamicBuffer<SkinMatrix>

7.2 GPU 做的

复制代码
⑥ Vertex Shader 从 SkinMatrix buffer 读矩阵
⑦ 对每个顶点:v' = Σ w_i × M_i × v(4根骨骼加权蒙皮)
⑧ 继续常规渲染管线(投影、光栅化、像素着色)

7.3 为什么这种分工合理

任务类型 在哪跑 原因
曲线采样 + 树遍历 CPU 分支多、数据依赖强,CPU 的分支预测和缓存友好
MAC 运算(乘加) GPU 每个顶点做一样的事,SIMT 架构天然适合

Rukhanka 的本质优化 不是"把 CPU 活搬到 GPU",而是把原来 SkinnedMeshRenderer 主线程单线程的 CPU 蒙皮,拆成:

  • 动画计算:JobSystem 多线程 + Burst SIMD
  • 顶点变形:GPU 硬件加速

八、CPU 传给 GPU 的数据

8.1 只有一样东西:SkinMatrix 数组

复制代码
每个蒙皮网格 → N 个 float3x4
               N = 该网格的骨骼数量

以 96 根骨骼的人形角色为例:

复制代码
96 根骨骼 × 3 个 float4 × 16 bytes = 4,608 bytes ≈ 4.5 KB

100 个同屏角色 ≈ 450 KB。对 PCIe 带宽来说微不足道。

8.2 什么没有传给 GPU

没传的数据 留在了 CPU
AnimationClip 曲线 Job 采样完就丢弃了
骨骼层级树结构 只用于 CPU 侧链式相乘
混合权重 只在 MixPose 里用一次
AvatarMask 只在 CalculateFinalLayerWeights 里用

GPU 只关心最终结果:每根骨骼把顶点拉到哪个位置。它不关心这个位置是怎么算出来的。


九、DynamicBuffer<SkinMatrix> 是什么

9.1 它是 ECS 的内置类型

csharp 复制代码
// Unity.Entities 核心库
public struct DynamicBuffer<T> : ... where T : unmanaged, IBufferElementData

IComponentData 平级,区别:

IComponentData DynamicBuffer
大小 固定(struct 大小) 可变
存储 Chunk 主体内存 Chunk 附加内存
访问 entity.GetComponent<T>() entity.GetBuffer<T>()

9.2 底层特殊处理

DynamicBuffer<SkinMatrix> 虽然是 ECS API,但 Unity ECS Rendering 在底层做了特殊处理:

  1. 识别到 SkinMatrix 类型 → 分配在 CPU-GPU 共享内存(D3D12/Vulkan 的 persistent mapped buffer)
  2. 注册为渲染管线的 vertex buffer
  3. CPU 写、GPU 读同一块物理地址,零拷贝

9.3 和 ComputeBuffer 的区别

DynamicBuffer<SkinMatrix> ComputeBuffer
内存位置 CPU-GPU 共享内存 GPU 显存
CPU 写入 直接写内存地址(Job 也能写) SetData 触发 PCIe 拷贝(必须主线程)
GPU 读取 直接读取 直接读取
拷贝开销 零拷贝 每次 SetData 都传输
同步 引擎自动双缓冲 手动 fence/barrier
API 层级 ECS 组件,buf[i] = value 图形 API,cb.SetData()

9.4 类比

ComputeBuffer 是"发快递":你把数据打包(SetData),快递员送过去(PCIe 传输),收件人收到才能拆包。

DynamicBuffer<SkinMatrix> 是"共享办公室":你把文件直接写在共享桌子上,同事走过来看。不需要寄送------你们在同一间屋子。


十、骨骼匹配:FillRigToSkinBonesRemapTableCache

这是一个常被忽略但很关键的设计。

问题 :Rig(Animator 的骨骼层级)和 SkinnedMesh(bones[] 数组)的骨骼排列顺序不一定一致。同一个模型可能 Rig 里骨骼顺序是 [hips, spine, chest, head],但 mesh 的 bones[] 顺序是 [head, chest, spine, hips]

Rukhanka 的解法:用 Hash128 做匹配。

csharp 复制代码
for (int i = 0; i < rigDef.rigBlob.Value.bones.Length; ++i)
{
    bba[i] = -1;  // 默认不匹配
    ref var rb = ref rigDef.rigBlob.Value.bones[i];
    var rbHash = rb.hash;

    for (int j = 0; j < sm.boneInfos.Value.bones.Length; ++j)
    {
        ref var bn = ref sm.boneInfos.Value.bones[j];
        if (bn.hash == rbHash)  // Hash128 比较 = 一条 CPU 指令
        {
            bba[i] = j;  // Rig 骨骼 i → SkinnedMesh 骨骼 j
            break;
        }
    }
}

结果是一个 BoneRemapTableBlobrigBoneToSkinnedMeshBoneRemapIndices[i] = j,表示"Rig 的第 i 根骨骼对应 SkinnedMesh 的第 j 根骨骼"。不匹配的设为 -1,在 ApplyAnimationToSkinnedMeshJobcontinue 跳过。

这个 remap table 是一个 BlobAsset ,只需算一次,缓存在 rigToSkinnedMeshRemapTables HashMap 里,后续帧直接 O(1) 查找。


十一、去重机制总结

Rukhanka 在两个层面做了去重:

层级 时机 方式 收益
Blob Asset Baking 相同 sharedMesh.GetHashCode() → 共享一个 SkinnedMeshInfoBlob 100 个相同怪物=1 份骨骼数据
Remap Table 运行时首次遇到 相同 (meshHash + rigHash) → 缓存 BoneRemapTableBlob 后续帧 O(1) 跳过哈希匹配

十二、一句话总结全流程

复制代码
SkinnedMeshRenderer(主线程 Baker 提取骨骼数据)
    → BlobAsset(BakingSystem 转换,Worker Thread 执行)
    → JobSystem 并行算动画(ComputeBoneAnimationJob)
    → 写入 DynamicBuffer<SkinMatrix>(ApplyAnimationToSkinnedMeshJob)
    → GPU Vertex Shader 读取(Unity ECS Rendering 自动绑定)
    → 顶点蒙皮变形 → 渲染输出

CPU 管"怎么动"(曲线采样 + 树遍历 + 矩阵乘法)
GPU 管"怎么变"(顶点加权蒙皮)
Rukhanka 是 CPU 侧的"骨骼矩阵计算引擎",不做渲染

这个方案相比较GPU ECS Animation的优势是支持丰富的动画应用场景

之前的对比教程

相关推荐
玖玥拾1 天前
Unity 3D 基础(一)Unity基础架构、物理系统、视觉渲染、空间变换与对象查询
笔记·游戏·3d·unity·游戏引擎
玖玥拾3 天前
C# 语言进阶(十四)Unity UI界面开发 + 网络消息联动
服务器·开发语言·网络·ui·unity·c#·游戏引擎
野区捕龙为宠3 天前
Unity 持久化数据
unity·游戏引擎·lucene
郝学胜-神的一滴3 天前
[简化版 GAMES 101] 计算机图形学 17:纹理技术从基础原理到多场景实战应用
c++·unity·游戏引擎·图形渲染·three.js·opengl·unreal
_ZHOURUI_H_4 天前
MyFramework: 同样是 Unity 游戏开发框架,和 HTFramework 的设计取向有什么不同?(下)
unity·游戏引擎·游戏开发·游戏ui·手游开发
小贺儿开发5 天前
Unity 知识库智能问答系统(RAG)
人工智能·unity·ai·大模型·智能问答·知识库·互动
丁小未5 天前
基于MVVM框架的XUUI 扩展的UI管理系统教程
unity·mvvm·ui框架·xuui·ui管理器
丁小未6 天前
基于MVVM框架的XUUI HelloWorld 新手教程
unity·性能优化·c#·游戏引擎