
做这个项目之前我一直在想一个问题核雕这种小到要用手掌托着、细节却密到要在放大镜下看的传统手工艺品到底要怎么样才能让更多人欣赏到拍高清图显然不够用户没法旋转、没法交互、没法像在博物馆里那样自己决定往哪走。我最终选了一条路——用 Unity 3D C# 搭一个核雕文化主题虚拟展馆把整个展厅、展柜、展品模型都搬进去让用户用鼠标和键盘在展馆里自由行走走到展柜前按一下E键就能看到核雕在高精度下的每一个细节。这篇文章我会从项目立项、方案选型、架构设计、场景渲染、交互实现到后期优化把整个过程中的思路和踩过的坑一次说清楚。如果你正在做文化遗产数字化、虚拟展厅、数字博物馆这类项目或者只是单纯想用 Unity 做一个带交互漫游的 3D 场景这篇文章里的很多方法和教训可以直接抄作业。1. 项目立项为什么做核雕虚拟展馆1.1 核雕文化的数字化困局核雕是中国传统工艺美术里很特别的一个品类以橄榄核、桃核、杏核为原材料纯手工雕刻出山水、人物、楼阁、舟船等精细画面。典型的题材是“东坡夜游赤壁”一枚不到三厘米长的橄榄核上要刻出苏东坡、客人、船夫、船身、窗户、茶几甚至书卷上的文字光人物可能就超过五六个。在实体展览馆里这类作品往往放在恒温恒湿的玻璃柜中观众隔着玻璃只能看到一个大轮廓稍微想贴近一点反光和玻璃厚度就把细节全挡住了如果长期用强光照射又会对核雕表面的包浆和颜色造成不可逆的损伤。所以我接触这个需求时对方给出的核心诉求非常明确让用户能“靠近”核雕。不只是图片式地看到而是最好能像在展馆里一样自由地走来走去。我们最初的备选方案有三个全景图、Web 3DThree.js、游戏引擎虚拟展厅。全景图方案最快问题也最明显——它本质上还是多张静态照片的组合用户只能站在固定点看固定视角不能真正迈步核雕模型也不能旋转观察。Three.js 可以做真 3D但我们团队当时都是 C# 背景没有足够的人力和时间在纯 JS 生态里重建一套光照、物理、UI 工具链。最终定下来用 Unity 3D C#理由很实际开发效率最高C# 的工程化管理能力对内容管理也比较友好。1.2 为什么选 Unity 而不是其他方案这里把方案对比说得更直白一些。如果你的目标是一个真三维、可自由漫游、可交互的虚拟展馆Unity 是很稳妥的选择原因有三个。第一Unity 的场景编辑器和资源工作流非常成熟。展厅的墙体、展柜、地板、灯光在编辑器里摆放即所见不需要像 Three.js 那样手写大量坐标和矩阵变换代码。第二UGUI 系统对做展馆这类重 UI 交互的场景非常友好展品详情面板、语音播放按钮、小地图、进度弹窗这些在 Unity 里都是现成的控件体系开发速度和写桌面软件差不多。第三跨平台输出很方便。同样的工程可以打包 Windows 客户端用来线下大屏演示也可以导出 WebGL 版放网页上免安装浏览后续还能扩展 AR 版本。如果是大型开放世界、写实度要求极高的场景我会推荐 UE5但一个室内展馆只有一二白平方米需要大量 2D 图文 UI、语音讲解、数据驱动内容UE 的开发成本反而会拖慢项目。如果你已经有 C# 基础用 Unity 做这种项目几乎没有任何语言层面的阻力。2. 项目架构设计Unity 场景怎么拆、C# 脚本怎么分2.1 版本与渲染管线选择URP 初始化别踩坑Unity 版本我建议直接选 LTS长期支持版本。我用的 Unity 2021.3 LTS后来在另一个项目里用了 2022.3 LTS都比较稳定。强调 LTS 是因为虚拟展馆这类项目周期一般两三个月起步过程中你不可能老跟着官方的功能迭代走LTS 是稳定性最优解。有些同学看到新版本出来就非要切过去除非你只是在做技术验证否则在正式项目里求稳更重要。渲染管线这里有一个容易被忽视的坑。如果你在 Unity Hub 新建项目时没有选 Universal 3D 模板而是选了内置管线Built-in模板后面想切 URP 就要手动操作打开 Package Manager安装 Universal RP 包然后在 Project Settings 的 Graphics 里把渲染管线资源赋上去同时还要在 Quality 设置里对应设置好。这里有个容易漏的点URP 需要专门的 Renderer Data 资产如果你只在 Graphics 里指定了 URP Asset忘了检查 Renderer 数据运行后可能出现材质显示粉红色或后处理全部失效的情况。URP 对这个项目最重要的意义不是画质多无敌而是在较低的性能开销下提供了不错的抗锯齿、HDR、Bloom 后处理。展馆里会用射灯打亮展品Bloom 能让灯光有一种柔和的光晕质感这在展馆氛围营造上非常重要。注意 URP 的 Bloom 等效果要挂在 Volume 组件上而且要确保相机启用后处理。我建议一开始就规划好 URP 配置不要在项目做了一半再切换否则材质重做和光照调整会非常折腾。2.2 目录结构与脚本模块划分Unity 项目如果一上来就随意放资源后面会很痛苦。这个项目我把目录划分成 Art、Scenes、Scripts、Prefabs、Resources、Audio、Data 几大类。其中 Scripts 内部按功能继续分Player、Interaction、UI、Exhibit、Manager、Utils。这样做的好处是策展方需要调整展品数据时只需要改 Resources 下的 JSON 和 Prefabs不会误碰到脚本。脚本设计我推荐一个“轻中控 多控制器”的模式不引入重量级框架但每个系统要有明确的边界。目录大概是这样的Assets/ ├── Art/ │ ├── Materials/ │ ├── Models/ │ ├── Textures/ ├── Scenes/ │ └── Main.unity ├── Scripts/ │ ├── Manager/ │ │ ├── GameManager.cs │ │ ├── ExhibitDataManager.cs │ │ └── AudioManager.cs │ ├── Player/ │ │ ├── FirstPersonController.cs │ │ └── InteractionRaycaster.cs │ ├── Interaction/ │ │ ├── IInteractable.cs │ │ ├── ExhibitPoint.cs │ │ └── InteractableHighlighter.cs │ ├── UI/ │ │ ├── UIManager.cs │ │ └── ExhibitDetailPanel.cs │ ├── Exhibit/ │ │ ├── ExhibitRotator.cs │ │ └── ExhibitionCamera.cs │ └── Utils/ │ └── MiniMapController.cs ├── Prefabs/ ├── Resources/ │ ├── ExhibitData.json │ └── ExhibitPrefabs/ ├── Audio/ └── Data/GameManager 负责游戏状态流转启动、展厅初始化、展品数据加载、进入详情、退出详情。它持有对 UIManager、AudioManager、InteractionRaycaster 的引用但不会让每个 UI 控件直接操作业务数据。ExhibitDataManager 则把所有展品的元数据从 JSON 里读进来用字典或数组缓存。AudioManager 统一管音频任何地方想播放语音都只调用 AudioManager.PlayGuideVoice(id)。2.3 数据驱动用 C# 序列化和 JSON 管理展品虚拟展馆最大的维护痛点是内容与代码耦合。策展人今天改一段简介明天换一个讲解词后天调展品摆放顺序如果这些信息都写在 C# 代码里每次改动都要重新编译打包。我改成数据驱动定义展品类[System.Serializable] public class ExhibitData { public string id; public string name; public string dynasty; public string author; public string material; public string description; public string audioClipName; public string prefabName; public string GetDisplayName() { return string.Format({0} —— {1}{2}, name, dynasty, author); } } [System.Serializable] public class ExhibitDataCollection { public ExhibitData[] exhibits; }然后把 JSON 放到 Resources/Data 下运行时用 JsonUtility.FromJson 反序列化。这里有个经典细节JsonUtility 对数组的支持不是直接映射到 List所以我用了 ExhibitDataCollection 这个包装类来包数组。这个坑不少同学踩过序列化出来数量是 0查了半天才发现是包装层的问题。数据驱动的好处是展品详情面板、语音讲解按钮、模型旋转逻辑可以完全基于一个 ExhibitData 对象来显示不用关心它来自 JSON 还是序列化资产。JSON 解析中会遇到字符串处理问题。比如展品编号我设计成 “HD-001-Olive-PurpleBoat” 这种带分节的格式需要把分区和序号拆出来用来控制展厅分区灯效这时候就会用 C# 的 Split、Trim、Substring。这些都是基础但实际会用到的 API面试问“C# 语言怎样截取字符串”答起来容易真正用的时候也要注意边界Substring 不要越界Split 后记得判断数组长度再取索引。3. 场景搭建、光照与数字资产管理3.1 展馆空间布局与动线设计虚拟展馆的场景设计不像游戏那样天马行空它讲究“引导”。用户第一次进到展馆里如果布局混乱用户就会乱走、迷路、失去耐心。我参考了真实博物馆的动线设计进门是序厅放核雕历史介绍左转是技艺展示区用图文展板和大屏动画说明选核、创意、粗刻、细刻、打磨五大步骤继续往前是作品展示区按题材分成“舟船”“人物”“山水”三个小分区最后是互动体验区放一个 3D 拆解模型用户可以亲手旋转核雕观察内部结构。这种洄游式动线能最大程度覆盖所有展品用户在行走中不会漏看。空间尺度上我控制的展厅主通道宽度是 3 到 4 米展柜离墙约 80 厘米每个交互点的半径在 1.2 米左右。这些尺寸在真实博物馆里都是验证过的虚拟场景中移动速度和碰撞体高度设置合理的话可以很大程度减少晕眩感。我设置角色视线高度在 1.6 米左右移动速度默认 2.8 米每秒跑步 5 米每秒垂直视角 60 度实测最舒服。如果你用第一人称控制器别把相机放到眼高 1.8 米那样展柜灯光和展品视线都会偏。这里还涉及“楼层小地图”的需求。展馆虽然只有一层但面积接近两百平方米用户容易忘记自己在哪。我在 UI 右上角做了一个实时小地图用俯拍相机加 RenderTexture 渲染到 RawImage再叠加一个表示玩家位置的箭头。地图中记录展柜的关键锚点玩家进入某个展品交互范围时锚点会变色相当于导览提示。3.2 核雕模型资产处理数字资产的变形记核雕模型是整个场景里最重要也最难处理的资产。实体核雕只有几厘米大但细节非常密集想在模型上还原出船窗上的格栅纹理建模精度要求不低。我用 Blender 做模型处理给每件展品输出两种规格一种是低模控制在 2 到 3 万面用于场景内普通展示和碰撞检测另一种是高模雕刻用于详情视角的特写渲染。高模不进游戏主场景单独放在详情展示台上用独立相机加载性能影响很小。贴图方面核雕表面因为橄榄核本身的材质有一种油润、半哑光的质感。我用 PBR 贴图Albedo 提供底色和纹理图案Roughness 在 0.45 到 0.6 之间Smoothness 不能拉满否则会像塑料。金属度保持 0因为核雕不是金属。为了让木纹纹理更真实我给高模加了细节法线贴图这种贴图在 Blender 里通过多级细分雕刻后烘焙得到。如果你拿到的是扫描模型通常会有大量三角面需要先做减面处理和重拓扑。这里强调一个实操细节低模在场景中如果直接用普通相机看会显得边缘发虚、细节糊。我的处理方式是在展柜玻璃上做一层透明材质用 Fresnel 效应让玻璃边缘泛一点白光把视觉焦点引导到展品上。同时把展品放在一个带独立点光源照射的小底台上点光源强度 8 到 12颜色偏暖色温约 3300K和展馆环境光照形成冷暖对比这样小体积的核雕即使在低模下也很显眼。这个技巧对任何微型展品都适用。3.3 光照烘焙与 URP 后处理的配合展馆室内场景是典型的“静态场景为主”的情况光照优先做成烘焙贴图不要全部动态实时。我用了两种策略组合静态展柜、墙体、地板参与烘焙只有玩家和少数动态物体用实时光照。在 URP 下烘焙前注意把 Light Mode 在菜单 Window Rendering Lighting 里设置为 Baked Indirect也就是间接光烘焙直接光还是由灯光实时提供这样动态物体在场景里移动时不会被“点不着亮”的现象吓到。后处理用了 URP 内置的 VolumeBloom 强度控制在 0.25 左右Color Adjustments 里对比度微调到 102饱和度微调到 105整体展馆氛围会比较像专业博物馆。不要用高强度的 Bloom 把画面搞得夜店风展馆要的是安静的高级感灯光参数可以参考这个表灯光类型强度色温阴影说明环境平行光1.26000K软阴影模拟天光入口展柜射灯8-123300K硬阴影聚焦展品走廊筒灯3-54000K无阴影引导动线序厅洗墙灯63500K无阴影墙面展板有一个踩了很多次的坑URP 烘焙完成后场景发黑或者所有静态物体丢失光照贴图。大多数情况下是物体漏勾了 Contribute GI或者 Mesh Renderer 的 Lightmap Static 没打开。养成习惯所有静态物体选中后勾 Lightmap Static再重新烘焙。URP 的 Lightmap 编码默认是 High Quality在某些显卡上可能出现暗部噪点可以把 Compressed Lightmap 打开或者把 Lightmap Resolution 从 40 调到 30。4. 交互漫游核心功能的 C# 实现4.1 第一人称漫游控制器实现交互漫游的基础是玩家角色控制。我用 CharacterController 而不是刚体它的好处是自带碰撞和步进高度处理非常适合室内漫游。核心思路是每次 Update 里读取水平输入乘以速度再叠加重力最后调用 Movepublic class FirstPersonController : MonoBehaviour { public float walkSpeed 2.8f; public float runSpeed 5f; public float gravity -9.8f; public float mouseSensitivity 2f; public Transform cameraTransform; private CharacterController controller; private void Awake() { controller GetComponentCharacterController(); } private void Update() { float horizontal Input.GetAxisRaw(Horizontal); float vertical Input.GetAxisRaw(Vertical); Vector3 move transform.forward * vertical transform.right * horizontal; move.Normalize(); float speed Input.GetKey(KeyCode.LeftShift) ? runSpeed : walkSpeed; controller.Move(move * speed * Time.deltaTime); transform.Rotate(Vector3.up, Input.GetAxis(Mouse X) * mouseSensitivity); float lookX cameraTransform.localEulerAngles.x - Input.GetAxis(Mouse Y) * mouseSensitivity; lookX Mathf.Clamp(lookX, -80f, 80f); cameraTransform.localRotation Quaternion.Euler(lookX, 0f, 0f); } }这段代码注意一个点上下视角要 clamp 在 -80 到 80 度之间否则用户把视角翻过头会眩晕。水平转向直接用 transform.Rotate 没问题。如果最终发布 WebGL记得在 Player Settings 里设置好光标锁定模式否则鼠标一出窗口视角就卡住。CharacterController 的 height 要设置为视线高度 1.6 米center 和 camera 的偏移要配合好不然相机悬空或者穿模。4.2 交互系统射线检测、可交互接口与高亮虚拟展馆里用户会点击展柜、展品、互动屏幕、音视频按钮。这些交互如果全写各自的逻辑代码会很散。我定义了一个 IInteractable 接口把交互提示统一起来public interface IInteractable { string GetPromptText(); void OnInteract(PlayerController player); }所有可交互物体实现这个接口。ExhibitPoint 是最典型的一个挂在展品交互点 Prefab 上public class ExhibitPoint : MonoBehaviour, IInteractable { public string exhibitId; public string interactableName 查看展品; public string GetPromptText() { return string.Format([E] {0}, interactableName); } public void OnInteract(PlayerController player) { GameManager.Instance.EnterExhibitDetail(exhibitId); } }主相机每帧发一条射线检测最近的 IInteractablepublic class InteractionRaycaster : MonoBehaviour { public float maxDistance 3f; public Transform cameraTransform; public delegate void OnTargetChanged(IInteractable target); public event OnTargetChanged TargetChanged; private IInteractable currentTarget; private void Update() { IInteractable target null; if (Physics.Raycast(cameraTransform.position, cameraTransform.forward, out RaycastHit hit, maxDistance)) { target hit.collider.GetComponentIInteractable(); } if (target ! currentTarget) { currentTarget target; TargetChanged?.Invoke(currentTarget); } } }这就是 C# 里“委托与事件”的实际应用。TargetChanged 是一个事件UI 层订阅它在目标变化时更新提示文字和光标样式。如果你订阅了事件要在 OnDestroy 里取消订阅否则会出现事件泄漏Unity 里表现就是场景切来切去内存只增不减。这也是面试题“C# 委托是什么、事件和委托的区别”在项目中能落地的例子。4.3 展品详情展示独立相机加 RenderTexture展品点击后需要进入“详情模式”。我的实现不是把 UI 弹窗做大而是把展品模型放到场景里的一个“展示台”上再切换到专门的展示相机。这个相机会以展示台为圆心缓缓旋转用户可以拖动鼠标手动控制旋转速度也可以点击按钮开启自动旋转。关键点是用 RenderTexture 把展示相机的画面输出到 UI 的 RawImage 上这样详情面板的模型区域和 UI 系统完全独立透明背景、尺寸自适应都不受影响。这个方案比在 3D 场景直接放大模型干净得多后者容易受场景光照和遮挡影响出现穿模或畸变独立相机的画面只显示模型本身。展示相机参数Clear Flags 用 SolidColor背景色用深灰色或黑色接近面板背景融合效果好。FOV 设为 45 度比主相机的 60 度窄适合特写。模型旋转控制用简单组件public class ExhibitRotator : MonoBehaviour { public float autoRotateSpeed 20f; public float manualDragSpeed 0.3f; private bool isDragging; private void Update() { if (Input.GetMouseButtonDown(0)) isDragging true; if (Input.GetMouseButtonUp(0)) isDragging false; if (isDragging) { float delta Input.GetAxis(Mouse X) * manualDragSpeed; transform.Rotate(Vector3.up, -delta, Space.World); } else { transform.Rotate(Vector3.up, autoRotateSpeed * Time.deltaTime, Space.World); } } }自动旋转和手动拖拽切换的关键是判断鼠标按键状态用户正在拖拽时不要执行自动旋转否则两者会打架。细节是每次拖拽结束后要把上次的鼠标 X 值清零否则下一次拖拽会有跳变。4.4 音频系统与语音讲解管理展厅里音频分成背景音乐、环境音、语音讲解三类。背景音乐用一个 AudioSource 循环播放音量在 -18 dB 左右环境音比如展厅里轻微的人声和白噪声音量更低语音讲解是最需要管理的一类因为用户可能会在快速浏览时连续点击多个展品如果每个讲解音频从头播放会出现多路声音叠加。我在 AudioManager 里用一个专门的 AudioSource 处理语音每次播新语音前先 Stop再替换 clip再 Play。同时提供打断回调用户退出详情面板时语音立即停止。逻辑很简单但很多项目因为忘记 Stop 导致语音重叠。另外太长的讲解词我会做分段数据文件里直接传多个句子数组尽量避免在代码里做复杂的字符串切割这样也方便配音老师逐句对齐。4.5 展厅小地图与位置引导小地图用俯拍相机加 RenderTexture 实现性能开销很小。具体做法在展厅上方约 10 米处创建一个正交相机cullingMask 只渲染标记层地板路径贴花、展柜顶上的锚点标记然后在 UI 的 RawImage 上显示。玩家箭头用一个 Image 图标跟随玩家的位置和旋转在 RawImage 上移动。核心是坐标映射。展厅的局部直角坐标和 UI 像素坐标之间只需要一个线性映射但要注意展厅可能不是正方形需要按 X 和 Z 两个方向分别映射。如果展厅门向和地图旋转方向不一致还要做角度偏移public class MiniMapController : MonoBehaviour { public Transform player; public RectTransform arrowIcon; public RectTransform mapRect; public Vector2 worldMin; public Vector2 worldMax; public float mapRotationOffset; private void LateUpdate() { Vector3 pos player.position; float ratioX (pos.x - worldMin.x) / (worldMax.x - worldMin.x); float ratioZ (pos.z - worldMin.y) / (worldMax.y - worldMin.y); float mapWidth mapRect.rect.width; float mapHeight mapRect.rect.height; arrowIcon.anchoredPosition new Vector2( (ratioX - 0.5f) * mapWidth, (ratioZ - 0.5f) * mapHeight ); arrowIcon.localRotation Quaternion.Euler(0, 0, -player.eulerAngles.y mapRotationOffset); } }小地图的 bug 主要集中在比例尺寸上如果地图图片和展厅不是等比缩放箭头位置会偏移。所以要么准备一张和展厅宽高比一致的底图要么在 RawImage 的 aspect ratio 上做适配。俯拍相机要设置为正交、关闭景深和阴影RenderTexture 分辨率设到 256x256 就够减少 GPU 开销。5. 性能优化与常见问题排查实录5.1 性能预算与设备适配这个项目最终交付时Windows 桌面端 1080P 下稳定跑在 140 帧以上WebGL 端保持 50 到 60 帧。能做到这一点是因为从一开始就把性能当作功能的一部分来设计。桌面端主要瓶颈是 DrawCall 和纹理内存。我的经验数据是展馆场景整体 DrawCall 控制在 200 以内静态物体通过 Static Batching 把几百个批次压到几十个展品模型分开渲染但因为数量少影响不大。纹理尺寸 4096 只用于高模详情展示低模用 2048 或 1024普通展板用 512。在 URP 下开启 SRP Batcher能显著降低 CPU 的渲染调用开销。WebGL 端要额外注意包体积。Unity 导出 WebGL 默认会把场景资源和脚本压缩在一起一个包含高精模型的展馆动辄几十 MB加载速度会很慢。我的处理方式是把详情页用的高模打进 AssetBundle不在初始场景加载初始化时只加载低模和 UI用户点击某件展品时才异步加载对应的高模。这样首包体积能小一半以上。异步加载时注意用协程或异步操作封装不要在主线程上同步加载大资源否则页面会卡死。5.2 C# 脚本层面的高频问题和避坑清单开发过程里我把 C# 相关的问题整理成速查表很多都是高频踩坑点问题原因与解决数组越界JSON 只有 5 条数据代码却遍历到 index 7。遍历前先检查 Length字符串截取乱码Substring 越界或中英文字节错位。统一用字符数组或 Split 后判断长度Resources.Load 返回 null路径大小写敏感使用前判空协程不执行协程方法所在 MonoBehaviour 被禁用或物体被销毁事件订阅后不取消OnDestroy 里取消订阅否则旧目标被引用内存泄漏延时函数受 Time.timeScale 影响暂停游戏时代替方案用 Time.unscaledDeltaTime委托引用旧对象发布事件前判空使用 null 条件运算符Debug.Log 刷屏正式包删掉日志输出避免性能损耗中文在 WebGL 乱码检查 JSON 和文本文件编码统一为 UTF-8UI 按钮点击无反应检查场景里是否有 EventSystem这里单独说一下“协程和延时的区别”。虚拟展馆经常有“延迟关闭面板”“淡入淡出后切换章节”这类操作很多初学者习惯用 Thread.Sleep 做延迟这是绝对错误的。Unity 主线程和渲染线程连续工作Thread.Sleep 会把整个游戏冻结表现就是卡死。正确做法是协程配合 WaitForSeconds或者用 Invoke或使用 UniTask 的异步方法。使用协程时物体销毁后不要继续 yield否则会报 MissingReferenceException。关于“C# 多线程”再提醒一句Unity 中非主线程不能调用大多数 Unity API。如果你要用多线程做资源预加载或数据解析要保证最终返回值通过主线程队列传递给场景。我在项目里用了一个线程安全队列把字典型数据在线程里解析完放进队列主线程在 Update 里取既利用了多线程又不会踩 Unity API 的雷。5.3 从编辑器到打包环境问题与 WebGL 部署要点有人问过 Intel Mac 装 Unity 的问题。如果你也用 Intel 芯片的 MacUnity Hub 装 LTS 版本偶尔会卡在安装 Mono 或者模块下载失败。解决办法是先单独下载对应的 Unity 模块包再手动安装系统缺少的 .NET SDK或者去 Unity 官方下载页面用 Unity Hub 的“本地安装”按钮选择模块包。这类问题主要是网络和权限跟项目本身关系不大但确实会卡住不少人。打包 WebGL 还有几个细节压缩格式推荐 Brotli但发布到某些服务器时要配置 MIME 类型否则浏览器会下载失败建议本地测试先跑一遍 Build And Run确认没有跨域报错。如果后续要把展馆嵌到网页里Unity 官方的 WebGL 加载模板可以改承载容器大小和进度条建议尽早改模板不要发布后再用 CSS 硬调那样兼容性很差。做这个项目最大的体会是虚拟展馆的“虚拟”并不是核心价值核心价值是让观众用平时不可能的方式去看展。核雕实体太小太珍贵但在数字空间里每一个人都能把它放大到二三十厘米慢慢转着看看刀痕、看衣纹、看船窗上的格子。技术实现上有一点我特别想强调不要为了炫技加入太多花把式。交互第一层要直接第二层要信息准确第三层才谈表现力。我最终把所有交互收敛到三种模式——自由漫游、点击查看、自动导览实践下来用户反馈非常好。最后分享一个小技巧如果展品是深色材质比如核雕这种深褐色的橄榄核详情面板的背景色一定要用深灰或接近黑色而不是亮背景。这个细节看似无关紧要但直接影响用户对细节纹理的观察。浅色背景会让模型边缘和背景混在一起暗部细节全糊。展馆里所有展品放在深色底台上也是同样的道理。