1. 从一块绣片到可漫游的数字空间:这个项目到底在做什么
瓯绣是温州地区流传下来的传统刺绣工艺,针法细密、配色雅致,一幅好的绣片拿在手里,光线扫过丝线会有明显的明暗转折,这种质感照片很难拍出来,实物展柜隔着玻璃更看不真切。我做的这个项目,就是用 Unity 引擎配合 3D 建模和 C# 脚本,搭一个可以在里面自由走动、近距离端详展品、点击查看讲解的虚拟展馆。用户戴上耳机、握着鼠标,或者在手机上滑动屏幕,就能像逛真实展厅一样,从大门走到展墙前,凑近看一幅《百鸟图》的羽毛是怎么用套针一层层叠出来的。
说清楚它是什么之后,还得说清楚它解决了什么。传统实物展馆有几个绕不开的限制:展品怕光怕潮,长期陈列对绣品损伤大,所以灯光必须压得很暗,观众反而看不清针脚;换展周期长,一次布展动辄几周;场地固定,外地观众想看只能专程跑一趟。虚拟展馆把这些限制反过来变成优势——光照可以随便打,我可以给每幅绣品单独配一盏补光灯,把丝线的高光打出来;展品数据放在配置表里,改一行字就能换展;打包出来的程序放到任意一台机器上都能跑,手机也能装。
这个内容适合谁看?如果你正在做文化遗产数字化、虚拟展厅、VR 看房这类需要"空间漫游 + 展品交互"的项目,这篇记录基本可以直接抄作业;如果你是 Unity 新手,想找一个有完整业务闭环的练手项目,瓯绣展馆这种规模(十几个展区、几十件展品)刚好合适,不会大到劝退,也不会小到没东西可写;如果你只关心 C# 脚本层面的事件系统、数据驱动设计,第四部分可以直接跳过去看。
我前后花了大概两个月,中间返工了三次,踩过的坑主要集中在资产规范和性能上,这些后面会一个个说清楚。
1.1 三条技术路线我为什么最后选了 Unity
做虚拟展馆,摆在面前的路其实有三条,我最初是冲着网页方案去的,因为"打开浏览器就能看"这件事对传播太友好了。实际跑下来才发现没那么简单。
| 技术路线 | 开发效率 | 画面表现 | 分发难度 | 移动端表现 | 我的结论 |
|---|---|---|---|---|---|
| Three.js / WebGL 自研 | 低,交互要手写 | 中等,PBR 支持有限 | 最低,一个链接 | 中低端机卡顿明显 | 适合轻量展示 |
| Unity 导出 WebGL | 中,复用编辑器 | 好,光照材质完整 | 中,包体大加载慢 | 发热、内存吃紧 | 适合桌面浏览器 |
| Unity 原生打包(PC/安卓) | 高,一次开发多端 | 最好,可上高质量光照 | 中,需下载安装 | 旗舰机流畅 | 最终选它 |
我最终选的是 Unity 原生打包为主、WebGL 版本作为轻量入口的双轨方案。理由很实在:瓯绣的核心看点是丝线的质感和针法的层次,这个东西对光照和材质精度要求高,Unity 的 PBR(基于物理的渲染)管线配合烘焙光照,能把丝绸那种"半哑光带方向性高光"的感觉做出来,网页端在低端设备上很难稳定跑到这个效果。而且 C# 的生态成熟,数据配置、事件系统、异步加载这些都有现成轮子,不用自己造。
代价也得认:原生包体起步就是几百兆,用户得下载。我的处理是把贴图压缩和资源分包做扎实,主包控制在 300MB 以内,其余展区资源按需下载,第一屏能在 10 秒内进得去。
1.2 系统功能模块怎么切分
一个能跑起来的展馆,拆开看其实是六个互不干扰的模块,我用一张清单把它列清楚,后面每一部分都会展开:
- 资源加载模块:负责场景、贴图、音频、配置表的异步加载和内存释放,是性能的地基。
- 漫游控制模块:第一人称自由行走 + 固定机位轨道漫游两套控制器,切换由 UI 按钮触发。
- 展品交互模块:射线检测选中展品,弹出信息面板,支持旋转、缩放、局部高亮。
- 导览解说模块:预设参观路径,相机沿样条曲线移动,配合语音讲解。
- UI 与适配模块:分辨率自适应、字体、多语言预留。
- 数据配置模块:所有展品信息走 ScriptableObject,策划改内容不用碰代码。
这样切的目的是让每个人只关心自己那一层。比如美术只管往资源目录里丢模型,命名规范对了就能自动挂载;文案只管改配置表里的文本字段,改完重新打包即可生效。模块之间通过事件总线通信,互不直接引用,后期想加一个"线上答题"或者"收藏夹"功能,挂一个新的监听者就行。
2. 瓯绣资产从实物到 3D 的完整生产链
这是整个项目最耗时间、也最能体现质量差距的部分。虚拟展馆好不好看,八成取决于资产做得好不好,跟引擎关系不大。瓯绣的资产和普通道具不一样,它的核心信息全在表面:丝线的走向、针脚的疏密、颜色的过渡、绣面的起伏。如果只拿一张照片贴到一个平面上,凑近一看就是一张画,完全没有立体感,那这个虚拟展馆就白做了。
我的生产链是这样走的:实物高清采集 → 纹样矢量化与修图 → 建模与拓扑 → 展平 UV 与贴图烘焙 → 材质参数调校 → 导入引擎做规范检查。整套流程走下来,一幅中等复杂度的绣片大概要 6 到 8 个小时,其中一半时间花在修图和烘焙上。
2.1 纹样采集与矢量化处理
采集环节我会用两种方式配合。整体画面用高分辨率翻拍,光源用两侧 45 度柔光箱,避免正打导致高光溢出。局部细节用微距镜头分块拍,尤其是针脚走向复杂的地方,比如鱼鳞、羽毛、花瓣边缘,这些地方后期建模要参考。翻拍时一定放色卡,不然后期校色没有基准,蓝色绣线很容易偏成紫。
矢量化不是为了做矢量图,而是为了提取轮廓。我把翻拍图丢进图像处理工具做边缘检测,把绣片的整体外形、内部分区(比如花瓣和花蕊的边界)转成路径,导出后在建模软件里当底图参考。这样做的好处是模型轮廓能严格贴合真实绣片,不会出现"绣片是圆的、模型是椭圆的"这种尴尬。
注意:翻拍时相机的白平衡一定要手动锁定,别用自动。自动白平衡在不同光源下会漂移,同一批图色调不一致,后期统一调色会非常痛苦。
纹理贴图方面,我最终用的是"高清翻拍图 + 手工修补"的组合。翻拍图直接做基础色贴图,但绣品通常有布面褶皱、反光不均、边缘磨损,这些要手工修平,否则烘焙进材质里会变成脏斑。修图的时候保留适度的布料纹理,完全磨平反而假。
2.2 建模:从高模到低模的烘焙流程
瓯绣展品的模型分两类,处理方式完全不同。
第一类是平面绣片类,比如挂屏、绣画。这类东西真实形态是"一块布",但布不是完全平的,它有轻微的起伏和边缘卷曲。我的做法是用细分曲面做一个带轻微凹凸的薄板,边缘根据实物照片修出自然的弧度和磨损感。高模大概 5 万面,用来烘焙法线;低模拓扑到 800 到 1500 面,保证在展墙前贴近看也不会露出多边形棱角。
第二类是立体绣品和绣具,比如绣球、绣鞋、绣绷、针线篓。这类要完整建出体积,高模 20 万面左右,低模压到 3000 到 5000 面。绣绷这种有细长杆件的,杆件部分单独拆开,避免和主体挤在一个网格里导致拓扑困难。
烘焙是关键一步。我在 Blender 里把高模和低模叠好,用 Cycles 烘焙法线贴图和环境光遮蔽(AO)贴图,分辨率 2048×2048 起步,重点展品上到 4096。这里有个细节:瓯绣表面的起伏非常浅,法线强度如果按默认设置,出来的效果会过分夸张,像浮雕一样。我把法线强度调到 0.4 到 0.6 之间,试了好几次才找到既有立体感又不假的程度。
| 资产类型 | 高模面数 | 低模面数 | 贴图分辨率 | 法线强度参考 |
|---|---|---|---|---|
| 平面绣片 | 约 5 万 | 800–1500 | 2048 | 0.4–0.5 |
| 立体绣品 | 约 20 万 | 3000–5000 | 2048 | 0.5–0.6 |
| 绣具小件 | 约 8 万 | 1500–2500 | 1024 | 0.5 |
| 展馆建筑构件 | 约 10 万 | 2000–4000 | 2048 | 0.3 |
2.3 绣线光泽的材质参数怎么调
这是最能拉开质感差距的一环。丝绸的光学特性是"各向异性高光",也就是说,高光的形状会随着丝线方向拉伸,而不是一个圆点。Unity 的标准着色器不支持各向异性,我用了两种替代方案:
一是用URP 的 Lit 着色器配合高光滑面贴图,把丝线的方向信息编码进高光贴图,强行做出方向性高光。这个方案兼容性好,缺点是效果有限,需要靠光照配合。二是自定义着色器,用切线空间做各向异性计算,效果最好但开发成本高,我只在几件重点展品上用了。
基础参数上,我把金属度统一设为 0,因为丝绸不是金属;光滑度(Smoothness)控制在 0.35 到 0.55 之间,太低没有丝光,太高就变成塑料了。这个值我是拿实物在同样光照下对比着调的,第一次设了 0.8,渲染出来整幅绣片跟镀了膜一样,惨不忍睹。
提示:调材质别在纯色背景里调。把模型放到最终场景的实际光照环境下看,尤其是带方向光的展区。同一个材质参数,在不同的环境光下观感差别很大。
2.4 资产命名与导入规范
资产规范这件事,前期偷懒后期加倍还。我第一版没定规范,美术交上来的文件叫"新建文件夹 (2)/final_final.fbx"这种,脚本按名字找资源全找不到,返工了整整一周。后来定了强制规范:
- 模型:
MOD_展区编号_展品编号_名称,例如MOD_A01_003_BainiaoTu - 贴图:
TEX_展品编号_类型,类型用 BaseColor / Normal / Mask 区分 - 配置表:
CFG_展区编号,一张展区一个文件
导入设置也统一了:贴图按平台压缩(桌面端 BC7,安卓端 ASTC),关闭 Read/Write Enabled 省内存,模型关闭自动生成的碰撞体,改用自定义简化碰撞。这些设置我写了一份导入预设(Preset),美术直接套用,避免逐个手改。
3. 展馆场景搭建与漫游控制的落地细节
场景搭建这部分,很多人以为就是摆模型,实际做起来坑不少。展馆是一种特殊的空间:它有明确的参观动线,有大量重复的墙面构件,有严格的光照需求,还要保证帧率稳定。我按"结构先行、光照其次、交互最后"的顺序做,每一步都有验证节点,不然后期改结构会连带推翻光照和碰撞。
3.1 场景组织与光照烘焙策略
展馆结构我拆成三层:外壳层(地面、墙体、天花、固定立柱)、陈设层(展柜、展墙、屏风、灯具模型)、展品层(所有绣品和绣具)。三层分别放在不同的父节点下,各自用静态标记区分。这样做是为了烘焙光照时能精细控制哪些物体参与。
光照上我走了弯路。第一版用了全实时光照加实时阴影,画面确实漂亮,但高端机也跑不满 40 帧,因为展馆里有十几盏灯,实时阴影的开销直接爆炸。后来改成混合方案:主光方向光(模拟天窗进来的自然光)保留实时阴影但降低阴影距离,展柜内的射灯全部改成烘焙光照,用光照贴图(Lightmap)烘死。烘焙分辨率我设的是每单位 40 到 60 texel,重点展区上到 80。
烘焙的时候有个参数一定要盯:光照贴图缩放(Scale In Lightmap)。默认值经常不合适,大墙面会给太多 texel 浪费,小展品又给太少出现噪点。我一般是场景整体烘一次看效果,然后逐个物体调缩放值,重点物体加大,大平板减小。
注意:烘焙前一定要把场景里所有需要烘的物体标记为 Static,否则它不会进烘焙。我第一版漏标记了三面展墙,烘出来的墙上全是漏光的黑斑,查了半天才发现是这个问题。
3.2 第一人称与轨道双模式漫游实现
漫游我用的是CharacterController组件做第一人称行走,配合一个相机子物体。为什么不直接用刚体加力?因为展馆里有很多窄门、台阶、展柜边缘,用物理移动容易被卡住或者被弹开,CharacterController的Move方法可以精确控制滑动,踩台阶也更稳。
关键的移动脚本大致长这样:
using UnityEngine; [RequireComponent(typeof(CharacterController))] public class FirstPersonWalker : MonoBehaviour { public float walkSpeed = 2.5f; public float runSpeed = 5.0f; public float gravity = -9.81f; public float mouseSensitivity = 2.0f; public Transform cameraPivot; private CharacterController controller; private Vector3 velocity; private float pitch = 0f; void Awake() => controller = GetComponent<CharacterController>(); void Update() { // 视角旋转 float mx = Input.GetAxis("Mouse X") * mouseSensitivity; float my = Input.GetAxis("Mouse Y") * mouseSensitivity; transform.Rotate(0f, mx, 0f); pitch = Mathf.Clamp(pitch - my, -80f, 80f); cameraPivot.localEulerAngles = new Vector3(pitch, 0f, 0f); // 位移 float h = Input.GetAxis("Horizontal"); float v = Input.GetAxis("Vertical"); float speed = Input.GetKey(KeyCode.LeftShift) ? runSpeed : walkSpeed; Vector3 move = transform.right * h + transform.forward * v; controller.Move(move * speed * Time.deltaTime); // 重力,贴地时给一个小的向下保持力,防止漂浮 if (controller.isGrounded && velocity.y < 0f) velocity.y = -2f; velocity.y += gravity * Time.deltaTime; controller.Move(velocity * Time.deltaTime); } }速度值的设定有讲究。真实展馆里人正常步速大概 1.2 米每秒,但游戏里如果按这个速度做,用户会觉得像在爬。我实测下来 2.5 米每秒是"逛展"的舒适值,按住 Shift 加速到 5 米每秒用于快速穿过过渡走廊。移动端再加一个虚拟摇杆,速度降到 2.0,因为触屏操控本身就有抖动。
轨道漫游模式是为导览设计的,相机沿一条Spline样条曲线移动,曲线节点在场景里用空物体摆好。相机朝向用LookAt锁定展品,到点停留 5 秒播放讲解,然后继续。这个模式特别适合做"一键参观",用户什么都不用管,跟着镜头走一遍就能看懂整个展馆的叙事逻辑。
3.3 碰撞体与包围盒的实际使用
碰撞体是虚拟展馆里最容易被忽视、出问题又最难查的一环。我的原则是:视觉模型和碰撞体分离。展品的显示模型可能几千面,但碰撞体用一个简单的盒体;展柜的碰撞体用几个长方体拼出来,不用网格碰撞体。
这里就涉及一个概念,Renderer的包围盒。Unity 里每个渲染器都有一个bounds属性,描述这个物体在世界空间中的轴对齐包围盒,剔除、射线检测、可见性判断都会用到它。我做展品选中时踩过一个坑:早期我用渲染器的包围盒来判断鼠标是否悬停在展品上,结果发现一件细长的绣轴,鼠标离它还有一段距离就触发了高亮,原因是这个模型的包围盒把轴两端的装饰件也算进去了,盒子比实际物体大一圈。
解决办法是给每件交互展品单独挂一个BoxCollider,尺寸按主体手动调,射线检测只认碰撞体,不认渲染器包围盒。同时把所有展品的碰撞体放在独立的层(Layer),射线只检测这个层,效率高也不会误触墙面。
// 只对展品层做射线检测 int exhibitLayer = LayerMask.GetMask("Exhibit"); Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition); if (Physics.Raycast(ray, out RaycastHit hit, 8f, exhibitLayer)) { var exhibit = hit.collider.GetComponent<ExhibitItem>(); if (exhibit != null) hoverPanel.Show(exhibit.DisplayName); }检测距离我限制在 8 米,因为再远用户也看不清展品细节,弹面板反而干扰。这个距离是按展馆实际尺寸定的,我们的展墙离参观通道大概 3 到 5 米,8 米留了余量。
3.4 展馆动线与参观节奏设计
这点是我做完第一版被人吐槽后才重视的。展馆不是越大越好,是节奏要对。我最初把所有展品均匀分布在一条直线上,用户走进去五分钟就腻了,因为没有变化。
改版后我按"起承转合"来排布:入口区是一幅大型迎宾绣屏,建立第一印象;第一展区讲瓯绣的历史和工具,节奏舒缓;第二展区是针法展示,用可旋转的局部放大模型,互动性强,节奏加快;第三展区是精品陈列,灯光暗下来,用射灯聚焦单件展品,节奏重新慢下来;出口区设一个互动绣台,用户可以在屏幕上模拟挑针,作为收尾。
每个展区之间用一段过渡走廊隔开,走廊墙上放文字和图片介绍,既做了知识补充,也给了用户视觉休息的间隙。这个设计思路其实来自实体博物馆的策展逻辑,直接搬到虚拟空间里一样成立。
4. C# 脚本系统:数据驱动与交互事件的设计
脚本层面,我给自己的要求是"策划改内容不碰代码"。做到这一点,核心是把所有可变内容外置到配置资产里,代码只负责读配置、驱动表现。这样做的直接好处是,展品从 20 件增加到 60 件,我一行代码都不用改,只是多填几张表。
4.1 用 ScriptableObject 管理展品数据
每件展品都对应一个ScriptableObject资产,字段包括名称、年代、工艺说明、音频讲解、缩略图、模型引用等。用它的好处是可以在编辑器里可视化编辑,支持引用拖拽,打包时也能被正确序列化。
using UnityEngine; [CreateAssetMenu(fileName = "ExhibitData", menuName = "瓯绣展馆/展品数据")] public class ExhibitData : ScriptableObject { [Header("基础信息")] public string exhibitId; // 唯一编号,用于存档和埋点 public string displayName; public string dynasty; public string craftDescription; // 工艺说明,支持富文本 [Header("资源引用")] public GameObject modelPrefab; public Sprite thumbnail; public AudioClip narration; [Header("展示参数")] public Vector3 spawnRotation; public float modelScale = 1f; public bool allowRotate = true; public bool allowZoom = false; }这里有个细节值得说:exhibitId我用的是"展区号 + 三位序号"的格式,比如A01003。这个编号在整个项目里保证唯一,用于存档记录用户看过哪些展品、用于数据统计、也用于资源命名。编号生成我写了个编辑器小工具自动递增,避免手工填错导致重复。
4.2 交互事件与信息面板
交互的核心是一个轻量的事件总线。展品被点击时发一个事件,信息面板、音效系统、埋点系统各自监听,互相不知道对方存在。这样后期加功能不用改已有代码。
using System; using UnityEngine; public static class EventBus { public static event Action<ExhibitData> OnExhibitSelected; public static event Action OnPanelClosed; public static void SelectExhibit(ExhibitData data) => OnExhibitSelected?.Invoke(data); public static void ClosePanel() => OnPanelClosed?.Invoke(); }面板弹出的时候要注意几点。第一,弹面板的同时要锁住漫游输入,否则用户一边看说明一边还在往前走,直接穿墙。第二,面板要有一个淡入动画,0.2 秒左右,硬切会显得很生硬。第三,移动端面板要占满屏幕,桌面端占右侧三分之一,这个差异通过一个LayoutGroup加条件判断来处理。
提示:事件总线用静态事件要注意反注册。如果监听的物体被销毁了但没取消订阅,下次事件触发会报空引用或者更隐蔽的内存泄漏。我在
OnEnable订阅、OnDisable取消订阅,形成固定习惯。
4.3 异步加载与资源释放
展馆的资源量大,必须异步加载。我用的是 Unity 的Addressables系统,把每个展区打成一个资源组,用户走到展区入口时才加载对应组,离开一定距离后卸载。这样首屏加载时间从原来的 40 秒压到 8 秒左右。
加载策略上有个取舍:是按展区整组加载,还是按单件展品加载?整组加载速度快、逻辑简单,但内存占用高;单件加载省内存,但用户快速移动时会频繁触发加载,出现卡顿。我最终取了个折中:以展区为单位预加载,展区内的高精度模型再按距离做细节层级(LOD)切换。
卸载时机也很关键。不能用户刚离开就卸载,因为人常常会来回走。我设了一个 30 秒的延迟卸载,配合场景内最多保留两个展区的资源,内存峰值能稳定控制在 1.5GB 以内。
| 加载策略 | 首屏时间 | 内存峰值 | 移动中卡顿 | 适用场景 |
|---|---|---|---|---|
| 全部预加载 | 40s+ | 3GB+ | 无 | 小场景 |
| 按展区加载 | 8s | 1.5GB | 偶发 | 本项目采用 |
| 按单件加载 | 5s | 800MB | 频繁 | 极大场景 |
4.4 导览相机与时间轴控制
导览模式我用的是 Unity 的Timeline配合Cinemachine虚拟相机。Cinemachine的好处是可以定义多条相机轨道,用优先级切换,还自带平滑和噪声效果,做手持晃动感很方便。
具体做法是:在Timeline里排好每个镜头的时长、目标展品、讲解音频起止点,相机轨道用Dolly Track沿着参观动线铺。用户点击"开始导览"后Timeline播放,期间可以随时点击"退出"打断。打断的时候注意要把玩家控制器重新启用,并且把相机从虚拟相机切回主相机,不然会出现两个相机打架、画面闪烁的问题。
讲解音频和镜头时长要对齐。我的做法是先录好音频,量出每段时长,再按这个时长去排Timeline的片段。如果反过来先排镜头再配音,很容易出现话没说完镜头就走了,或者镜头停着等音频的尴尬。
5. 性能优化与实机调试的踩坑记录
开发机跑得流畅不代表真机流畅。我是在一台中端安卓机上测的时候才发现问题的,帧率掉到 20 出头,展品旋转卡顿,阴影边缘还出现了明显的锯齿和闪烁。后面花了大概一周做优化,把帧率拉回到 55 帧以上,中间的过程值得记录下来。
5.1 Draw Call 与批处理优化
性能瓶颈第一刀砍在 Draw Call 上。第一版场景里每件绣具、每根立柱、每个灯罩都是独立物体,Draw Call 峰值到 800 多,手机直接跪。优化的思路是合并:
- 所有静态的建筑构件(墙体、立柱、地面)标记 Static,让 Unity 自动做静态批处理。
- 大量重复的小物件(比如展柜上的标签牌、地面的引导灯)用 GPU 实例化,一个材质一次绘制。
- 材质数量从 40 多个压到 12 个,把能共用的贴图合并成图集,用同一张图集的不同区域做不同物体的贴图。
优化后 Draw Call 降到 120 左右,帧率提升非常明显。这里有个经验:先看材质数量,再看物体数量。很多时候物体不多但材质碎,一样会拉高开销。
| 优化项 | 优化前 | 优化后 | 帧率变化 |
|---|---|---|---|
| Draw Call | 820 | 125 | 20 → 42 |
| 材质数量 | 46 | 12 | 42 → 48 |
| 贴图总量 | 1.8GB | 620MB | 48 → 55 |
| 实时光源数 | 14 | 3 | 55 → 58 |
5.2 阴影问题的排查与解决
阴影是虚拟展馆里很敏感的东西,做得好氛围立刻就出来了,做不好就是一片脏。我遇到过三种典型问题:
第一种是阴影边缘闪烁,也叫阴影粉刺。原因是阴影偏移(Shadow Bias)设得太小,物体表面自己和自己产生阴影冲突。解决办法是适当提高 Bias,或者改用 Normal Bias 配合。我调了两次才找到不闪又能贴地的值。
第二种是阴影距离过远导致精度下降。方向光的阴影距离如果设得太大,离相机远的地方阴影会糊成一块。我把阴影距离从 150 收到 60,超出了就用烘焙阴影补,视觉上反而更干净。
第三种是移动端高分辨率阴影开销大。我把阴影贴图分辨率从 4096 降到 2048,重点区域单独用一个近距离的高质量阴影,远距离用低质量,整体开销降了四成。
注意:改阴影参数一定要在真机上看。编辑器里看着没问题的阴影,在手机上因为精度和平台差异,经常会出现编辑器里看不到的噪点和闪烁。
5.3 常见问题速查表
整理了一份我实际遇到并解决的问题表,按现象、可能原因、解决方向列出来,方便快速定位:
| 现象 | 可能原因 | 解决方向 |
|---|---|---|
| 展品点击无反应 | 射线层设置错误 / 碰撞体缺失 | 检查 LayerMask,确认碰撞体存在 |
| 弹窗后人物继续移动 | 输入未锁定 | 弹窗时禁用控制器并释放光标 |
| 移动端发热严重 | 实时光源多 / 帧率未限制 | 减少实时光,设置目标帧率 60 |
| 展品边缘有黑边 | 贴图 Alpha 通道问题 | 检查透明通道,改用 Alpha 裁剪 |
| 加载后模型变白 | 材质丢失引用 | 检查 Addressables 打包是否包含材质 |
| 音频和口型不同步 | 加载延迟 | 预加载音频,用加载完成回调触发播放 |
| 场景切换时闪黑 | 相机切换冲突 | 统一相机管理,切换时禁用旧相机 |
| 存档丢失 | 写入时机不对 | 改为退出时和关键节点双写 |
5.4 帧率与设备适配策略
不同设备的性能差距可以到十倍,一套参数打天下不现实。我做了一档自动降级机制:游戏启动时读一次设备信息,按 GPU 型号和内存给一个性能档位(高、中、低),然后据此调整阴影质量、贴图精度、后处理开关和分辨率缩放。
高档位开满,中档位关掉屏幕空间环境光遮蔽和抗锯齿改用快速近似抗锯齿,低档位把分辨率缩放降到 0.8 并关掉所有后处理。这样同一份包在旗舰机和中端机上都能跑得比较体面。资源包本身只打一份,靠运行时参数区分,避免维护多套包。
6. 发布部署的几种路径和适配要点
做完之后总得分发出去,不同平台的处理差异挺大,这里把几条主要路径说清楚。
6.1 桌面端与移动端的打包差异
桌面端打包相对简单,注意两点:一是分辨率适配,用户可能用 2K 甚至 4K 屏,UI 要用锚点和 Canvas Scaler 做自适应,别用绝对坐标;二是退出逻辑,全屏模式下要有明确的退出按钮和快捷键。
移动端要麻烦一些。触摸操作没有悬停状态,所以移动端要改成"点击选中"而不是"悬停高亮"。虚拟摇杆要处理多点触控,防止右手转视角时误触摇杆。另外移动端要处理返回键,用户按返回时要能关闭弹窗、退出导览、最后退出应用,层层递进。
6.2 网页版的取舍
网页版我保留了,作为"点开就能看"的入口,但只放精简版:展品数量减半,贴图精度降一档,去掉实时阴影,光全部烘焙。这样做是为了控制加载体积,让用户能在 15 秒内看到第一屏。网页版的价值在于传播,不适合当主力体验版本。
6.3 与展馆硬件设备的联动可能
这个项目后续我还在做一个扩展方向:和实体展馆的硬件联动。比如在实体展馆里放一个触摸大屏,用户在屏幕上操作虚拟展馆,同时通过串口或网络协议控制旁边的一盏实物射灯,灯打在真实的绣品上,虚拟和现实对应起来,参观体验会很特别。
技术上,串口通信这部分 Unity 原生没有现成支持,需要引入第三方库,或者用 C# 的System.IO.Ports加上合适的运行时环境。我之前做过类似的传感器数据读取,思路是通的:Unity 负责渲染和逻辑,外部设备通过一个中间层通信,中间层把设备状态转成 Unity 能接收的消息。这件事的技术门槛主要在环境配置和数据稳定性上,等做出来再单独写一篇。
7. 一些做完之后才想明白的事
写到最后,想把几个月下来最有感触的几点记下来,都是踩过坑才明白的。
资产规范要第一天就定,不要想着"先做起来后面再整理"。我第一版资产乱成一团,后面花在改名和整理上的时间,比重新做一遍还多。规范不难,难的是坚持执行,最好写成一个检查脚本,打包前自动跑一遍,不合格直接报错。
数据一定要外置。凡是内容相关的东西,展品名称、说明、年代、音频路径,全部放配置表。代码里硬编码字符串是给自己埋雷,改一次文案要重新编译打包,效率极低。
性能优化别等最后做。我一开始想着功能做完再优化,结果优化的时候发现很多结构要动,牵一发动全身。正确做法是定一个性能预算,比如 Draw Call 不超过 150、同屏三角面不超过 80 万,开发过程中随手看 Profiler,超了就当场处理。
多去真机上看,多找人试。开发机上的完美效果在真机上可能完全是另一回事。我请了几个完全不懂技术的朋友来试,他们找不