基于Unity的瓯绣虚拟展馆:3D建模、光照烘焙与交互优化
2026/9/18 10:39:33 网站建设 项目流程

瓯绣文化主题的虚拟展馆这个项目,我从立项到交付前后折腾了小半年,中间返工过两次,一次是烘焙出来的光把绣面照成了塑料片,一次是漫游走到展柜前点不开展品说明。这套系统说到底就是用 Unity + 3D + C# 把一座线下展馆搬进屏幕里:观众戴上耳机、按住鼠标,就能在青砖灰瓦的展厅里自由走动,走到哪件绣品跟前,抬手一点,绣品的纹样、针法、工序、来历就一层层展开。这套东西适合三类人参考:做数字展陈的同行、想拿非遗题材练手的 Unity 初学者、以及手里有实物展品但预算不够做实体分馆的场馆运营方。下面我把整个系统的架构、建模、渲染、代码、优化和踩过的坑,按实际施工顺序摊开讲,参数和代码都可以直接抄。

1. 整体架构与方案选型拆解

1.1 先把需求拆成三件事:看得到、走得动、点得开

做虚拟展馆最容易犯的错是一上来就打开 Blender 建模型,建到一半发现动线设计错了,整个展厅得推倒重来。我的做法是先拿白纸把需求拆成三层,写清楚再动手。

第一层是"看得到"。瓯绣是平面绣品,视觉重心全在绣面的光泽和针脚走向上,一根丝线分成十六股,绣出来的画面在不同角度下亮暗是不一样的。这意味着贴图和材质必须做够,靠"糊一张高清照片上去"是撑不住的,近看会像贴纸。

第二层是"走得动"。展馆漫游不是竞速游戏,速度要压到步行节奏,镜头不能晃、不能飘,转身要有阻尼感。传达的信息里有一条很关键:观展时人的视线高度基本固定在 1.55 到 1.65 米之间,相机挂点就按 1.6 米来设,这个数字不是随便定的,它决定了展柜高度、画框悬挂高度的整套尺度体系。

第三层是"点得开"。展品要能被选中,选中之后要有反馈:镜头推进、灯光聚焦、说明面板滑出、语音解说同步播放。这一层最容易做成"能跑但不好用",比如点了没反应、点了镜头直接瞬移过去把人绕晕、面板挡住了展品本身。后面我会专门讲这块怎么处理。

1.2 引擎选型:为什么是 Unity,而不是纯网页方案

这个话题我被客户问过不下五次。当时摆在桌面上的有三条路:纯网页 Three.js、Unity WebGL、Unity 桌面端/一体机端。最后我选的是 Unity + WebGL 为主、桌面端为辅的双轨方案。理由列在下面这张表里,你可以直接拿去跟甲方对齐。

方案上手成本画面可控度交互自由度部署体积适用判断
纯网页 Three.js低(前端基础够就行)中,光照材质全手写中,物理与拾取要自己搭首包 3-6MB只做在线轻量浏览,不做漫游
Unity WebGL高,光照烘焙链路成熟高,物理、寻路、音频一套齐精简后 25-45MB要漫游、要点选、要音画同步
Unity 桌面/一体机最高,可上高清烘焙与后处理最高单体包,与 Web 共用工程场馆现场大屏或头显体验

真正让我下决心的是光照。瓯绣的卖点在绣面质感,而绣面质感 80% 靠间接光和反射探针撑起来。Unity 的 Progressive GPU Lightmapper 能在一个晚上把整座展厅的间接光烘出来,这件事在网页方案里要自己写烘焙逻辑,投入产出比太差。另外 C# 作为主语言,写数据驱动逻辑非常顺,展品资料全部放在 ScriptableObject 和 JSON 里,运营方自己改文字不用重新编译工程。

1.3 工程目录与数据驱动的分层设计

工程目录我按"资产来源"而不是"文件类型"来分,这样美术和程序交接时不会互相覆盖。结构大概是这样:

