
核雕这门手艺讲究的是方寸之间见天地。一粒橄榄核长度不过两三厘米上面却能刻出十八罗汉、赤壁夜游、苏州园林。但问题也来了——真正能到现场看核雕的人太少光线不对看不清展柜玻璃反光更别提把一颗核雕拿在手里360度转着看了。我做这个虚拟展馆项目的出发点很直接用Unity 3D把核雕的观看体验搬到线上让用户能自由旋转、缩放、近距离观察每一件作品同时通过UGUI搭建完整的展馆导览系统最后用WebGL打包让它在浏览器里就能跑起来不需要装任何客户端。这个项目适合谁看如果你正在做Unity的WebGL项目尤其是文化展示、虚拟展厅、数字博物馆这类方向这里面的坑我基本都踩过一遍。如果你只是刚接触Unity和C#想找一个完整的、有实际意义的项目来练手这个展馆的架构也不算复杂核心就是场景管理、UI交互和模型操控三块。下面我会从项目架构开始把每个环节的技术选型理由、实操步骤、踩坑记录都摊开来讲。1. 为什么选Unity 3D WebGL来做核雕展馆1.1 核雕展示的核心需求拆解先想清楚一件事核雕展馆到底要解决什么问题我列了一下大概有这几个硬需求。第一是多角度观察。核雕是立体雕刻正面看是一个样子侧面看又是另一个样子。传统图片展示只能给固定角度用户看不到背面和底部的细节。虚拟展馆必须支持自由旋转和缩放。第二是细节放大。核雕的精细程度很高有些作品在1厘米范围内刻了七八个人物。用户需要能拉近镜头看清局部的刀工和纹理。第三是展馆导览。不能只是把模型丢在场景里让用户自己瞎转需要有展区划分、作品信息面板、导航按钮这些UI元素让整个体验像逛展馆一样有秩序。第四是低门槛访问。目标用户不一定有高性能电脑也不一定愿意下载安装包。WebGL方案可以让用户打开浏览器就能看这是最关键的决策依据。1.2 Unity 3D相比其他方案的优势市面上做虚拟展馆的方案不少我对比过几种主流选择。方案优势劣势适用场景Unity 3D WebGL生态成熟、资源丰富、C#开发效率高WebGL包体较大、移动端性能有限中小型展馆、文化展示Three.js轻量、浏览器原生开发效率低、复杂交互实现成本高简单3D展示Unreal Engine渲染质量顶级学习曲线陡、WebGL支持不成熟高端影视级展示全景图方案实现简单、性能好无法自由旋转模型、交互受限纯背景展示选Unity的核心原因是开发效率和生态。核雕展馆需要大量的UI交互、场景管理、动画控制这些在Unity里都有现成的组件和API。C#的语言特性也比JavaScript更适合组织复杂的交互逻辑。UGUI系统虽然有些历史包袱但做展馆导览这种常规UI布局完全够用。另一个关键考量是模型导入流程。核雕模型通常是高精度的面数可能达到几十万甚至上百万。Unity对FBX和OBJ格式的支持很成熟导入后可以通过LODLevel of Detail和Mesh压缩来优化性能。Three.js虽然也能加载这些格式但优化手段没有Unity丰富。1.3 WebGL打包的现实约束WebGL是个好东西但有几个现实问题必须提前想清楚。包体大小。Unity的WebGL包体默认就不小加上核雕的高精度模型和贴图很容易超过100MB。这对加载速度是很大的考验。我的做法是模型做减面处理贴图压缩到1024x1024以内开启Unity的压缩选项最终把包体控制在50MB左右。内存限制。浏览器对WebGL的内存分配有上限32位浏览器通常限制在2GB左右实际可用更少。核雕模型如果面数太高加载时直接崩溃。所以减面不是可选项是必须做的。移动端兼容。手机浏览器的WebGL性能参差不齐尤其是中低端安卓机。我的策略是检测设备类型移动端自动降低渲染质量关闭阴影和抗锯齿保证基本流畅。注意Unity 2021 LTS之后的版本对WebGL的支持好了很多尤其是IL2CPP编译和Brotli压缩。如果你还在用2019或更早的版本建议升级能省不少事。2. 展馆场景的搭建与核雕模型的导入处理2.1 场景结构设计从展区划分到光照布局展馆场景不能随便摆几个模型就完事需要有空间逻辑。我的设计是三个展区历史展区展示核雕的发展脉络技法展区展示不同雕刻技法精品展区展示代表性作品。每个展区之间用走廊连接用户通过导航按钮或自由漫游在展区间切换。场景层级结构是这样的ExhibitionHall (空物体挂载场景管理脚本) ├── Zone_History (历史展区) │ ├── DisplayStand_01 (展台) │ ├── Carving_01 (核雕模型) │ └── InfoPanel_01 (信息面板锚点) ├── Zone_Technique (技法展区) │ └── ... ├── Zone_Masterpiece (精品展区) │ └── ... ├── Lighting (光照组) │ ├── DirectionalLight (主光源) │ └── SpotLights (展台射灯) └── UI_Canvas (UI画布)光照是展馆的灵魂。核雕的细节全靠光影来体现光照不对再好的模型也白搭。我用的是三点布光法的变体主光源用Directional Light提供整体照明展台射灯用Spot Light从上方45度打下来再加一个微弱的Fill Light从侧面补光避免暗部死黑。// 展台射灯配置示例 Light spotLight displayStand.AddComponentLight(); spotLight.type LightType.Spot; spotLight.spotAngle 35f; // 光锥角度 spotLight.intensity 2.5f; // 强度 spotLight.range 5f; // 照射距离 spotLight.color new Color(1f, 0.98f, 0.95f); // 微暖色调 spotLight.shadows LightShadows.Soft; // 软阴影这里有个细节色温。核雕是木质材料暖色调的光3000K-4000K能更好地还原木质的温润感。冷白光会让核雕看起来像塑料失去质感。我在Spot Light的color上做了微调R通道给满G通道0.98B通道0.95模拟暖白光的效果。2.2 核雕模型的减面与优化策略核雕模型通常来自三维扫描或手工建模面数动辄几十万。直接导入UnityWebGL端必崩。减面是第一步。我的减面流程是这样的在Blender中做初步减面。用Decimate修改器把面数降到原来的30%左右。核雕的细节主要在表面纹理减面时要注意保留轮廓边缘避免出现明显的多边形棱角。在Unity中做二次优化。导入后开启Mesh Compression设置为Medium。同时生成LOD Group根据相机距离切换不同精度的模型。法线贴图补偿细节。减面后丢失的表面细节用法线贴图来补偿。从高模烘焙法线贴图到低模上这样在视觉上能保留大部分细节。// LOD Group配置示例 LODGroup lodGroup carvingModel.AddComponentLODGroup(); LOD[] lods new LOD[3]; // LOD0: 原始模型距离0-5米 lods[0] new LOD(0.5f, new Renderer[] { highPolyRenderer }); // LOD1: 中等精度距离5-15米 lods[1] new LOD(0.2f, new Renderer[] { midPolyRenderer }); // LOD2: 低精度距离15米以上 lods[2] new LOD(0.05f, new Renderer[] { lowPolyRenderer }); lodGroup.SetLODs(lods); lodGroup.RecalculateBounds();减面的度怎么把握我的经验是核雕主体轮廓不能有明显棱角但表面纹理可以适当简化。因为用户在旋转观察时最先注意到的是轮廓表面纹理在快速旋转时反而看不清。所以减面时优先保留边缘顶点表面内部的顶点可以大胆合并。2.3 材质与贴图的WebGL适配核雕的材质是木质需要表现出粗糙度和次表面散射的效果。Unity的Standard Shader可以调但在WebGL下性能开销较大。我的做法是自定义一个简化版的Shader去掉不必要的计算。Shader Custom/CarvingWood { Properties { _MainTex (Albedo, 2D) white {} _NormalMap (Normal, 2D) bump {} _Roughness (Roughness, Range(0,1)) 0.7 _Specular (Specular, Range(0,1)) 0.15 } SubShader { Tags { RenderTypeOpaque } LOD 200 CGPROGRAM #pragma surface surf Standard fullforwardshadows #pragma target 3.0 sampler2D _MainTex; sampler2D _NormalMap; float _Roughness; float _Specular; struct Input { float2 uv_MainTex; float2 uv_NormalMap; }; void surf (Input IN, inout SurfaceOutputStandard o) { o.Albedo tex2D(_MainTex, IN.uv_MainTex).rgb; o.Normal UnpackNormal(tex2D(_NormalMap, IN.uv_NormalMap)); o.Metallic 0.0; o.Smoothness 1.0 - _Roughness; } ENDCG } FallBack Diffuse }贴图压缩方面WebGL平台推荐用ASTC或ETC2格式。如果目标设备支持ASTC优先用ASTC压缩比更高质量更好。不支持的话回退到ETC2。贴图分辨率控制在1024x1024个别需要展示细节的可以用2048x2048但数量要严格控制。提示Unity的Texture Import Settings里有个Max Size选项导入时直接设成1024避免忘记压缩导致包体膨胀。3. UGUI导览系统的交互设计与实现3.1 展馆UI的整体布局思路UGUI是Unity的UI系统虽然现在有了UI Toolkit但UGUI在WebGL下的稳定性和兼容性更好而且资料多、上手快。展馆的UI我分成三层底层是导航层固定在屏幕底部包含展区切换按钮、返回按钮、帮助按钮。这一层始终可见用户随时能切换展区。中层是信息层当用户点击某个核雕作品时弹出显示作品名称、作者、年代、技法说明等信息。信息层是半透明的不遮挡模型。顶层是操作层包含旋转、缩放、重置视角的按钮以及一个全屏查看的切换按钮。这一层在用户选中模型后出现。// UI层级管理示例 public class UIManager : MonoBehaviour { public GameObject navigationPanel; // 导航层 public GameObject infoPanel; // 信息层 public GameObject controlPanel; // 操作层 void Start() { // 初始状态只显示导航层 navigationPanel.SetActive(true); infoPanel.SetActive(false); controlPanel.SetActive(false); } public void OnCarvingSelected(CarvingData data) { // 选中模型时显示信息层和操作层 infoPanel.SetActive(true); controlPanel.SetActive(true); infoPanel.GetComponentInfoPanelController().SetData(data); } public void OnCarvingDeselected() { infoPanel.SetActive(false); controlPanel.SetActive(false); } }3.2 模型旋转与缩放的触控/鼠标交互这是展馆最核心的交互功能。用户需要能用鼠标拖拽旋转模型用滚轮缩放触屏设备上则用单指旋转、双指缩放。实现思路是射线检测 拖拽计算。鼠标按下时从相机发射一条射线检测是否击中核雕模型。如果击中记录初始位置和模型的初始旋转。鼠标移动时计算位移量映射到模型的旋转角度上。public class ModelRotator : MonoBehaviour { private bool isDragging false; private Vector3 lastMousePos; public float rotateSpeed 5f; public float zoomSpeed 2f; public float minZoom 0.5f; public float maxZoom 3f; void Update() { // 鼠标拖拽旋转 if (Input.GetMouseButtonDown(0)) { Ray ray Camera.main.ScreenPointToRay(Input.mousePosition); RaycastHit hit; if (Physics.Raycast(ray, out hit) hit.transform transform) { isDragging true; lastMousePos Input.mousePosition; } } if (Input.GetMouseButtonUp(0)) { isDragging false; } if (isDragging) { Vector3 delta Input.mousePosition - lastMousePos; float rotX delta.x * rotateSpeed * Time.deltaTime; float rotY -delta.y * rotateSpeed * Time.deltaTime; transform.Rotate(Vector3.up, rotX, Space.World); transform.Rotate(Vector3.right, rotY, Space.World); lastMousePos Input.mousePosition; } // 滚轮缩放 float scroll Input.GetAxis(Mouse ScrollWheel); if (scroll ! 0) { Vector3 scale transform.localScale; scale Vector3.one * scroll * zoomSpeed; scale Vector3.Max(scale, Vector3.one * minZoom); scale Vector3.Min(scale, Vector3.one * maxZoom); transform.localScale scale; } } }触屏设备的处理需要额外注意。Unity的Input系统在移动端会自动把触摸映射到鼠标事件但双指缩放需要手动处理。// 双指缩放处理 if (Input.touchCount 2) { Touch touch1 Input.GetTouch(0); Touch touch2 Input.GetTouch(1); Vector2 prevPos1 touch1.position - touch1.deltaPosition; Vector2 prevPos2 touch2.position - touch2.deltaPosition; float prevDistance Vector2.Distance(prevPos1, prevPos2); float currentDistance Vector2.Distance(touch1.position, touch2.position); float delta currentDistance - prevDistance; Vector3 scale transform.localScale Vector3.one * delta * 0.01f; scale Vector3.Max(scale, Vector3.one * minZoom); scale Vector3.Min(scale, Vector3.one * maxZoom); transform.localScale scale; }这里有个坑旋转轴的选择。如果用Space.Self模型旋转几圈后轴向会乱掉用户拖拽的方向和模型旋转的方向对不上。必须用Space.World让旋转始终围绕世界坐标系的Y轴和X轴进行。这个细节我在项目初期没注意测试时发现旋转几十度后模型就翻跟头了排查了半天才找到原因。3.3 信息面板的数据驱动设计展馆里有几十件核雕作品每件都有不同的信息。如果每件作品都手动配置UI工作量巨大且容易出错。我的做法是数据驱动用ScriptableObject存储作品数据UI面板根据数据动态生成内容。[CreateAssetMenu(fileName CarvingData, menuName Exhibition/CarvingData)] public class CarvingData : ScriptableObject { public string carvingName; // 作品名称 public string author; // 作者 public string era; // 年代 public string technique; // 技法 public string description; // 详细描述 public Sprite thumbnail; // 缩略图 public GameObject modelPrefab; // 模型预制体 }信息面板的控制器根据这些数据填充UIpublic class InfoPanelController : MonoBehaviour { public Text nameText; public Text authorText; public Text eraText; public Text techniqueText; public Text descriptionText; public Image thumbnailImage; public void SetData(CarvingData data) { nameText.text data.carvingName; authorText.text 作者 data.author; eraText.text 年代 data.era; techniqueText.text 技法 data.technique; descriptionText.text data.description; thumbnailImage.sprite data.thumbnail; } }这样做的好处是新增作品只需要创建一个ScriptableObject资源不需要改代码。展馆后期扩展时策划人员可以自己配置数据程序员不用介入。注意ScriptableObject的数据在WebGL打包后是只读的如果需要运行时修改得用JSON或PlayerPrefs。展馆场景一般不需要运行时修改所以ScriptableObject完全够用。4. WebGL打包与性能调优的实战记录4.1 WebGL打包的关键配置项Unity的WebGL打包有不少配置项设错了要么包体巨大要么跑不起来。我把我用的配置列一下。Player Settings里的关键设置配置项推荐值理由Compression FormatBrotli压缩率最高现代浏览器都支持Data Caching开启缓存资源二次加载更快Strip Engine Code开启去掉未使用的引擎代码减小包体Managed Stripping LevelMedium平衡包体和稳定性IL2CPP Code GenerationFaster (smaller) buildsWebGL下包体优先Enable ExceptionsNone关闭异常捕获减小包体和提升性能Quality Settings里的调整关闭抗锯齿Anti AliasingWebGL下开销太大阴影质量降到Hard Shadows或直接关闭纹理质量降到Half Res关闭VSync// 运行时根据设备调整质量 void Start() { if (Application.isMobilePlatform) { QualitySettings.SetQualityLevel(0); // 最低质量 QualitySettings.shadows ShadowQuality.Disable; QualitySettings.antiAliasing 0; } else { QualitySettings.SetQualityLevel(2); // 中等质量 QualitySettings.shadows ShadowQuality.HardOnly; QualitySettings.antiAliasing 0; } }4.2 包体压缩与加载速度优化包体是WebGL项目的生命线。我做过测试包体从100MB降到50MB首次加载时间从40秒降到15秒左右取决于网络。优化手段主要有这几个模型减面是最有效的。前面说过核雕模型从50万面降到15万面包体能减少30%以上。贴图压缩也很关键。所有贴图导入时设置Max Size为1024格式用ASTC 6x6。如果贴图数量多可以考虑合并成图集减少Draw Call。音频压缩。展馆的背景音乐和解说音频用Vorbis格式质量设50%左右。如果音频不是必须的可以考虑去掉用文字代替。代码剥离。开启Managed Stripping Level把未使用的代码去掉。但要注意反射调用的代码不会被自动剥离需要手动加link.xml保护。!-- link.xml 保护反射调用的类 -- linker assembly fullnameAssembly-CSharp type fullnameCarvingData preserveall/ type fullnameUIManager preserveall/ /assembly /linker加载速度优化方面我用的是分步加载策略。先加载展馆的基础场景和UI让用户能尽快看到界面。核雕模型按展区异步加载用户切换到某个展区时再加载对应的模型。// 异步加载展区资源 IEnumerator LoadZoneAssets(string zoneName) { string path Zones/ zoneName; ResourceRequest request Resources.LoadAsyncGameObject(path); while (!request.isDone) { float progress request.progress; loadingBar.fillAmount progress; yield return null; } GameObject zonePrefab request.asset as GameObject; Instantiate(zonePrefab); }4.3 帧率稳定与内存泄漏排查WebGL下的性能问题主要集中在帧率波动和内存泄漏两方面。帧率波动通常是因为Draw Call过高。核雕模型如果每个都是独立渲染几十个模型就是几十个Draw Call。解决办法是静态合批和GPU Instancing。展馆里不动的展台、墙壁用静态合批相同的核雕底座用GPU Instancing。// 开启GPU Instancing的材质设置 Material material new Material(Shader.Find(Standard)); material.enableInstancing true;内存泄漏在WebGL下比较隐蔽因为浏览器不会直接报错而是表现为运行一段时间后崩溃。常见原因是事件监听未取消和资源未释放。// 正确的资源释放 void OnDestroy() { // 取消事件监听 EventManager.OnCarvingSelected - OnCarvingSelected; // 释放动态加载的资源 Resources.UnloadUnusedAssets(); // 销毁动态创建的Texture if (runtimeTexture ! null) { Destroy(runtimeTexture); } }我踩过的一个坑在Update里频繁创建字符串。C#的字符串是不可变的每次拼接都会产生新的字符串对象在WebGL下GC压力很大。解决办法是用StringBuilder或者缓存字符串。// 错误做法每帧创建新字符串 void Update() { infoText.text 当前视角 currentAngle.ToString(F1) 度; } // 正确做法缓存StringBuilder private StringBuilder sb new StringBuilder(); void Update() { sb.Clear(); sb.Append(当前视角); sb.Append(currentAngle.ToString(F1)); sb.Append(度); infoText.text sb.ToString(); }提示Unity Profiler在WebGL下不能直接用但可以用浏览器自带的Performance面板来分析。Chrome的Performance面板能看到每帧的耗时分布定位性能瓶颈很有用。5. 核雕展馆项目中的几个典型坑与解决思路5.1 模型导入后法线翻转的问题核雕模型从Blender导出FBX再导入Unity有时候会出现法线翻转模型看起来像里外翻了个面。这个问题通常是因为Blender和Unity的坐标系不一致导致的。解决办法有两个一是在Blender导出时勾选Y Forward和Z Up确保坐标系匹配二是在Unity的Model Import Settings里勾选Swap UVs或Flip Normals。我遇到的情况更复杂一些模型的部分面法线翻转不是全部。排查后发现是Blender里有些面的顶点顺序反了。解决办法是在Blender里进入编辑模式全选所有面按ShiftN重新计算法线。5.2 WebGL下中文乱码的排查过程UGUI的Text组件在WebGL下显示中文时有时候会出现方块或乱码。原因是Unity默认的字体不包含中文字形。解决办法是导入中文字体生成Font Asset。具体步骤下载一个开源中文字体如思源黑体导入Unity在Window TextMeshPro Font Asset Creator中创建Font Asset选择字符集为Custom Range输入需要的中文字符范围生成Font Asset后在Text组件中指定这个字体// 动态设置中文字体 public TMP_FontAsset chineseFont; void Start() { nameText.font chineseFont; descriptionText.font chineseFont; }这里有个细节Font Asset的字符集不要选All Characters那样会生成一个巨大的字体图集包体暴增。只选展馆里实际用到的字符或者用Custom Range指定常用汉字范围如0x4E00-0x9FFF。5.3 移动端触控与鼠标事件的冲突处理展馆需要同时支持PC和移动端但触控和鼠标事件有时候会冲突。比如在移动端用户触摸屏幕时Unity会同时触发Touch事件和Mouse事件导致旋转操作被执行两次。解决办法是用条件编译或运行时判断在移动端只处理Touch事件在PC端只处理Mouse事件。void Update() { #if UNITY_IOS || UNITY_ANDROID HandleTouchInput(); #else HandleMouseInput(); #endif }如果不想用条件编译也可以在运行时判断void Update() { if (Input.touchSupported Input.touchCount 0) { HandleTouchInput(); } else { HandleMouseInput(); } }我推荐用运行时判断因为条件编译在编辑器里测试时不太方便。但要注意有些Windows设备也支持触控这时候两种输入都要处理需要加一个标志位来避免重复响应。5.4 展馆场景切换时的资源管理展馆有多个展区用户切换展区时如果不做资源管理内存会越用越多最终崩溃。我的做法是切换时卸载上一个展区的资源。public class ZoneManager : MonoBehaviour { private GameObject currentZone; private string currentZoneName; public void SwitchZone(string zoneName) { // 卸载当前展区 if (currentZone ! null) { Destroy(currentZone); Resources.UnloadUnusedAssets(); } // 加载新展区 StartCoroutine(LoadZone(zoneName)); } IEnumerator LoadZone(string zoneName) { ResourceRequest request Resources.LoadAsyncGameObject(Zones/ zoneName); while (!request.isDone) { yield return null; } currentZone Instantiate(request.asset) as GameObject; currentZoneName zoneName; } }这里有个坑Resources.UnloadUnusedAssets()是异步操作调用后不会立即释放内存而是等下一帧才执行。如果连续切换展区可能会因为资源还没释放完就加载新资源导致内存峰值过高。解决办法是加一个延迟或者用AssetBundle的引用计数来管理。6. 从展馆项目延伸出的几点个人体会做这个核雕展馆项目前后花了大概两个月时间其中一半时间是在调性能和踩坑。有几个体会比较深分享一下。第一WebGL项目的性能优化要提前做不能等做完再优化。我一开始想着先把功能做完再优化结果模型面数太高打包后直接跑不起来又回头重新减面、重新做LOD浪费了很多时间。正确的做法是项目启动时就定好性能预算模型面数上限、贴图分辨率上限、Draw Call上限开发过程中严格遵守。第二UGUI在WebGL下的性能比想象中好但要注意Canvas的拆分。一个大的Canvas会导致任何UI变化都触发整个Canvas的重建开销很大。我的做法是把静态UI和动态UI分到不同的Canvas上静态的如背景、边框一个Canvas动态的如信息面板、按钮另一个Canvas。第三数据驱动的设计能省很多事。展馆里的作品数据用ScriptableObject管理新增作品只需要创建资源文件不用改代码。这个设计在后期扩展时特别有用策划人员可以自己配置程序员不用介入。第四测试要覆盖低端设备。我在一台老旧的安卓手机上测试时发现帧率只有15帧左右操作卡顿明显。后来降低了渲染质量关闭了阴影和抗锯齿帧率才稳定在30帧。如果只在高配电脑上测试上线后会被用户骂死。最后再分享一个小技巧Unity的WebGL模板可以自定义。默认的加载页面比较简陋可以在Assets/WebGLTemplates下创建一个自定义模板把加载进度条、展馆Logo、背景图都放进去用户体验会好很多。模板文件是HTMLCSSJavaScript改起来不难但效果提升很明显。这个展馆项目后续还可以扩展的方向不少比如加入语音解说、增加AR模式让用户用手机摄像头把核雕放在桌面上看、或者做一个后台管理系统让策展人员自己上传作品。不过那是另一个项目的事了先把当前这个跑通跑稳再说。