Appearance
校招简历项目加分点
项目有完整玩法闭环
TIP
面试回答: 可以这样说:我的 Demo 不是只做了移动、战斗、背包这些孤立功能,而是做了一个完整玩法闭环。玩家从主界面进入关卡,接到目标,进行移动、攻击、释放技能,击败敌人后获得金币、经验、装备,进入结算界面,再用奖励强化角色,解锁下一关或挑战更高难度。
这就形成了:目标 -> 操作 -> 反馈 -> 奖励 -> 成长 -> 下一轮目标。 我还处理了失败闭环,比如死亡后结算、复活、重试、引导玩家强化;也处理了数据闭环,比如奖励入库、背包更新、任务进度更新、存档恢复。
C# 示例:一个简单玩法闭环状态机
c
using UnityEngine; // 引入 Unity 基础 API。
public sealed class GameplayLoopController : MonoBehaviour // 定义玩法闭环控制器。
{ // 类开始。
private enum LoopState // 定义玩法流程状态。
{ // 枚举开始。
MainMenu, // 主界面状态。
PrepareLevel, // 关卡准备状态。
Playing, // 战斗进行状态。
Settlement, // 结算状态。
Upgrade, // 成长强化状态。
} // 枚举结束。
private LoopState state; // 保存当前玩法状态。
private int gold; // 保存玩家金币。
private int exp; // 保存玩家经验。
private void Start() // 游戏开始时进入主界面。
{ // 方法开始。
ChangeState(LoopState.MainMenu); // 切换到主界面状态。
} // 方法结束。
public void OnClickStartLevel() // 玩家点击开始关卡时调用。
{ // 方法开始。
ChangeState(LoopState.PrepareLevel); // 进入关卡准备状态。
} // 方法结束。
public void OnLevelLoaded() // 关卡资源加载完成时调用。
{ // 方法开始。
ChangeState(LoopState.Playing); // 进入战斗状态。
} // 方法结束。
public void OnBossDead() // Boss 死亡时调用。
{ // 方法开始。
gold += 100; // 发放金币奖励。
exp += 50; // 发放经验奖励。
ChangeState(LoopState.Settlement); // 进入结算状态。
} // 方法结束。
public void OnClickUpgrade() // 玩家点击强化时调用。
{ // 方法开始。
ChangeState(LoopState.Upgrade); // 进入成长强化状态。
} // 方法结束。
public void OnUpgradeFinished() // 强化结束时调用。
{ // 方法开始。
ChangeState(LoopState.MainMenu); // 回到主界面,准备下一轮挑战。
} // 方法结束。
private void ChangeState(LoopState next) // 统一切换玩法状态。
{ // 方法开始。
state = next; // 更新当前状态。
Debug.Log("Gameplay Loop State: " + state); // 打印状态变化,方便调试流程。
} // 方法结束。
} // 类结束。IMPORTANT
加分说法: 我判断一个 Demo 有没有闭环,不是看功能数量,而是看玩家能不能从开始玩到结算,奖励能不能变成成长,成长能不能驱动下一次挑战,并且退出重进后状态还能恢复。
项目有性能优化前后对比
TIP
面试回答: 可以这样说:我做优化时不是凭感觉说“变流畅了”,而是先建立基线,再复测对比。比如同一台真机、同一个战斗场景、同样 10 分钟压测,优化前平均 42 FPS,P99 帧耗时 58ms,GC Alloc 约 3.2KB/frame,DrawCall 约 1800;优化后平均 58 FPS,P99 降到 24ms,常规帧 GC 降到 0-128B/frame,DrawCall 降到 650。
主要改动包括:怪物、子弹、飘字对象池化;大量 UI Item 改虚拟列表;贴图进图集;相同材质合批;AI 分帧;资源分包和预加载;关闭不必要 Raycast Target;裁掉没用 Shader Variant。最后用 Profiler、Frame Debugger、Memory Profiler 和真机复测报告证明优化有效。
C# 示例:简单记录帧耗时和 GC Alloc
c
using System; // 引入 Array.Sort,用来计算 P99。
using UnityEngine; // 引入 MonoBehaviour、Time、Debug。
using Unity.Profiling; // 引入 ProfilerRecorder,用来读取 GC Alloc。
public sealed class PerformanceSampler : MonoBehaviour // 定义性能采样组件。
{ // 类开始。
private const int MaxSamples = 6000; // 最多记录 6000 帧,约等于 60 FPS 下 100 秒。
private readonly float[] frameTimes = new float[MaxSamples]; // 保存每帧耗时,单位毫秒。
private int sampleCount; // 保存当前已经采样的帧数。
private float totalFrameTime; // 保存总帧耗时。
private float maxFrameTime; // 保存最大单帧耗时。
private long totalGcAlloc; // 保存总 GC 分配字节数。
private long maxGcAlloc; // 保存最大单帧 GC 分配字节数。
private ProfilerRecorder gcRecorder; // 保存 GC Alloc 采样器。
private void OnEnable() // 组件启用时开始采样。
{ // 方法开始。
sampleCount = 0; // 重置采样数量。
totalFrameTime = 0f; // 重置总帧耗时。
maxFrameTime = 0f; // 重置最大帧耗时。
totalGcAlloc = 0; // 重置总 GC 分配。
maxGcAlloc = 0; // 重置最大 GC 分配。
gcRecorder = ProfilerRecorder.StartNew(ProfilerCategory.Memory, "GC Allocated In Frame"); // 开始记录每帧 GC Alloc。
} // 方法结束。
private void Update() // 每帧记录一次数据。
{ // 方法开始。
if (sampleCount >= MaxSamples) return; // 采样满了就停止记录。
float frameMs = Time.unscaledDeltaTime * 1000f; // 把当前帧耗时转换成毫秒。
long gcBytes = gcRecorder.Valid ? gcRecorder.LastValue : 0; // 读取当前帧 GC 分配。
frameTimes[sampleCount] = frameMs; // 保存当前帧耗时。
sampleCount++; // 采样帧数加一。
totalFrameTime += frameMs; // 累加帧耗时。
if (frameMs > maxFrameTime) maxFrameTime = frameMs; // 更新最大帧耗时。
totalGcAlloc += gcBytes; // 累加 GC 分配。
if (gcBytes > maxGcAlloc) maxGcAlloc = gcBytes; // 更新最大 GC 分配。
} // 方法结束。
private void OnDisable() // 组件关闭时输出统计结果。
{ // 方法开始。
if (sampleCount <= 0) return; // 没有采样数据就直接返回。
float[] sorted = new float[sampleCount]; // 创建用于排序的帧耗时数组。
Array.Copy(frameTimes, sorted, sampleCount); // 复制有效采样数据。
Array.Sort(sorted); // 排序后方便取 P99。
int p99Index = Mathf.Clamp((int)(sampleCount * 0.99f), 0, sampleCount - 1); // 计算 P99 下标。
float avgMs = totalFrameTime / sampleCount; // 计算平均帧耗时。
long avgGc = totalGcAlloc / sampleCount; // 计算平均每帧 GC 分配。
Debug.Log($"Avg:{avgMs:F2}ms Max:{maxFrameTime:F2}ms P99:{sorted[p99Index]:F2}ms AvgGC:{avgGc}B MaxGC:{maxGcAlloc}B"); // 输出统计结果。
gcRecorder.Dispose(); // 释放 ProfilerRecorder。
} // 方法结束。
} // 类结束。TIP
加分句: 我会强调“优化前后对比必须同场景、同设备、同流程”,并且不只看平均帧率,还要看 P95/P99、GC spike、峰值内存和加载耗时,因为玩家真正感受到的是卡顿尖峰。
项目有工具链或编辑器扩展
IMPORTANT
面试回答: 可以这样说:我项目里做过一些 Unity 编辑器工具,不只是为了方便自己,而是把重复、易错、依赖人工记忆的流程自动化。比如配置表一键导入和校验、资源命名和压缩设置检查、一键打包、运行时状态调试面板等。
重点要说收益:比如导表从手动操作变成一键生成代码和 JSON;资源导入时自动设置压缩格式,避免美术忘记改平台设置;打包工具自动切环境、写版本号、生成构建报告,减少发错包、漏资源的问题。
C# 示例:批量设置贴图导入格式的 EditorWindow
c
using UnityEditor; // 引入 Unity 编辑器 API。
using UnityEngine; // 引入 Unity 基础类型。
public sealed class TextureImportToolWindow : EditorWindow // 定义一个贴图导入设置工具窗口。
{ // 类开始。
private DefaultAsset folder; // 保存用户选择的资源目录。
[MenuItem("Tools/Texture Import Tool")] // 在 Unity 顶部菜单添加工具入口。
private static void Open() // 打开工具窗口的方法。
{ // 方法开始。
GetWindow<TextureImportToolWindow>("Texture Tool"); // 创建或显示工具窗口。
} // 方法结束。
private void OnGUI() // 绘制编辑器窗口界面。
{ // 方法开始。
folder = (DefaultAsset)EditorGUILayout.ObjectField("Folder", folder, typeof(DefaultAsset), false); // 让用户选择一个资源目录。
if (GUILayout.Button("Apply Mobile Texture Settings")) // 绘制按钮并判断是否点击。
{ // 判断开始。
ApplySettings(); // 点击后执行批量设置。
} // 判断结束。
} // 方法结束。
private void ApplySettings() // 执行贴图批量导入设置。
{ // 方法开始。
if (folder == null) // 判断是否没有选择目录。
{ // 判断开始。
Debug.LogError("Please select a folder."); // 提示用户先选择目录。
return; // 终止执行。
} // 判断结束。
string rootPath = AssetDatabase.GetAssetPath(folder); // 获取目录的工程路径。
string[] guids = AssetDatabase.FindAssets("t:Texture2D", new[] { rootPath }); // 查找目录下所有 Texture2D。
int changedCount = 0; // 记录修改了多少张贴图。
foreach (string guid in guids) // 遍历每一个贴图 GUID。
{ // 循环开始。
string path = AssetDatabase.GUIDToAssetPath(guid); // 把 GUID 转成资源路径。
TextureImporter importer = AssetImporter.GetAtPath(path) as TextureImporter; // 获取贴图导入器。
if (importer == null) // 判断导入器是否为空。
{ // 判断开始。
continue; // 不是有效贴图就跳过。
} // 判断结束。
importer.mipmapEnabled = true; // 开启 Mipmap,适合大多数 3D 场景贴图。
importer.textureCompression = TextureImporterCompression.Compressed; // 开启压缩,降低包体和内存。
TextureImporterPlatformSettings android = importer.GetPlatformTextureSettings("Android"); // 获取 Android 平台设置。
android.overridden = true; // 启用 Android 单独设置。
android.format = TextureImporterFormat.ASTC_6x6; // 设置 Android 使用 ASTC 6x6。
android.maxTextureSize = 1024; // 限制最大贴图尺寸为 1024。
importer.SetPlatformTextureSettings(android); // 写回 Android 平台设置。
TextureImporterPlatformSettings ios = importer.GetPlatformTextureSettings("iPhone"); // 获取 iOS 平台设置。
ios.overridden = true; // 启用 iOS 单独设置。
ios.format = TextureImporterFormat.ASTC_6x6; // 设置 iOS 使用 ASTC 6x6。
ios.maxTextureSize = 1024; // 限制最大贴图尺寸为 1024。
importer.SetPlatformTextureSettings(ios); // 写回 iOS 平台设置。
importer.SaveAndReimport(); // 保存设置并重新导入资源。
changedCount++; // 修改数量加一。
} // 循环结束。
AssetDatabase.SaveAssets(); // 保存所有资源修改。
AssetDatabase.Refresh(); // 刷新 Unity 资源数据库。
Debug.Log("Texture import settings changed: " + changedCount); // 输出处理结果。
} // 方法结束。
} // 类结束。TIP
加分句: 编辑器工具一定要和运行时代码隔离,通常放在 Assets/Editor 目录,或者用 #if UNITY_EDITOR 包起来,避免打进包体。面试时再补一句“我会用构建报告和错误率证明工具收益”,就很像真实项目经验。
项目有资源管理方案
WARNING
面试回答: 可以这样说:项目里资源不会让业务到处直接 Load,而是统一走 ResourceManager。业务只关心“申请资源”和“归还资源”,资源管理器内部负责异步加载、依赖处理、缓存、引用计数、版本 Manifest、卸载和泄漏检查。
资源一般分三类:常驻资源,比如公共 Shader、通用 UI、基础音频;场景资源,比如地图、怪物、关卡特效;临时资源,比如弹窗、活动资源、一次性特效。常驻资源跟随游戏生命周期,场景资源跟随场景生命周期,临时资源用完就释放。
C# 示例:简化版 Prefab 资源管理器
c
using System; // 引入 IDisposable。
using System.Collections.Generic; // 引入 Dictionary。
using System.Threading.Tasks; // 引入 Task。
using UnityEngine; // 引入 GameObject。
using UnityEngine.AddressableAssets; // 引入 Addressables。
using UnityEngine.ResourceManagement.AsyncOperations; // 引入 AsyncOperationHandle。
public sealed class PrefabLease : IDisposable // 定义资源租约,业务拿到它就必须归还。
{ // 类开始。
private readonly PrefabResourceManager owner; // 保存资源管理器引用。
private readonly string key; // 保存资源 key。
private bool disposed; // 防止重复释放。
public GameObject Asset { get; } // 对外暴露 Prefab 资源。
internal PrefabLease(PrefabResourceManager owner, string key, GameObject asset) // 创建资源租约。
{ // 构造函数开始。
this.owner = owner; // 保存管理器。
this.key = key; // 保存资源 key。
Asset = asset; // 保存资源对象。
} // 构造函数结束。
public void Dispose() // 业务用完资源时调用。
{ // 方法开始。
if (disposed) return; // 如果已经释放过,就直接返回。
disposed = true; // 标记已经释放。
owner.Release(key); // 通知资源管理器减少引用计数。
} // 方法结束。
} // 类结束。
public sealed class PrefabResourceManager // 定义 Prefab 资源管理器。
{ // 类开始。
private sealed class Entry // 定义资源缓存记录。
{ // 类开始。
public AsyncOperationHandle<GameObject> Handle; // 保存 Addressables 句柄。
public Task<GameObject> LoadingTask; // 保存正在加载的异步任务。
public int RefCount; // 保存业务引用计数。
} // 类结束。
private readonly Dictionary<string, Entry> cache = new Dictionary<string, Entry>(); // 保存资源缓存表。
public async Task<PrefabLease> LoadAsync(string key) // 业务通过统一入口申请资源。
{ // 方法开始。
Entry entry = GetOrCreateEntry(key); // 获取缓存记录,没有就创建。
entry.RefCount++; // 引用计数加一。
GameObject asset = await entry.LoadingTask; // 等待资源和依赖加载完成。
return new PrefabLease(this, key, asset); // 返回租约,让业务负责归还。
} // 方法结束。
private Entry GetOrCreateEntry(string key) // 获取或创建资源记录。
{ // 方法开始。
if (cache.TryGetValue(key, out Entry entry)) return entry; // 已经加载或正在加载时复用。
entry = new Entry(); // 创建新的资源记录。
cache[key] = entry; // 先放进缓存,避免并发重复加载。
entry.LoadingTask = LoadInternalAsync(key, entry); // 启动真正的异步加载。
return entry; // 返回资源记录。
} // 方法结束。
private async Task<GameObject> LoadInternalAsync(string key, Entry entry) // 执行底层加载。
{ // 方法开始。
AsyncOperationHandle<GameObject> handle = Addressables.LoadAssetAsync<GameObject>(key); // 发起 Addressables 加载。
await handle.Task; // 等待依赖和资源加载完成。
if (handle.Status != AsyncOperationStatus.Succeeded) // 判断加载是否失败。
{ // 判断开始。
cache.Remove(key); // 移除失败缓存。
Addressables.Release(handle); // 释放失败句柄。
throw new Exception("Load failed: " + key); // 抛出异常交给业务兜底。
} // 判断结束。
entry.Handle = handle; // 保存成功句柄。
return handle.Result; // 返回加载好的 Prefab。
} // 方法结束。
internal void Release(string key) // 归还资源。
{ // 方法开始。
if (!cache.TryGetValue(key, out Entry entry)) return; // 没有缓存就直接返回。
entry.RefCount--; // 引用计数减一。
if (entry.RefCount > 0) return; // 还有业务在用时不能释放。
Addressables.Release(entry.Handle); // 引用归零后释放底层资源。
cache.Remove(key); // 从缓存表移除记录。
} // 方法结束。
} // 类结束。NOTE
加分句: 我会补一句:资源管理方案最怕“加载入口很多、释放时机不清楚”。所以我会统一入口、统一引用计数、统一日志,并在切场景后输出未释放资源列表,这样资源泄漏能被定位到具体 key 和调用来源。
项目有对象池、状态机、事件系统等基础框架
CAUTION
面试回答: 可以这样说:项目里我沉淀了对象池、状态机、事件系统这些基础框架。对象池解决频繁创建销毁导致的 GC 和性能抖动;状态机解决角色、怪物、UI 流程的状态切换;事件系统解决模块之间直接引用过多的问题,比如战斗击杀怪物后,任务系统、UI、音效系统都可以通过事件响应。
重点不是“我写了这些类”,而是要说边界:对象池只管复用,状态机只管状态流转,事件系统只管消息分发。它们不写具体玩法逻辑,只给业务层提供稳定能力。
C# 简化骨架:
c
using System; // 引入 Action、Func、Type、Delegate。
using System.Collections.Generic; // 引入 Stack 和 Dictionary。
using UnityEngine; // 引入 Debug 和 Time 相关 Unity 类型。
public sealed class ObjectPool<T> where T : class // 定义一个泛型对象池,只管理引用类型对象。
{ // 对象池类开始。
private readonly Stack<T> cache = new Stack<T>(); // 用栈保存可复用对象。
private readonly Func<T> createFunc; // 保存创建新对象的方法。
private readonly Action<T> onGet; // 保存对象取出时的重置方法。
private readonly Action<T> onRelease; // 保存对象回收时的清理方法。
public ObjectPool(Func<T> createFunc, Action<T> onGet, Action<T> onRelease) // 构造对象池并注入创建和回调逻辑。
{ // 构造函数开始。
this.createFunc = createFunc; // 保存创建对象的方法。
this.onGet = onGet; // 保存取出对象时的回调。
this.onRelease = onRelease; // 保存回收对象时的回调。
} // 构造函数结束。
public T Get() // 从对象池取出对象。
{ // Get 方法开始。
T item = cache.Count > 0 ? cache.Pop() : createFunc(); // 有缓存就复用,没有就创建新对象。
onGet?.Invoke(item); // 调用取出回调,例如激活对象和重置状态。
return item; // 返回可用对象。
} // Get 方法结束。
public void Release(T item) // 把对象归还到池子。
{ // Release 方法开始。
if (item == null) return; // 空对象不回收,避免异常。
onRelease?.Invoke(item); // 调用回收回调,例如隐藏对象和清理数据。
cache.Push(item); // 放回缓存栈中等待下次复用。
} // Release 方法结束。
} // 对象池类结束。
public interface IState // 定义状态接口。
{ // 状态接口开始。
void Enter(); // 进入状态时调用。
void Tick(float deltaTime); // 状态每帧更新时调用。
void Exit(); // 离开状态时调用。
} // 状态接口结束。
public sealed class StateMachine // 定义有限状态机。
{ // 状态机类开始。
private IState currentState; // 保存当前状态。
public void ChangeState(IState nextState) // 切换到下一个状态。
{ // ChangeState 方法开始。
if (ReferenceEquals(currentState, nextState)) return; // 如果状态没变,就不重复切换。
currentState?.Exit(); // 退出旧状态。
currentState = nextState; // 更新当前状态。
currentState?.Enter(); // 进入新状态。
} // ChangeState 方法结束。
public void Tick(float deltaTime) // 每帧更新状态机。
{ // Tick 方法开始。
currentState?.Tick(deltaTime); // 调用当前状态的更新逻辑。
} // Tick 方法结束。
} // 状态机类结束。
public sealed class EventBus // 定义事件系统。
{ // 事件系统类开始。
private readonly Dictionary<Type, Delegate> handlers = new Dictionary<Type, Delegate>(); // 用事件类型映射回调列表。
public void Subscribe<T>(Action<T> handler) // 订阅某种类型的事件。
{ // Subscribe 方法开始。
Type type = typeof(T); // 获取事件类型。
handlers[type] = handlers.TryGetValue(type, out Delegate oldHandler) ? Delegate.Combine(oldHandler, handler) : handler; // 合并回调并保持订阅顺序。
} // Subscribe 方法结束。
public void Unsubscribe<T>(Action<T> handler) // 取消订阅某种类型的事件。
{ // Unsubscribe 方法开始。
Type type = typeof(T); // 获取事件类型。
if (!handlers.TryGetValue(type, out Delegate oldHandler)) return; // 没有订阅记录就直接返回。
Delegate newHandler = Delegate.Remove(oldHandler, handler); // 从回调链中移除指定回调。
if (newHandler == null) handlers.Remove(type); // 如果没有回调了,就移除事件类型。
else handlers[type] = newHandler; // 如果还有回调,就写回新的回调链。
} // Unsubscribe 方法结束。
public void Publish<T>(T message) // 发布某种类型的事件。
{ // Publish 方法开始。
if (!handlers.TryGetValue(typeof(T), out Delegate handler)) return; // 没有订阅者就直接返回。
foreach (Delegate callback in handler.GetInvocationList()) // 按订阅顺序逐个调用,方便保证事件顺序。
{ // foreach 开始。
try { ((Action<T>)callback).Invoke(message); } // 调用订阅者回调,并隔离单个异常。
catch (Exception exception) { Debug.LogException(exception); } // 某个监听者报错时记录异常,不中断后面的监听者。
} // foreach 结束。
} // Publish 方法结束。
} // 事件系统类结束。TIP
加分句: 对象池要防重复回收和状态没重置;状态机要防状态爆炸,可以拆分层状态机或组合状态;事件系统要注意取消订阅、事件顺序和异常隔离。这样答出来,就不像只是会写工具类,而是有框架设计意识。
项目有 Shader 或渲染效果
NOTE
面试回答: 可以这样说:项目里我做过一些 Shader 和渲染表现,比如角色受击闪白、边缘光高亮、死亡溶解、技能范围预警、UI 灰度、冷却遮罩等。我的重点不是单纯写一个效果,而是把它接进战斗流程:比如怪物死亡时播放溶解,受击时触发 Rim 高亮,技能释放前显示范围预警。
实现上一般是:美术提供主贴图、噪声图、Mask 图;Shader 暴露颜色、强度、溶解进度等参数;程序用 MaterialPropertyBlock 或材质参数驱动效果;最后用真机、Frame Debugger、Profiler 看 DrawCall、Overdraw、GPU 时间。
Shader 示例:角色溶解 + Rim Light
c
Shader "Interview/DissolveRim" // 定义一个面试用的溶解加边缘光 Shader。
{ // Shader 开始。
Properties // 定义材质面板参数。
{ // Properties 开始。
_MainTex ("Main Texture", 2D) = "white" {} // 主贴图。
_NoiseTex ("Noise Texture", 2D) = "white" {} // 溶解噪声图。
_Color ("Color", Color) = (1, 1, 1, 1) // 主颜色。
_EdgeColor ("Edge Color", Color) = (1, 0.5, 0, 1) // 溶解边缘颜色。
_Dissolve ("Dissolve", Range(0, 1)) = 0 // 溶解进度。
_EdgeWidth ("Edge Width", Range(0.01, 0.2)) = 0.05 // 溶解边缘宽度。
_RimColor ("Rim Color", Color) = (0.3, 0.8, 1, 1) // 边缘光颜色。
_RimPower ("Rim Power", Range(1, 8)) = 3 // 边缘光强度。
} // Properties 结束。
SubShader // 定义子着色器。
{ // SubShader 开始。
Tags { "RenderType"="TransparentCutout" "Queue"="AlphaTest" } // 使用裁剪队列,避免半透明排序问题。
Pass // 定义一个渲染 Pass。
{ // Pass 开始。
ZWrite On // 开启深度写入,裁剪物体仍然能正确遮挡。
CGPROGRAM // 开始 CG/HLSL 代码。
#pragma vertex vert // 指定顶点函数。
#pragma fragment frag // 指定片元函数。
#pragma target 2.0 // 降低目标版本,提高移动端兼容性。
#include "UnityCG.cginc" // 引入 Unity 常用函数。
sampler2D _MainTex; // 声明主贴图采样器。
sampler2D _NoiseTex; // 声明噪声图采样器。
float4 _MainTex_ST; // 主贴图缩放偏移。
half4 _Color; // 主颜色使用 half 精度。
half4 _EdgeColor; // 边缘颜色使用 half 精度。
half _Dissolve; // 溶解进度。
half _EdgeWidth; // 溶解边缘宽度。
half4 _RimColor; // 边缘光颜色。
half _RimPower; // 边缘光指数。
struct appdata // 顶点输入结构。
{ // appdata 开始。
float4 vertex : POSITION; // 顶点位置。
float3 normal : NORMAL; // 顶点法线。
float2 uv : TEXCOORD0; // 顶点 UV。
}; // appdata 结束。
struct v2f // 顶点到片元的数据结构。
{ // v2f 开始。
float4 pos : SV_POSITION; // 裁剪空间坐标。
float2 uv : TEXCOORD0; // 传递 UV。
half3 worldNormal : TEXCOORD1; // 世界空间法线。
half3 viewDir : TEXCOORD2; // 视线方向。
}; // v2f 结束。
v2f vert(appdata v) // 顶点着色器。
{ // vert 开始。
v2f o; // 创建输出数据。
o.pos = UnityObjectToClipPos(v.vertex); // 模型空间转裁剪空间。
o.uv = TRANSFORM_TEX(v.uv, _MainTex); // 应用贴图缩放偏移。
o.worldNormal = UnityObjectToWorldNormal(v.normal); // 法线转世界空间。
o.viewDir = WorldSpaceViewDir(v.vertex); // 计算世界空间视线方向。
return o; // 返回顶点输出。
} // vert 结束。
half4 frag(v2f i) : SV_Target // 片元着色器。
{ // frag 开始。
half noise = tex2D(_NoiseTex, i.uv).r; // 读取噪声值。
clip(noise - _Dissolve); // 噪声小于溶解值的像素被裁掉。
half4 col = tex2D(_MainTex, i.uv) * _Color; // 采样主贴图并乘主颜色。
half edge = step(noise, _Dissolve + _EdgeWidth); // 计算溶解边缘区域。
half rim = pow(saturate(1 - dot(normalize(i.worldNormal), normalize(i.viewDir))), _RimPower); // 计算边缘光。
col.rgb = lerp(col.rgb, _EdgeColor.rgb, edge * _EdgeColor.a); // 给溶解边缘染色。
col.rgb += rim * _RimColor.rgb * _RimColor.a; // 叠加 Rim Light。
return col; // 输出最终颜色。
} // frag 结束。
ENDCG // 结束 CG/HLSL 代码。
} // Pass 结束。
} // SubShader 结束。
} // Shader 结束。NOTE
加分句: 我会强调移动端取舍:溶解用 clip 比半透明更好控排序和深度;参数变化尽量用 MaterialPropertyBlock,避免频繁实例化材质;效果上线前会看 Overdraw、Shader Variant 数量和真机 GPU 时间。
项目有网络同步或联机 Demo
TIP
面试回答: 可以这样说:我做过一个简化联机 Demo,采用的是服务端权威 + 客户端表现优化。客户端不直接上报“我现在在哪”,而是上报输入,比如方向、技能、序号;服务端负责校验输入、模拟角色状态、结算技能和伤害,然后广播状态快照。
客户端自己角色用本地预测,按键后立刻移动,保证手感;等服务端回包后,如果误差小就平滑校正,误差大才拉回。其他玩家用快照插值,客户端延迟一点播放服务器快照,解决弱网抖动和位置瞬移问题。
C# 示例:客户端预测 + 远端快照插值骨架
c
using System.Collections.Generic; // 引入 List,用来保存远端玩家快照。
using UnityEngine; // 引入 MonoBehaviour、Vector3、Time 等 Unity 类型。
public struct PlayerInputMessage // 定义客户端发给服务端的输入消息。
{ // 输入消息结构体开始。
public int Sequence; // 输入序号,用来做确认和重放。
public Vector2 Move; // 移动方向,客户端只发输入,不发最终位置。
public float ClientTime; // 客户端发送输入时的时间。
} // 输入消息结构体结束。
public struct PlayerSnapshotMessage // 定义服务端广播回来的状态快照。
{ // 快照结构体开始。
public int AckSequence; // 服务端已经处理到的输入序号。
public float ServerTime; // 服务端快照时间。
public Vector3 Position; // 服务端权威位置。
} // 快照结构体结束。
public sealed class NetworkSyncDemoClient : MonoBehaviour // 定义联机 Demo 客户端同步组件。
{ // 类开始。
public Transform remotePlayer; // 保存远端玩家的显示对象。
private readonly List<PlayerSnapshotMessage> remoteSnapshots = new List<PlayerSnapshotMessage>(); // 保存远端玩家快照缓冲。
private int nextSequence; // 保存下一个输入序号。
private Vector3 predictedPosition; // 保存本地预测位置。
private const float MoveSpeed = 5f; // 定义角色移动速度。
private const float InterpolationDelay = 0.1f; // 定义远端玩家插值延迟。
private void Start() // 初始化组件。
{ // 方法开始。
predictedPosition = transform.position; // 用当前角色位置初始化预测位置。
} // 方法结束。
private void Update() // 每帧更新本地预测和远端插值。
{ // 方法开始。
PlayerInputMessage input = BuildInputMessage(); // 构造本帧输入消息。
ApplyLocalPrediction(input); // 先在本地执行预测移动,保证手感。
SendInputToServer(input); // 把输入发给服务端,真实项目这里会走网络层。
RenderRemotePlayer(); // 根据服务器快照插值显示远端玩家。
} // 方法结束。
private PlayerInputMessage BuildInputMessage() // 构造客户端输入消息。
{ // 方法开始。
Vector2 move = new Vector2(Input.GetAxisRaw("Horizontal"), Input.GetAxisRaw("Vertical")); // 读取 WASD 或摇杆输入。
return new PlayerInputMessage { Sequence = nextSequence++, Move = move.normalized, ClientTime = Time.time }; // 返回带序号的输入包。
} // 方法结束。
private void ApplyLocalPrediction(PlayerInputMessage input) // 执行客户端本地预测。
{ // 方法开始。
Vector3 delta = new Vector3(input.Move.x, 0f, input.Move.y) * MoveSpeed * Time.deltaTime; // 根据输入计算本帧位移。
predictedPosition += delta; // 更新预测位置。
transform.position = predictedPosition; // 立刻显示到角色身上。
} // 方法结束。
public void OnServerSnapshot(PlayerSnapshotMessage snapshot) // 收到服务端自己的权威快照时调用。
{ // 方法开始。
float error = Vector3.Distance(predictedPosition, snapshot.Position); // 计算预测位置和服务端位置误差。
if (error > 1f) // 如果误差太大,说明预测已经明显偏离。
{ // 判断开始。
predictedPosition = snapshot.Position; // 直接拉回服务端权威位置。
} // 判断结束。
else // 如果误差不大。
{ // else 开始。
predictedPosition = Vector3.Lerp(predictedPosition, snapshot.Position, 0.15f); // 平滑校正误差,避免画面抖动。
} // else 结束。
} // 方法结束。
public void AddRemoteSnapshot(PlayerSnapshotMessage snapshot) // 收到其他玩家快照时调用。
{ // 方法开始。
remoteSnapshots.Add(snapshot); // 把快照加入缓冲区。
remoteSnapshots.Sort((a, b) => a.ServerTime.CompareTo(b.ServerTime)); // 按服务端时间排序,处理乱序包。
} // 方法结束。
private void RenderRemotePlayer() // 插值显示远端玩家。
{ // 方法开始。
if (remotePlayer == null || remoteSnapshots.Count < 2) return; // 没有对象或快照不足就不处理。
float renderTime = Time.time - InterpolationDelay; // 延迟一点播放,吸收网络抖动。
while (remoteSnapshots.Count >= 2 && remoteSnapshots[1].ServerTime <= renderTime) // 丢弃已经过期的旧快照。
{ // 循环开始。
remoteSnapshots.RemoveAt(0); // 删除最旧快照,避免列表无限增长。
} // 循环结束。
PlayerSnapshotMessage from = remoteSnapshots[0]; // 取插值起点。
PlayerSnapshotMessage to = remoteSnapshots[1]; // 取插值终点。
float t = Mathf.InverseLerp(from.ServerTime, to.ServerTime, renderTime); // 计算插值比例。
remotePlayer.position = Vector3.Lerp(from.Position, to.Position, t); // 平滑显示远端玩家位置。
} // 方法结束。
private void SendInputToServer(PlayerInputMessage input) // 发送输入到服务端。
{ // 方法开始。
Debug.Log("Send input seq: " + input.Sequence); // Demo 中用日志代替真实网络发送。
} // 方法结束。
} // 类结束。TIP
加分句: 我会强调:普通位置同步可以丢旧包,因为新快照会覆盖;但登录、购买、技能释放、奖励结算这种关键消息必须可靠,通常要带 seq、ack、超时重发和重连恢复。服务端始终是权威,客户端只负责表现和手感。
项目有 C++ 引擎向 Demo
IMPORTANT
面试回答: 可以这样说:我做过一个偏 C++ 引擎向的 Demo,它不是单纯展示玩法,而是展示一个小型引擎 Runtime 的基本结构:有 Application 主循环,有 Window/Input/Scene/Resource/Render 等模块,有统一的初始化、更新和销毁流程,也有日志、Profiler、资源句柄、RAII 管理这些底层能力。
我会重点讲:主循环每帧先处理平台事件和输入,再更新场景,再提交渲染队列,最后 Present;资源模块负责加载纹理、Mesh、Shader,并用句柄管理生命周期;渲染模块不直接依赖玩法对象,而是接收 RenderItem,排序后生成 DrawCommand。
C++ 示例:简化引擎主循环和模块管理
c
#include <iostream> // 引入标准输出,用来打印日志。
#include <memory> // 引入 unique_ptr,用 RAII 管理模块生命周期。
#include <vector> // 引入 vector,用来保存模块列表。
#include <string> // 引入 string,用来保存模块名字。
class IModule // 定义引擎模块接口。
{ // 接口类开始。
public: // 公开接口开始。
virtual ~IModule() = default; // 虚析构,保证通过基类指针删除子类安全。
virtual std::string Name() const = 0; // 返回模块名字,方便日志和调试。
virtual void Init() = 0; // 模块初始化。
virtual void Tick(float deltaTime) = 0; // 模块每帧更新。
virtual void Shutdown() = 0; // 模块关闭和释放资源。
}; // 接口类结束。
class RenderModule final : public IModule // 定义渲染模块。
{ // 渲染模块类开始。
public: // 公开接口开始。
std::string Name() const override { return "Render"; } // 返回模块名字。
void Init() override { std::cout << "Render Init\n"; } // 初始化渲染设备和管线。
void Tick(float deltaTime) override { std::cout << "Render Tick\n"; } // 每帧提交渲染命令。
void Shutdown() override { std::cout << "Render Shutdown\n"; } // 释放渲染资源。
}; // 渲染模块类结束。
class ResourceModule final : public IModule // 定义资源模块。
{ // 资源模块类开始。
public: // 公开接口开始。
std::string Name() const override { return "Resource"; } // 返回模块名字。
void Init() override { std::cout << "Resource Init\n"; } // 初始化资源缓存和加载器。
void Tick(float deltaTime) override { std::cout << "Resource Tick\n"; } // 每帧处理异步加载完成队列。
void Shutdown() override { std::cout << "Resource Shutdown\n"; } // 释放缓存资源。
}; // 资源模块类结束。
class Engine // 定义引擎主类。
{ // Engine 类开始。
private: // 私有成员开始。
bool running = true; // 控制主循环是否继续运行。
std::vector<std::unique_ptr<IModule>> modules; // 用 unique_ptr 保存模块,自动管理生命周期。
public: // 公开接口开始。
void AddModule(std::unique_ptr<IModule> module) // 注册一个模块。
{ // AddModule 方法开始。
modules.push_back(std::move(module)); // 把模块所有权移动进模块列表。
} // AddModule 方法结束。
void Init() // 初始化所有模块。
{ // Init 方法开始。
for (auto& module : modules) // 按注册顺序遍历模块。
{ // for 开始。
module->Init(); // 调用模块初始化。
} // for 结束。
} // Init 方法结束。
void Run() // 执行主循环。
{ // Run 方法开始。
int frame = 0; // 记录运行帧数,Demo 中用来退出循环。
while (running) // 主循环开始。
{ // while 开始。
float deltaTime = 1.0f / 60.0f; // Demo 中固定模拟 60 FPS。
PollEvents(); // 处理窗口和输入事件。
for (auto& module : modules) // 遍历所有模块。
{ // for 开始。
module->Tick(deltaTime); // 按顺序更新模块。
} // for 结束。
Present(); // 提交最终画面。
frame++; // 帧数加一。
if (frame >= 3) running = false; // Demo 只跑三帧后退出。
} // while 结束。
} // Run 方法结束。
void Shutdown() // 关闭所有模块。
{ // Shutdown 方法开始。
for (auto it = modules.rbegin(); it != modules.rend(); ++it) // 按初始化反方向遍历模块。
{ // for 开始。
(*it)->Shutdown(); // 关闭模块并释放资源。
} // for 结束。
} // Shutdown 方法结束。
private: // 私有方法开始。
void PollEvents() // 处理平台事件。
{ // PollEvents 方法开始。
std::cout << "Poll Events\n"; // Demo 中用日志表示处理输入和窗口事件。
} // PollEvents 方法结束。
void Present() // 显示最终画面。
{ // Present 方法开始。
std::cout << "Present Frame\n"; // Demo 中用日志表示交换缓冲区。
} // Present 方法结束。
}; // Engine 类结束。NOTE
加分句: C++ 引擎向 Demo 最好强调 RAII、模块边界、主循环顺序、资源句柄、渲染队列、内存池、Profiler。面试官听到这些,会更容易判断你不是只会调用现成引擎 API,而是理解引擎底层怎么组织。
项目代码结构清晰,模块边界明确
WARNING
面试回答: 可以这样说:项目代码结构清晰,不是说文件夹很多,而是每个模块职责明确、依赖方向明确。我的项目一般分成 Game、Services、Framework、Data、Editor 几层:Game 写具体玩法,Services 提供资源、音频、网络、存档能力,Framework 放事件系统、状态机、对象池、计时器等通用框架。
核心原则是:上层可以依赖下层,下层不能反向依赖上层。 比如战斗模块不能直接操作 UI 面板,而是发布 MonsterKilledEvent,任务系统、UI、音效系统各自监听。这样换 UI、换资源系统、改任务逻辑时,不会牵一发动全身。
C# 示例:用接口划清模块边界
c
using System; // 引入 Action,用来定义事件回调。
using UnityEngine; // 引入 Debug,方便打印日志。
public interface IGameModule // 定义游戏模块统一接口。
{ // 接口开始。
void Init(ModuleContext context); // 初始化模块,并注入上下文。
void Tick(float deltaTime); // 每帧更新模块。
void Dispose(); // 释放模块资源和事件订阅。
} // 接口结束。
public interface IEventBus // 定义事件系统接口。
{ // 接口开始。
void Publish<T>(T message); // 发布事件消息。
void Subscribe<T>(Action<T> handler); // 订阅事件消息。
void Unsubscribe<T>(Action<T> handler); // 取消订阅事件消息。
} // 接口结束。
public interface IResourceService // 定义资源服务接口。
{ // 接口开始。
GameObject LoadPrefab(string key); // 通过 key 加载 Prefab。
} // 接口结束。
public sealed class ModuleContext // 定义模块上下文。
{ // 类开始。
public IEventBus Events { get; } // 暴露事件系统接口。
public IResourceService Resources { get; } // 暴露资源服务接口。
public ModuleContext(IEventBus events, IResourceService resources) // 构造上下文。
{ // 构造函数开始。
Events = events; // 保存事件系统。
Resources = resources; // 保存资源服务。
} // 构造函数结束。
} // 类结束。
public readonly struct MonsterKilledEvent // 定义怪物死亡事件数据。
{ // 结构体开始。
public readonly int MonsterId; // 保存怪物 ID。
public MonsterKilledEvent(int monsterId) // 构造事件。
{ // 构造函数开始。
MonsterId = monsterId; // 写入怪物 ID。
} // 构造函数结束。
} // 结构体结束。
public sealed class BattleModule : IGameModule // 定义战斗模块。
{ // 类开始。
private ModuleContext context; // 保存模块上下文。
public void Init(ModuleContext context) // 初始化战斗模块。
{ // 方法开始。
this.context = context; // 保存上下文引用。
} // 方法结束。
public void Tick(float deltaTime) // 更新战斗模块。
{ // 方法开始。
Debug.Log("Battle ticking"); // 示例:执行战斗逻辑。
} // 方法结束。
public void OnMonsterKilled(int monsterId) // 怪物死亡时调用。
{ // 方法开始。
context.Events.Publish(new MonsterKilledEvent(monsterId)); // 发布事件,不直接操作 UI 或任务模块。
} // 方法结束。
public void Dispose() // 清理战斗模块。
{ // 方法开始。
context = null; // 清空上下文引用。
} // 方法结束。
} // 类结束。IMPORTANT
加分句: 我会用 namespace、asmdef、接口和事件限制依赖方向:Framework 不知道 Game,Service 不写具体玩法规则,业务模块之间不直接互相 new,而是通过接口、事件、DTO、资源句柄协作。这样项目大了以后才不会变成一团。
项目能现场演示,能讲清楚取舍
TIP
面试回答: 可以这样说:我的项目是可以现场演示的,而且我会准备一条固定路线:先从主界面进入关卡,演示移动、战斗、技能、结算、奖励入库这一套闭环;然后打开调试面板,展示当前状态机、事件日志、资源引用、性能数据;最后挑一个复杂模块,比如技能系统、资源管理或网络同步,讲它的设计取舍。
讲取舍时不要说“我就是这么做的”,要说清楚:为什么选这个方案、它带来的收益、它付出的代价、后续怎么改。比如对象池减少 GC,但会占常驻内存,所以要限制容量;事件系统降低耦合,但调用链会隐藏,所以要做事件日志;异步预加载减少卡顿,但会提高峰值内存,所以要分批加载和及时释放。
C# 示例:演示模式 Debug 面板骨架
c
using UnityEngine; // 引入 Unity 基础 API。
using UnityEngine.UI; // 引入 UI 文本组件。
public sealed class DemoDebugPanel : MonoBehaviour // 定义现场演示用调试面板。
{ // 类开始。
public Text stateText; // 显示当前玩法状态。
public Text resourceText; // 显示资源引用信息。
public Text performanceText; // 显示性能信息。
private int frameCount; // 记录帧数。
private float totalTime; // 记录累计时间。
private string currentState = "MainMenu"; // 保存当前演示状态。
private int loadedResourceCount; // 保存已加载资源数量。
private int activePoolObjectCount; // 保存对象池活跃对象数量。
private void Update() // 每帧更新调试面板。
{ // 方法开始。
frameCount++; // 帧数加一。
totalTime += Time.unscaledDeltaTime; // 累加真实时间,不受暂停影响。
if (Input.GetKeyDown(KeyCode.F1)) // 按 F1 切换到战斗演示状态。
{ // 判断开始。
currentState = "BattleDemo"; // 更新当前状态。
} // 判断结束。
if (Input.GetKeyDown(KeyCode.F2)) // 按 F2 模拟加载资源。
{ // 判断开始。
loadedResourceCount++; // 已加载资源数量加一。
} // 判断结束。
if (Input.GetKeyDown(KeyCode.F3)) // 按 F3 模拟对象池生成对象。
{ // 判断开始。
activePoolObjectCount++; // 活跃池对象数量加一。
} // 判断结束。
RefreshPanel(); // 刷新面板显示。
} // 方法结束。
private void RefreshPanel() // 刷新调试信息。
{ // 方法开始。
float fps = totalTime > 0f ? frameCount / totalTime : 0f; // 计算平均 FPS。
if (stateText != null) stateText.text = "State: " + currentState; // 显示当前状态。
if (resourceText != null) resourceText.text = "Resources: " + loadedResourceCount; // 显示资源数量。
if (performanceText != null) performanceText.text = "FPS: " + fps.ToString("F1") + " PoolActive: " + activePoolObjectCount; // 显示性能和对象池数据。
} // 方法结束。
} // 类结束。NOTE
一句话背法: 现场演示证明项目真的能跑,调试面板证明你知道内部状态,取舍分析证明你不是只会堆功能,而是知道收益、代价、风险和改进方向。