Assets/ _Project/ Art/ Architecture/ 建筑结构、地面、天花 Exhibits/ 单件绣品(模型 + 材质 + 贴图各自子目录) Props/ 展柜、屏风、灯笼、卷轴 Textures/ 公共图集与平铺贴图 Audio/ Narration/ 展品解说(每件一个文件,命名带展品 ID) Ambience/ 环境底噪 Data/ ExhibitData/ ScriptableObject 资产 exhibit_manifest.json Scripts/ Core/ 启动、场景流转、全局配置 Player/ 漫游控制器、输入 Interaction/ 拾取、聚焦、状态机 UI/ 面板、图册、字幕 Scenes/ Boot.unity 常驻场景 Hall_Main.unity 主展馆 Hall_Side.unity 副厅(Additive 加载)

数据分层是这套系统的骨架。展品的一切信息——编号、中英文名、简介、工序列表、缩略图、解说音频——都封在一个ExhibitData的 ScriptableObject 里,场景里只放模型和一个引用。这样做有三个好处:改文案不用碰场景文件,避免多人协作时的合并冲突;可以做批量校验,比如检查有没有哪件展品漏配了解说音频;将来做多语言版本时,只要再开一套数据资产就够了。

2. 瓯绣展品的三维还原要点

2.1 从实物到贴图:采集环节决定了天花板

绣品的高清贴图是整个项目质量的天花板,这一步偷懒,后面调材质调到手抽筋也救不回来。我的采集流程是这样的:用 50mm 定焦加微距接圈做翻拍,机位垂直对准绣面,两侧各一支柔光箱,角度压到 30 度以内,再加一片偏振镜把丝绸的镜面反射压下去。偏振镜这一步不能省,否则拍出来的绣面到处都是高光斑,做反照率贴图时会把这些斑点烘进去,贴到模型上就像绣品上粘了塑料膜。

拍完之后做正交拼接,一张绣品通常拍 6 到 12 张局部,用 PS 的自动对齐和自动混合拼成大图,再拉一遍色卡把白平衡和色彩偏差校正回实物。这里有个经验:不要指望一次拍准,一定要带一枚色卡在现场拍一张,回来看片时对着色卡调,不然同一件绣品在不同批次的光线下采集出来颜色能差出两个色号。

关于模型的立体感,这里是关键。瓯绣是浅浮雕,绣线堆起来的高度通常在 0.5 到 2 毫米之间,用结构光扫描仪去抄这类细节性价比很低,扫出来的点云噪声大,清理比重建还费时。更划算的做法是用微距照片转高度图:把反照率图转灰度,做轻微的高斯模糊,再用曲线把亮部压暗、暗部提亮,最后得到一张高度图,导入 Unity 后接到法线通道上,配合视差或者简单的位移就能把针脚的起伏做出来。

2.2 绣面材质调参:让丝绸"活"过来

材质这块我返工过一次,第一版做出来的绣品像塑料。问题出在各向异性上。真实丝线是有方向的,光线沿丝线方向反射强、垂直方向弱,而 URP 自带的 Lit Shader 没有各向异性通道,我一开始硬拉 Smoothness,结果整个绣面变成一块均匀的高光板。

解决办法是用 Shader Graph 自己搭一个带各向异性的布面着色器,核心思路是把法线贴图的切向方向取出来,喂给各向异性节点,用一个二次幂的衰减控制高光带的宽度。参数上大概是这么配的:

  • 反照率贴图:采集图去高光后的版本,sRGB 打开,Max Size 2048 或 4096,压缩走 ASTC 6x6 或者桌面端 DXT5。
  • 法线贴图:由高度图生成,勾上 "Create from Grayscale",Bumpiness 控制在 0.15 到 0.3 之间,太高针脚会变成凸起的小疙瘩。
  • Smoothness:绣面主体 0.35 到 0.5,丝线高光处可以贴一张遮罩图局部提到 0.7,但千万别整体拉满。
  • Metallic:保持 0。丝绸是电介质,给金属度会立刻变成金箔效果,这是新手最常见的误操作。
  • 各向异性强度:0.4 到 0.6,方向贴图的 UV 朝向要和针脚走向对齐,这里需要对每件绣品的纹样单独处理,机器替代不了。

还有一个细节:绣品都装了画框或展柜玻璃,玻璃半透明材质会带来排序问题和性能开销。我的处理是玻璃单独用一个简化材质,渲染队列设成 Transparent,关掉阴影投射,反射靠展柜内部放一个 Box Projection 的反射探针来采样,不用平面反射,那个太贵。玻璃的 Alpha 给到 0.12 到 0.18 之间,太高会看不清绣面,太低就没有玻璃感了。

2.3 减面、LOD 与图集合并的取舍

面数预算我是在建模前就定死的,不然后面一定失控。参考数字如下:

资产类型三角面预算贴图尺寸LOD 级数说明
单件绣品(含框)6k-15k20482主视觉,近看不能糊
展柜、屏风、家具2k-5k10242复用同一套图集
建筑结构(墙地顶)单块 ≤ 500512-10241靠平铺贴图撑细节
装饰小件(灯笼、卷轴)≤ 3005121走 GPU Instancing 批量画

绣品本体其实是平面,面数主要花在画框的雕花上。画框纹样有三种做法:真雕花模型、法线贴图假装、剪影卡片。我的选择是主展线前五件用真雕花(近看要经得起推敲),其余全部用法线贴图。这个取舍的标准很朴素:观众停留时间超过 30 秒的展品值得花面数。

LOD 不是所有东西都要做。绣品的 LOD0 和 LOD1 差距很小,做出来收益不大;真正需要 LOD 的是重复出现的建筑构件和装饰小件,那些东西在远处成群出现,减面收益立竿见影。另外图集一定要合,把展柜、灯笼、卷轴这些中低精度资产的贴图合到 2048 的大图集里,能省下大量 Draw Call,也会让后面的静态批处理更好做。

3. 场景搭建与渲染核心配置

3.1 展馆布局与动线:尺度错了后面全白搭

虚拟展馆的布局我参照了真实展馆的几个硬指标,这些数字是拿来就能用的:主通道净宽 2.4 到 3 米(两人并行不擦肩),展品与观者之间的最小视距 1.2 米,画框中心悬挂高度 1.5 到 1.6 米(对齐视平线),展柜玻璃面与展品之间留至少 15 厘米的视觉进深,否则看起来像贴在一起。

动线设计上我用了"主轴 + 支线"的结构:一条主通道贯穿前后,两侧各开两个支展厅,支展厅里的展品是专题性质的(针法演变、题材分类、现代衍生)。这样做的原因是漫游类应用最怕"迷路感",主轴的存在给观众一个心理锚点,走岔了也知道怎么回来。场景里我在转角处放了一组地面铺装的变化和两盏壁灯,用视觉引导代替箭头指示,比挂指示牌自然得多。

Collider 这块要注意:CharacterController的半径建议给 0.25 到 0.35,太小会卡进墙角缝隙,太大又进不去窄门。所有展柜我都额外加了一圈隐形碰撞墙,比模型外轮廓往外扩 5 厘米,防止观众贴脸怼进玻璃里。这个 5 厘米是试出来的,太小了镜头会穿,太大了会觉得隔空被挡住。

3.2 光照方案:混合光照加烘焙,参数一次给全

这是整个项目最花时间也最出效果的部分。我的方案是 Mixed Lighting:方向光设成 Baked Indirect,静态建筑和展品全部烘进 Lightmap,动态物体用 Light Probe 接光。

参数取值说明
LightmapperProgressive GPU有独显时比 CPU 快 3-5 倍,改一次参数十几分钟就能看结果
Lightmap Resolution建筑 12-20 texels/unit;展品 40-60展品要近看,密度必须比建筑高一个量级
Lightmap Padding4-8小于 4 容易在 UV 岛边缘漏出邻块颜色
Ambient Occlusion开,Max Distance 1.0-1.5墙角、柜底、画框背面压暗,立体感全靠它
Directional ModeDirectional保留主光方向,绣面才有立体起伏
Direct / Indirect Samples32-64 / 256-512静态展馆,间接采样多给一些
Bounces2再往上收益递减明显
Light Probes每组 8-12 个,转角与门口加密动态展品和游客影子接光用

有个坑必须提前说:Lightmap UV 一定要在模型导入设置里勾上 Generate Lightmap UVs,让 Unity 自动展一套烘焙 UV。我自己展的 UV 追求面积利用率,两个 UV 岛之间挨得太近,烘出来整个墙面全是渗色。Padding 提到 8 之后问题基本消失,代价是贴图利用率下降,但对静态展馆来说完全划算。

另外展品的 Lightmap Scale 要单独调。同样在 20 texels/unit 的场景设置下,建筑墙面足够了,但绣面会糊。我的做法是给近展线的绣品单独挂一个 Lightmap Parameters 资产,把 Resolution 提到 60,同时把这件资产的 Scale In Lightmap 调到 3 到 5。这样只在关键展品上多花贴图,整体尺寸不会膨胀。

3.3 阴影、后处理与画质性能平衡

实时阴影在室内漫游里是性能杀手。我最后只保留了一盏方向光开实时阴影,阴影距离压到 25 米,超出范围的部分靠烘焙阴影兜底。Shadow Resolution 提到 High,Bias 调到 0.05,Normal Bias 给 0.3 到 0.5,这两个值是用来治阴影痤疮和漏光的,具体多少要看着效果试,没有一个通用数字。

后处理只留了三样:Bloom(强度 0.3 到 0.5,用来做展厅射灯的溢光)、Color Grading(稍微往暖色调推一点,青砖配暖光很有味道)、轻微 Vignette。抗锯齿用 MSAA 2x 配合后处理的 FXAA,比单纯堆 SMAA 便宜。景深我试过,效果确实好看,但对漫游类应用来说,观众视线一直在动,景深会造成"看哪哪糊"的眩晕感,最后关掉了。

性能预算我给自己定了这么一条线:桌面端跑 60 帧,WebGL 端跑 30 帧,一体机端锁 72 帧。判断标准是 Draw Call 控制在 300 以内,三角形数量控制在 200 万以内,实时阴影贴图不超过一张 2048。这些数字不是理论值,是拿 Profiler 在这个场景里实打实压出来的,超出之后帧率掉得很快。

4. C# 交互逻辑的落地实现

4.1 漫游控制器:走路的手感是调出来的

漫游控制器看着简单,但要调出"在一个安静展厅里慢慢走"的手感,参数得抠。核心代码大致是这样:

using UnityEngine; [RequireComponent(typeof(CharacterController))] public class VisitorWalker : MonoBehaviour { [Header("移动参数")] public float walkSpeed = 2.2f; // 步行节奏,比游戏里慢一截 public float runSpeed = 4.5f; public float gravity = -18f; // 比 -9.8 更跟手,落地不飘 public float mouseSens = 2.0f; [Header("视线限制")] public float minPitch = -70f; public float maxPitch = 70f; private CharacterController cc; private Transform cam; private float pitch; private float verticalVel; private bool inputLocked; void Awake() { cc = GetComponent<CharacterController>(); cam = Camera.main.transform; } void Update() { if (inputLocked) return; Look(); Move(); } void Look() { float mx = Input.GetAxis("Mouse X") * mouseSens; float my = Input.GetAxis("Mouse Y") * mouseSens; transform.Rotate(0f, mx, 0f); pitch = Mathf.Clamp(pitch - my, minPitch, maxPitch); cam.localEulerAngles = new Vector3(pitch, 0f, 0f); } void Move() { float h = Input.GetAxisRaw("Horizontal"); float v = Input.GetAxisRaw("Vertical"); Vector3 dir = (transform.right * h + transform.forward * v).normalized; float speed = Input.GetKey(KeyCode.LeftShift) ? runSpeed : walkSpeed; if (cc.isGrounded && verticalVel < 0f) verticalVel = -2f; verticalVel += gravity * Time.deltaTime; Vector3 motion = dir * speed + Vector3.up * verticalVel; cc.Move(motion * Time.deltaTime); } public void Lock(bool v) => inputLocked = v; }

几个参数的解释:重力给到 -18 而不是物理常数,是因为CharacterController的碰撞检测在贴近地面时容易产生微小弹跳,加大重力能让落地更干脆,视觉上不会"飘"。鼠标灵敏度 2.0 是配合观展节奏来的,太高会让人转头就晕。Lock方法是给展品聚焦用的,观众一旦进入展品详情,漫游输入要立刻锁住,否则镜头推进的时候观众还能自己走,画面会打架。

4.2 射线拾取与数据驱动面板

拾取这块我用的还是最朴素的方式:从屏幕中心或鼠标位置发一条射线,命中带 Collider 且层为 Interactable 的物体就触发聚焦。没用 EventSystem 的物理射线,因为观众点击的是三维空间里的展品,用物理射线更直接,也更好做距离限制。

using UnityEngine; public class ExhibitInteractable : MonoBehaviour { [SerializeField] private ExhibitData data; private Renderer[] renderers; public ExhibitData Data => data; // 把子物体所有 Renderer 的包围盒合并成一个世界空间包围盒 public Bounds GetWorldBounds() { if (renderers == null) renderers = GetComponentsInChildren<Renderer>(); if (renderers.Length == 0) return new Bounds(transform.position, Vector3.one * 0.5f); Bounds b = renderers[0].bounds; for (int i = 1; i < renderers.Length; i++) b.Encapsulate(renderers[i].bounds); return b; } }

这个GetWorldBounds是整套系统的关键工具,用处有三个:一是聚焦时算相机该退到多远,二是自动生成近似碰撞体(展品形状不规则时用 BoxCollider 包住比 MeshCollider 便宜太多),三是做拾取距离过滤,太远的展品不允许点击。

展品数据用 ScriptableObject 承载:

using UnityEngine; [CreateAssetMenu(menuName = "瓯绣展馆/展品数据", fileName = "Exhibit_")] public class ExhibitData : ScriptableObject { public string id; public string titleCn; public string titleEn; public string craftCategory; // 针法类别,用于分类筛选 [TextArea(3, 10)] public string introCn; public Sprite coverThumb; public AudioClip narration; public string[] craftSteps; // 制作工序条目 public float autoSpinSpeed = 10f; }

运营方拿到这套之后改文案只要在 Inspector 里改 TextArea,不需要我重新出包。这个设计在项目验收时救了场,甲方当场提了六处文案修改,我在他们会议室里十分钟改完。

4.3 展品聚焦:用包围盒自动算相机停位

聚焦动画是体验的关键。最忌讳的是镜头直接瞬移,观众会失去空间感,不知道自己在哪。我的做法是保持方向不变,只沿当前视线方向后退,同时旋转对准展品中心,整个过程 0.8 秒缓入缓出。

using System.Collections; using UnityEngine; public class ExhibitFocusController : MonoBehaviour { [SerializeField] private VisitorWalker walker; [SerializeField] private float padFactor = 1.15f; // 留白系数 [SerializeField] private float duration = 0.8f; private Camera cam; void Awake() => cam = Camera.main; public IEnumerator Focus(ExhibitInteractable item) { walker.Lock(true); Bounds b = item.GetWorldBounds(); // 用包围球半径估算:保证展品完整落在视锥内 float radius = b.extents.magnitude; float fovRad = cam.fieldOfView * Mathf.Deg2Rad; float dist = radius / Mathf.Sin(fovRad * 0.5f) * padFactor; Vector3 dir = (cam.transform.position - b.center).normalized; Vector3 endPos = b.center + dir * dist; Quaternion endRot = Quaternion.LookRotation(b.center - endPos, Vector3.up); Vector3 startPos = cam.transform.position; Quaternion startRot = cam.transform.rotation; float t = 0f; while (t < duration) { t += Time.deltaTime; float k = Mathf.SmoothStep(0f, 1f, t / duration); cam.transform.position = Vector3.Lerp(startPos, endPos, k); cam.transform.rotation = Quaternion.Slerp(startRot, endRot, k); yield return null; } cam.transform.SetPositionAndRotation(endPos, endRot); // 这里接着弹出信息面板、播放解说 } public void Back() { /* 记录进入前的位姿,按原路返回 */ } }

这个公式值得展开说。包围盒的extents.magnitude其实是包围球半径的一个上限,取它最保险,因为展品不一定是立方体,斜着看的时候对角线最长的方向也要能装下。Sin(fov/2)是垂直视场角的一半,取正弦的原因是相机距离和视锥在几何上构成一个直角三角形。padFactor给 1.15 是留白,让展品四周还有点余地,不留白会显得很挤。

4.4 状态机、面板与音频的联动

整套交互我用一个轻量状态机来管,三个状态循环:Roaming(漫游)、Focusing(聚焦动画中)、Viewing(展品详情)。状态切换时要做的事很明确:Roaming 到 Focusing 要锁输入、记位姿;Focusing 到 Viewing 要弹面板、播解说、开自转;Viewing 回到 Roaming 要反向恢复。

自转这块有讲究。展品自转速度不能快,我给到 8 到 12 度每秒,而且必须是缓入缓出的往复摆动而不是匀速转圈。匀速转圈看久了会让人有轻微晕眩,尤其是屏幕大的时候。摆动幅度我设在正负 25 度,这个角度足够展示绣面的不同反光,又不会让观众觉得东西在乱晃。

音频是另一个容易出问题的地方。解说音频用AudioSource挂 3D 空间化,Spatial Blend 给 0.6 到 0.8,Max Distance 设 8 到 12 米。为什么不做成完全的 3D 音源?因为观众聚焦之后相机离展品很近,音源就在耳边,纯 3D 会有明显的头中效应和音量突变,混一部分 2D 进去声音更稳。解说播放时同步走一条字幕轨道,方便环境嘈杂的现场,这个功能在现场演示时被甲方点名表扬过。

5. 性能优化与多平台发布

5.1 诊断流程:先定位再改,别瞎优化

优化最忌讳拍脑袋。我的流程是固定的四步:先用 Profiler 看是 CPU 顶还是 GPU 顶,CPU 顶就点开 Timeline 看是脚本耗时、批处理还是物理;GPU 顶就用 Frame Debugger 一帧一帧翻,看 Draw Call 是不是炸了、有没有重复渲染同一批几何。定位不到就白干。

这个项目遇到的第一个性能问题是 Draw Call 到了 800 多,Frame Debugger 一翻,发现是展柜里的小装饰件没有合批。处理手段有三个,按收益排序:合并图集加静态批处理、开启 GPU Instancing 让同材质的重复物体一次画完、把远处的小件换成低模或直接剔除。做完之后 Draw Call 降到 260 左右。

第二个问题是 WebGL 端内存超限崩浏览器。原因是所有展品贴图都是 2048 未压缩,光是反照率和法线加起来就快 400MB。处理方式是全部改成 ASTC 6x6 压缩,展品按可见性分批加载。

5.2 资源加载:分包与按需加载

我用了 Addressables 做资源管理,把内容分成三组:Core(启动场景、漫游控制器、UI 框架,必须预载)、Exhibits_GroupA(主展线,进厅就加载)、Exhibits_GroupB(支厅,走到门口触发加载)。这样首包能压到 30MB 左右,后面的内容按需拉取。

场景加载用 Additive 方式,副厅单独做一个场景,观众走到连接处时异步加载,加载期间在门洞位置放一个遮挡用的幕布和进度提示,观众不会看到突兀的空白。这里的经验是:异步加载一定要有视觉遮挡和反馈,否则在低端设备上会出现明显的卡顿和黑屏,看起来像程序崩了。

对象池主要用在两处:浮动的展品标签和点击时的粒子反馈。数量不多,但复用之后 GC Alloc 明显下来了,Profiler 里那根黄色的 GC 曲线平稳了很多。

5.3 WebGL 构建与 IIS 部署的实操细节

WebGL 发布这块踩的坑最多,值得单独讲。构建设置上,Code Optimization 选 Size(体积优先),IL2CPP Code Generation 用 Faster (smaller) builds,Compression Format 我用 Brotli,然后把 Decompression Fallback 关掉。关掉的原因是它会额外打出一个解压用的 JS,体积增加明显;代价是服务器必须正确响应压缩格式,这个下面配。

托管到 IIS 时,默认没有.unityweb.wasm的 MIME 映射,不配就是白屏。在站点根目录放一份web.config

<?xml version="1.0" encoding="UTF-8"?> <configuration> <system.webServer> <staticContent> <remove fileExtension=".unityweb" /> <mimeMap fileExtension=".unityweb" mimeType="application/octet-stream" /> <remove fileExtension=".wasm" /> <mimeMap fileExtension=".wasm" mimeType="application/wasm" /> <remove fileExtension=".data" /> <mimeMap fileExtension=".data" mimeType="application/octet-stream" /> <remove fileExtension=".br" /> <mimeMap fileExtension=".br" mimeType="application/octet-stream" /> </staticContent> </system.webServer> </configuration>

另外还得在 IIS 里开启静态内容压缩,并确认Content-Encoding头是对的。我遇到过一次"本地跑得好好的,一上传就白屏",排查了半天发现是服务器把.br文件按普通文本返回了,浏览器解不开。判断方法很土但有效:F12 打开网络面板看那条最大的请求,状态码 200 但控制台报解压失败,基本就是这个原因。

顺带说一下小游戏平台适配。如果要往小游戏方向走,要注意平台对 WebGL 2.0 和 Instanced Rendering 的支持程度、包体大小限制以及视频播放走的是平台自己的接口而不是 Unity 自带的 VideoPlayer。这些限制意味着同一个工程要出两套构建配置,画面档次要往下调一档,实时阴影基本要全砍,改成烘焙阴影。

一体机端的适配相对顺一些,用 XR Interaction Toolkit 把漫游控制器换掉,移动方式改成瞬移或摇杆平移,减少晕动。这里的关键是帧率必须锁住,掉帧在头显里带来的不适感是成倍放大的,宁可降画质也不能掉帧。

6. 常见问题速查与排查实录

6.1 渲染与光照类问题

这一类问题在烘焙环节最容易集中爆发,我整理成了一张表,照着查基本能覆盖九成情况。

现象最可能原因处理方式
烘完墙面一块亮一块暗Lightmap UV 重叠或岛间距不足勾 Generate Lightmap UVs,Padding 提到 8,检查 UV Overlap
模型边缘有邻块的色渗进来Padding 太小提高 Padding,或给该资产单独挂 Lightmap Parameters
阴影边缘有锯齿且闪烁阴影距离过大或 Bias 失当降 Shadow Distance,Bias 0.05,Normal Bias 0.3-0.5
绣面像塑料片Metallic 不为 0 或 Smoothness 过高Metallic 归零,Smoothness 压到 0.5 以下
玻璃后面的展品忽隐忽现半透明排序问题玻璃单独材质,设 Transparent 队列,关阴影投射
展品颜色发灰贴图色彩空间不对反照率勾 sRGB,法线与遮罩图取消 sRGB

关于阴影闪烁,我再补一句经验:这个问题有一半不是 Bias 造成的,而是场景里有多盏灯同时投实时阴影。室内展馆我最后只留一盏灯开实时阴影,其余全部走烘焙,问题自然就没了。

6.2 交互与输入类问题

拾取失效是调试时最耗时的,因为它可能错在任何一个环节。我的排查顺序是固定的:先看物体有没有 Collider,再看它的 Layer 有没有在射线的 LayerMask 里,然后看射线距离上限设了多少,最后看是不是被前面的玻璃挡住了射线。玻璃如果没有做特殊处理,会成为射线的第一命中目标,导致永远点不到后面的展品。我的解法是给玻璃设一个专门的层,射线时在 LayerMask 里排除掉。

现象排查方向
点展品完全没反应Collider 缺失 / LayerMask 不匹配 / 距离超限
点哪里都触发同一件展品展品共用了同一个父物体的 Collider,或 ID 配错
聚焦后镜头穿进模型包围盒取的是局部包围盒,需用 Renderer.bounds 取世界空间
聚焦完回不去原位进入前没有记录相机位姿,返回逻辑缺数据
音频播一半断了AudioSource 被复用了,或解说与漫游音效抢同一个源
鼠标转向很飘灵敏度太高,或 Update 里用了非 deltaTime 的插值

6.3 构建发布类问题

现象最可能原因处理方式
WebGL 构建后白屏MIME 映射缺失或压缩格式不匹配补 web.config,核对 gzip/br 响应头
首包体积超预期Resources 目录滥用或贴图未压缩改用 Addressables,贴图走 ASTC/DXT
移动端掉帧严重实时阴影与后处理过重关实时阴影,后处理只留 Bloom 与色调映射
一体机里画面抖动帧率没锁住固定帧率上限,降低渲染分辨率
桌面端打包后贴图变糊导入设置里的 Max Size 被平台覆盖检查各平台的贴图覆盖设置,必要时手动指定

7. 交付前后的几条个人经验

7.1 素材交接与版本管理

非遗题材项目的素材来源特别杂,有场馆提供的实物翻拍,有当地手艺人给的纹样扫描件,还有网上找的参考文献。这些东西一定要在入库时就做编号和来源登记,否则做到后期会出现"这张图是谁给的、能不能用"的扯皮。我的做法是在Art/Exhibits/下每件展品建一个独立目录,里面放一个source.md,写清素材来源、拍摄或扫描时间、处理人。这个文件不起眼,但在结算和后续维护时价值极高。

版本管理上,Unity 工程的.meta文件必须提交,二进制资产(PSD、FBX 源文件)用 Git LFS 管,.unity场景文件尽量避免多人同时改。我们后来定了一条规矩:同一时间只有一个人能改场景,其他人在预制体上干活,改完再让一个人合并。这条规矩救了我们至少两次。

7.2 现场演示的应急预案

这套系统最后要在场馆里做现场演示,现场环境和办公室差得很远。我准备了三个预案:一是断网预案,所有资源本地化,不依赖任何在线加载;二是降画质预案,工程里留了一个 Quality 档位的快捷切换,现场如果机器带不动,一键切到中低档;三是备用视频预案,把核心漫游路线录成一段视频带着,万一程序出问题可以先用视频顶着讲。

另外还有一个很小的细节:现场屏幕的颜色和调色时用的显示器差很多。我建议带着色卡去现场对一次,把后处理的色调偏移微调一下,同一套系统在不同的屏幕上一个偏冷一个偏暖,观众的第一印象差别其实挺大。

7.3 后续还能往哪些方向扩

如果这个系统还要继续往下做,我觉得有几个方向是值得投入的。一个是讲解内容的层次化,现在是一层简介,可以再往下做针法的逐层拆解,让观众点开一个针法看到它在一幅绣品里的具体位置和走向,这对教学场景特别有用。二是做导览角色,用一个虚拟讲解员按动线带路,讲解员本身就是最好的动线引导。三是把展品数据做成开放的 JSON 接口,场馆那边有新展品时只提供数据和照片,我这边跑一个批处理脚本生成模型和配置,不用每次重新出整个包。

这套东西做完,我最深的一点体会是,虚拟展馆的价值不在于把线下展馆复制一遍,而在于把线下做不到的事做出来:绣品能放大到看清每一根丝线的走向,能一层层剥开看针法,能在同一个位置同时对照三代手艺人的同题材作品。这些才是观众愿意在展厅里多待十分钟的理由,而技术上的那些参数和代码,说到底都只是把这件事做扎实的手段。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询