做了这么多年 Unity,我越来越觉得“简易三维场景”其实是最好练手,也最能暴露基本功的项目。很多朋友一上来就想着搭大型开放世界,追着高精度模型和 PBR 材质跑,结果工程文件乱成一锅粥,运行帧率也惨不忍睹。我自己的经验是,先老老实实做一个能走、能看、能交互的迷你场景,把地形、灯光、阴影、角色控制、UI 交互和发布流程全部跑通,后面再去碰数字孪生、大场景优化、XR 开发这些进阶方向才有底气。这篇就基于我最近用 Unity 做的一个简易三维场景项目,把从建工程到发布 WebGL 的完整思路、核心参数和踩坑记录都整理出来,适合刚学 Unity 不久、想完成第一个完整作品的朋友,也适合做毕设或者项目演示底子的人参考。
1. 项目定位与整体设计思路
1.1 这个“简易三维场景”到底在做什么
先明确一下项目边界。我要做的不是游戏 Demo,也不是高精度的建筑可视化,而是一个“麻雀虽小五脏俱全”的三维演示场景,核心目标是验证 Unity 场景搭建的完整工作流。整个场景包含一块可自由行走的地形,地面上有树木、石块、房子这类占位物件,头顶有方向光负责照明和阴影,角色用第一人称视角在场景里漫游,同时界面上有一个滑动条可以控制时间变化,场景里还放了几个可以拾取的小物品,互动后会播放一个淡出效果。
这种定位有几个好处。第一,覆盖了 Unity 开发中最常用的模块:地形系统、灯光渲染、角色控制、UGUI 交互、脚本生命周期、协程、Build 发布,几乎每个初学者都需要过一遍;第二,所有资源都可以用 Unity 内置的基础几何体和自带资源生成,不需要外部建模,也不用买素材,适合零美术基础的人;第三,后续扩展性很强,不管是往数字孪生方向做成一个沙盘演示,还是接入真实传感器数据做可视化,底子都是这一套。
1.2 Unity 版本与渲染管线选择
关于版本,我强烈建议用 LTS(长期支持版)。我这次用的是 Unity 2022.3 LTS,国际版直接从官网下载即可。这里多说一句,Unity Hub 里可以同时装多个版本,不同项目用不同版本是很正常的,不要追求最新的 6000.x,稳定性永远比新功能重要。
渲染管线的选择是一个容易纠结的点。Unity 目前主流有内置渲染管线(Built-in)、URP 和 HDRP 三套。对于简易三维场景,我的建议是直接用内置渲染管线。虽然 URP 的效果上限更高,但新手最容易碰到的“阴影问题”“光照发灰”“后期效果不生效”,有一大半是渲染管线切换引起的配置差异。内置管线默认参数就能跑得很好,文档和教程存量也最大,等玩熟了再迁移到 URP 不迟。
还有一个很容易被忽略的点:项目创建时的模板选择。Unity Hub 新建项目时有 3D Core、3D URP、3D HDRP 等多个模板,很多人会随手选一个标准 3D 模板,后来装的资源包却对应 HDRP 渲染,结果模型渲染出来变成洋红色。我这次选的是 3D Core 模板,后期也没有换管线,省了一堆麻烦。
1.3 场景漫游的核心模块拆解
在动手前,我习惯先把一个“可漫游三维场景”拆成几个独立模块,每个模块只干一件事。这个思维习惯很重要,因为 Unity 工程一旦变大,脚本之间相互引用会变得非常混乱,反过来,如果一开始就划分清楚,后面加功能会非常顺畅。
我的拆法是:
- 环境模块:地形、天空盒、雾效、树木和建筑占位体
- 光照模块:方向光、环境光、阴影参数
- 角色模块:第一人称视角、CharacterController 移动、鼠标旋转视角
- 交互模块:可拾取物品、UI 提示、淡出效果
- 界面模块:滑动条控制时间、按钮事件
- 发布模块:WebGL 测试与打包
每个模块独立开发、独立测试,最后再串起来。比如角色控制不影响 UI 逻辑,UI 逻辑不影响光照参数,这样某个模块出问题时可以快速定位。我在实际操作中见过不少朋友把移动、交互、动画全部写在一个脚本里,出 bug 的时候根本不知道是哪段代码的锅,这种架构问题一定要从一开始就避免。
2. 环境搭建:地形、天空与基础摆件
2.1 用 Unity 地形系统刷出一个能走的小场景
Unity 自带的地形系统在 Window > General > Terrain 面板,也可以直接在 Hierarchy 里右键 3D Object > Terrain 创建。我这次建的地形是 200x200 单位,分辨率 512,这个配置在性能和细节之间比较平衡,太小的地形没有漫游感,太大的地形会引入烘焙耗时和帧率压力,对简易项目来说没必要。
地形创建之后,第一步是“抬地面”。选中地形,在 Terrain 工具栏切到 Raise/Lower Terrain 模式,用鼠标左键在场景视图里涂抹,可以按住 Shift 降低。这里有一个技巧:笔刷大小(Brush Size)和强度(Opacity)要配合使用。做大面积山脉时用大笔刷低强度,做局部坡道细节时用小笔刷,这样地面起伏才自然。我见过有人一上来就用满强度的笔刷狠刷,结果地形变成一坨锯齿形的土堆,想修回来还得花更多时间。
第二步是给地形刷纹理。在 Terrain 工具栏切到 Paint Texture,先添加一张草地纹理作为基础层,再加一张泥土纹理用于道路和坡地。这里要注意一个细节:Unity 的“目标强度”(Target Strength)决定纹理的透明度,刷道路时可以先把强度降到 0.3 左右,多次涂抹,比一次性刷满要自然得多。如果没有现成纹理,用自带的 TerrainTextures 包就可以,只要 Window > Package Manager > Terrain 里勾选导入示例资源。
第三步是添加树木和植被。切到 Paint Trees,选中一棵树预制体,在场景里点几下就能种出树。但我这里要提醒一下:简易场景不要种太多树,也不要用高模树。树的数量一多,Draw Call 会爆炸,第一人称漫游时会明显掉帧。我这次整个场景只种了二十多棵,树模型用的是 Unity 自带资源里的 Low Poly Tree,远处再用雾效掩盖边界,观感不差,性能也完全扛得住。
2.2 天空盒与全局光照参数细节
天空盒决定整个场景的色调和氛围,但很多新手会忽略。默认天空盒是蓝色的渐变天空,有点假,我习惯换成 Procedural 模式的 Skybox 材质,参数可以自己调。在 Window > Rendering > Lighting 的 Environment 选项卡里,把 Skybox Material 指到一个新建的 Procedural Skybox 材质,然后把 Atmosphere Thickness 调小一点,太阳亮度稍微提高,地面会被环境光轻微照亮,整体会舒服很多。
环境光这一项特别关键。因为场景里没有补光,角色背光面会非常黑,看起来像硬砍出来的阴影。我通常会把 Environment Lighting 里的 Ambient Mode 设为 Trilight,Ambient Sky、Ambient Equator、Ambient Ground 分别给一个偏蓝的亮色、中灰色和深灰色。这三个值的含义很好理解:天顶光、水平环境和地面反射光,调得好能让建筑的阴面也有体积感。这个参数在“阴影太黑”的排查中也经常用到,先别急着加补光灯,试试把环境光调高一点,可能问题就解决了。
最后是雾效。我这次在 Lighting 窗口勾选了 Fog,Fog Mode 用 Linear,Start 和 End 分别设为 100 和 300。雾效的作用不只是让远处变模糊,它还能把地形和天空的交界线柔和掉,让场景边缘不生硬。简易场景通常没有远处的高模细节,用一层淡淡的线性雾是最省钱也最出效果的规避方案。
2.3 用基础几何体搭出房子和道路
场景里如果全是地形会显得空,但没必要去下载建筑模型。Unity 内置的 Cube、Cylinder、Capsule 这些基础几何体,组合一下就能做出简单的房屋和道路。我这次搭了一个“方盒子房子”:四个 Cube 拉长做成墙面,再用一个 Cube 压扁做屋顶,屋顶材质给个偏红的纯色,整体看上去像一个抽象风格的乡村小屋。柱子用 Cylinder,围栏用 Cube 横着摆一排,这种极简风格在演示场景里很常见,比贴图拉满的写实模型还耐看。
材质这一块,我建议新建材质后把 Surface 类型设为 Standard 而非 URP/Lit,然后主要调 Albedo 颜色、Metallic 和 Smoothness 三个参数。比如地面道路用深灰色,把 Smoothness 调到 0.1 左右,避免强烈的镜面反射;水面可以用浅蓝色加 Smoothness 0.8,看起来有反光效果。这里有个原理要理解:Smoothness 越高越像光滑的塑料或水面,越低越像粗糙的布或岩石,这是 PBR 材质最基础的部分,做任何场景都会遇到。
3. 灯光、阴影与渲染细节处理
3.1 方向光角色与阴影参数的设置
方向光负责模拟太阳,是整个场景最主要的可见光源。我在 Hierarchy 里创建一个 Directional Light,旋转角度设置为 (50, -30, 0) 左右,刚创建时默认朝下,需要手动调整。旋转角度决定了太阳高度和阴影投射方向,这直接决定画面层次感,调出来效果最顺的角度通常让物体影子拉得比较长。
然后是阴影设置。选中方向光,在 Inspector 面板里把 Shadow Type 设为 Soft Shadows,这个选项会开启柔和阴影边缘,比 Hard Shadows 的锯齿边缘看起来真实很多。但要注意性能代价,Soft Shadows 采样成本更高,在简易场景里没有问题,但如果是移动端项目就要小心。接着把 Shadows 下的 Strength 保持默认 1,Normal Bias 和 Shadow Bias 一般是 0.2,这是 Unity 的常见默认值,也可以不调。
阴影距离是一个大坑。在 Quality Settings 的 Shadows 区域,有个 Shadow Distance 参数,默认一般是 150 或 100。如果这个值太小,比如默认在某些模板里只有 40,那么角色走到离摄像机稍远的位置时,地面上的阴影会突然消失,非常违和。我这次直接设为 300,覆盖整个地形范围,且没有明显的性能下降。这个参数就是热词里“unity阴影问题”最常见的来源之一,很多所谓阴影丢失的问题,一查 Shadow Distance 就明白了。
3.2 阴影边缘锯齿与阴影闪烁的排坑思路
做三维场景时,最常遇到的阴影问题有三个:过硬、过暗、闪烁。
过硬的阴影,解决方式是在方向光上把 Shadow Type 改成 Soft Shadows,同时到 Quality Settings 里把 Shadow Resolution 提高。这里我给一个参考:简易场景用 Medium 和 High 之间足够,改到 Ultra 之后边缘确实更细腻,但 Draw Call 显存开销会涨,而且对肉眼来说提升有限。
阴影过暗,常见原因是环境光太低,导致阴影区域死黑一片。解决办法就是上一步里我说的 Trilight 环境光调整,或者直接在方向光上把 Shadow Strength 从 1 降到 0.7 左右,阴影不会那么“闷”。
阴影闪烁,通常叫 Shadow Acne,是指在阴影边缘出现密密麻麻的噪点或网格状条纹。这个主要是因为 Bias 值太小。选中方向光,把 Shadow Bias 从默认的 0.2 调高到 0.5 左右,闪烁会明显减少。但 Bias 不能太大,太大会造成 Peter Panning,也就是阴影脱离物体本身的悬浮现象。这个值需要在具体场景里来回试,没有万能参数,我的经验是室外大场景从 0.5 起步,室内小场景从 0.2 起步。
3.3 利用雾效和后期调整整体氛围
氛围处理上,雾效是最省事的工具。我前面提到用 Linear 模式的雾,但如果你希望雾效更自然,可以把 Fog Mode 改成 Exponential Squared,密度 Density 设为 0.005 左右,这种模式的颜色变化更柔和,适合清晨或黄昏的感觉。雾的颜色最好和天空盒地平线颜色一致,否则远处会有一条明显的“雾墙”,很出戏。我这次把雾色调成偏灰的蓝紫色,和天空盒的暮色呼应,整个场景看起来多了一层氛围感。
如果还想再进一步,可以在场景里加一个 Directional Light 的 Color 渐变,模拟一天中不同时段的色调。比如中午的白光、傍晚的橙红、夜晚的深蓝。这个可以直接在灯光上改 Color 属性,配合后面要做的滑动条时间控制,效果会非常直观。但要注意的是,夜景下如果灯光 Color 太暗,场景会什么都看不见,需要额外补一个低强度点光源作为补光,或者调高环境光亮度。
4. 角色控制与摄像机跟随
4.1 第一人称控制器的完整脚本
角色控制是最影响“能看能走”体验的模块。Unity 有自带的 Character Controller 组件,用它来实现第一人称移动比较稳,因为它自带碰撞和重力处理,比直接用 Rigidbody 加 Transform 控制要简单且稳定。我创建一个空物体命名为 Player,给它挂上 CharacterController 组件,再挂上一个 Camera 作为子物体,摄像机放在 Player 内部大约 1.7 身高的位置。
下面是控制脚本,可以直接抄走用。脚本要挂在 Player 空物体上,Camera 作为子物体由玩家鼠标旋转控制。
using UnityEngine; public class FirstPersonController : MonoBehaviour { [Header("移动参数")] public float walkSpeed = 5f; public float runSpeed = 10f; public float jumpHeight = 1.2f; public float gravity = -9.81f; [Header("视角参数")] public float mouseSensitivity = 2f; public float pitchLimit = 80f; private CharacterController controller; private Camera playerCamera; private Vector3 velocity; private float pitch = 0f; private void Awake() { controller = GetComponent<CharacterController>(); playerCamera = GetComponentInChildren<Camera>(); Cursor.lockState = CursorLockMode.Locked; } private void Update() { // 鼠标控制视角 float mouseX = Input.GetAxis("Mouse X") * mouseSensitivity; float mouseY = Input.GetAxis("Mouse Y") * mouseSensitivity; pitch -= mouseY; pitch = Mathf.Clamp(pitch, -pitchLimit, pitchLimit); playerCamera.transform.localRotation = Quaternion.Euler(pitch, 0f, 0f); transform.Rotate(Vector3.up * mouseX); // 键盘控制移动 float horizontal = Input.GetAxis("Horizontal"); float vertical = Input.GetAxis("Vertical"); Vector3 move = transform.right * horizontal + transform.forward * vertical; bool isRunning = Input.GetKey(KeyCode.LeftShift); float currentSpeed = isRunning ? runSpeed : walkSpeed; if (controller.isGrounded && velocity.y < 0f) { velocity.y = -2f; } controller.Move(move * currentSpeed * Time.deltaTime); // 跳跃与重力 if (Input.GetButtonDown("Jump") && controller.isGrounded) { velocity.y = Mathf.Sqrt(jumpHeight * -2f * gravity); } velocity.y += gravity * Time.deltaTime; controller.Move(velocity * Time.deltaTime); } }这里有一个很多人会忽略的细节:重力加速度如果是 -9.81,然后直接乘 Time.deltaTime,在地表附近速度其实很小,但落到地面附近时会被 controller.isGrounded 拦截,所以要在 grounded 时给 velocity.y 一个小的负值,比如 -2f,防止角色在地面时因为速度不为 0 而不断抖动。
4.2 摄像机平滑跟随的深度原理解析
很多项目需要第三人称视角,这里我拆开讲一下摄像机跟随的原理。我见过不少新手把摄像机直接设为角色的子物体,这样省事但会有两个问题:一是角色快速旋转时摄像机被强制带动,画面会很晕;二是后续相机做插值或碰撞检测时,父子关系会带来额外复杂度。正确做法是让摄像机通过脚本在 LateUpdate 里每帧跟着目标物体的 Position,而不是把它挂成子物体。
下面是常用的平滑跟随脚本,挂在主摄像机上即可。
using UnityEngine; public class CameraFollow : MonoBehaviour { public Transform target; public Vector3 offset = new Vector3(0f, 3f, -6f); public float smoothSpeed = 5f; private void LateUpdate() { Vector3 desiredPosition = target.position + target.rotation * offset; transform.position = Vector3.Lerp(transform.position, desiredPosition, smoothSpeed * Time.deltaTime); transform.LookAt(target.position + Vector3.up * 1.5f); } }为什么必须用 LateUpdate 而不是 Update?因为在 Update 中角色的移动、物理、动画都在同一帧进行,如果你在 Update 里就读取了目标位置,那么摄像机可能要比角色早知道一帧的位置,导致画面的抖动或者轻微滞后。LateUpdate 在场景内所有 Update 执行完之后才运行,摄像机这时候读取的目标位置一定是这一帧的最终位置,所以跟随效果才平滑。这是做三维场景最容易忽略的细节之一,新手往往遇到“画面抽搐”问题,查了一圈发现就是 Update 和 LateUpdate 的时序问题。
4.3 新输入系统(Input System)的兼容处理
Unity 近期的模板默认启用新输入系统(Input System Package),这时候如果直接用 Input.GetAxis,控制台会报错“InvalidOperationException: You are trying to read Input using the UnityEngine.Input class, but you have switched active Input handling to Input System package”。处理方案有三种:一是 Player Settings 的 Active Input Handling 改成 Both,也就是新旧输入都支持;二是项目只用新输入系统,在 Project Settings > Player 里勾选,然后改用 InputSystem 的 API;三是像我们这次一样把 Active Input Handling 设为 Input Manager (Old) 或 Both,直接沿用旧 API。
考虑到简易场景快速上手的诉求,我在 Player Settings 里把 Active Input Handling 设成了 Both。这样 Input.GetAxis 和 Input.GetButtonDown 这类经典 API 可以正常工作,后续想引入新 Input System 的 PlayerInput 组件也不会报错。但我必须说明,Both 模式会有额外的性能开销,做正式项目时还是应该尽早选定一套输入方案,不要两边混着用。
5. UI 交互与动态效果扩展
5.1 用滑动条控制场景时间变化
做完角色漫游后,场景还缺最后一个能“玩”的点。我加了一条 UI 滑动条,用来控制太阳角度,从而模拟一天中的时间变化。这个功能用 UGUI 的 Slider 做非常简单,它也是热词里“unity做一个滑动条”这个需求的典型场景。
操作步骤是:在 Hierarchy 右键 UI > Slider,自动生成 Canvas 和 EventSystem,然后设置 Slider 的 Min Value 为 0,Max Value 为 360。我给方向光挂了一个脚本,接收一个 float 参数,实时改变灯光的旋转角度和颜色,脚本如下:
using UnityEngine; using UnityEngine.UI; public class TimeController : MonoBehaviour { public Light sunLight; public Slider timeSlider; public float minSunAngle = 0f; public float maxSunAngle = 360f; private void Start() { timeSlider.minValue = minSunAngle; timeSlider.maxValue = maxSunAngle; timeSlider.value = 40f; timeSlider.onValueChanged.AddListener(OnTimeChanged); OnTimeChanged(timeSlider.value); } private void OnTimeChanged(float value) { sunLight.transform.rotation = Quaternion.Euler(value, -30f, 0f); float normalized = Mathf.InverseLerp(minSunAngle, maxSunAngle, value); if (normalized < 0.25f || normalized > 0.75f) { sunLight.color = new Color(1f, 0.7f, 0.6f); RenderSettings.fogColor = new Color(0.6f, 0.5f, 0.5f); } else { sunLight.color = Color.white; RenderSettings.fogColor = new Color(0.8f, 0.8f, 0.8f); } } }这里有一个技巧:把 Slider 的事件通过 OnValueChanged 绑定,而不是在 Update 里每帧检测 Slider.value,这样只有在用户拖动时才会触发逻辑,避免每帧轮询带来的无谓开销。当时我实测下来,动态修改方向光旋转角度会有极轻微的阴影延迟感,但这个对演示场景来说完全可以接受。
5.2 可拾取物品与淡出动效实现
交互部分我放了三颗小金块,玩家在场景中走动时可以按 E 拾取,拾取后会播放一个 1.5 秒的淡出效果,然后物品销毁,同时在屏幕左上角显示一个“已拾取 X/3”的提示文本。
首先是拾取检测。我给每个金块挂上一个 BoxCollider 并勾选 Is Trigger,然后在 Player 的摄像机上写一个射线检测脚本,朝屏幕中心发射一条射线,检测命中物体的标签是否为 Pickup。下面是简化逻辑:
using UnityEngine; public class PickupController : MonoBehaviour { public Camera playerCamera; public float pickupRange = 3f; public string pickupTag = "Pickup"; public GameObject pickupHint; public Text pickupStatus; private int totalPickups = 3; private int pickedCount = 0; private void Update() { Ray ray = new Ray(playerCamera.transform.position, playerCamera.transform.forward); RaycastHit hit; if (Physics.Raycast(ray, out hit, pickupRange) && hit.collider.CompareTag(pickupTag)) { pickupHint.SetActive(true); if (Input.GetKeyDown(KeyCode.E)) { StartCoroutine(FadeOutAndDestroy(hit.collider.gameObject)); pickedCount++; pickupStatus.text = "已拾取 " + pickedCount + "/" + totalPickups; if (pickedCount >= totalPickups) { pickupStatus.text = "全部拾取完毕"; } } } else { pickupHint.SetActive(false); } } private System.Collections.IEnumerator FadeOutAndDestroy(GameObject target) { Renderer targetRenderer = target.GetComponent<Renderer>(); Color originalColor = targetRenderer.material.color; float duration = 1.5f; float elapsed = 0f; while (elapsed < duration) { elapsed += Time.deltaTime; float alpha = Mathf.Lerp(1f, 0f, elapsed / duration); targetRenderer.material.color = new Color(originalColor.r, originalColor.g, originalColor.b, alpha); // 注意:标准材质需要设置 Transparency 模式才能正确显示半透明 targetRenderer.material.SetFloat("_Mode", 3f); targetRenderer.material.SetInt("_SrcBlend", (int)UnityEngine.Rendering.BlendMode.SrcAlpha); targetRenderer.material.SetInt("_DstBlend", (int)UnityEngine.Rendering.BlendMode.OneMinusSrcAlpha); targetRenderer.material.SetInt("_ZWrite", 0f); targetRenderer.material.DisableKeyword("_ALPHATEST_ON"); targetRenderer.material.EnableKeyword("_ALPHABLEND_ON"); targetRenderer.material.renderQueue = 3000; yield return null; } Destroy(target); } }这里有一个重要提醒:在 Unity 的标准 Shader 中,材质的 Rendering Mode 如果保持 Opaque,你直接修改材质的 Alpha 值不会生效,模型不会变透明。上面代码里手动设置了 _Mode、_SrcBlend 等内部属性,把这个材质切到了 Transparent 模式,才能看到真正的半透明淡出效果。这是热词里“unity脚本控制逐渐消失”最常见的坑,很多人在这卡好久。
如果你不想写这些内部参数,也有更省事的替代方案:把材质直接设为 Legacy Shaders/Transparent/Diffuse,这是在 Inspector 里手动改,脚本只管 Alpha。但那样材质的光照效果会打折扣,因为 Transparent 不支持阴影,相当于做了一次降级处理。我建议还是保留材质原有属性,在脚本里用上述方法动态切模式,效果更完整。
5.3 扩大 UI 按钮点击范围与 Raycast 穿透问题
在做 UI 时,很多人会遇到“按钮太小,点半天点不中”的问题,也就是热词里“unity 如何扩大按钮的点击范围”。新手最容易想到的做法是把 Image 组件拉大,但这样图片会被拉伸变形。正确做法是:把一个空物体挂在按钮下,给它挂上 Image 组件,并把 Image 的 Source Image 设为空,Color 里 Alpha 设为 0,这样一个不可见的透明层就可以自由拉伸,用来扩大点击区域。
但这里有个坑:透明 Image 的 Raycast Target 属性默认是勾选的,它会拦截射线,导致它后面或者它底下的其他 UI 元素点不到。如果你在按钮上层放了一个透明层用来做遮罩,那么按钮很可能就点不了了,或者按钮和透明层同时相应事件,出现奇怪的行为。需要把不需要响应的透明层 Image 的 Raycast Target 取消勾选。
按钮点击区域扩大后,我还会在 Graphic Raycaster 的 Blocking Objects 设置里调整一下,但简易项目里不需要深入这一块。记住一个原则:UI 的射线检测是基于 Graphic 的,透明只是影响视觉,只要 Raycast Target 勾选了,它照样能拦截点击。
6. WebGL 发布与性能优化实战
6.1 从编辑器到 WebGL 的发布流程
场景做好之后,我选择发布成 WebGL,这样可以直接在浏览器里跑,也方便给朋友看和嵌入到网页。流程是:File > Build Settings,选中 WebGL,点 Switch Platform,然后点 Build。这一步要注意,Unity 第一次切到 WebGL 平台会花不少时间做转换,别以为卡死了。
WebGL 的 Player Settings 有几个参数很关键。在 Resolution and Presentation 里,Fullscreen Mode 可以选 Windowed 或 Fullscreen,Canvas 分辨率建议设为 1280x720 或者自适应,具体看你的演示目标。Publishing Settings 里的 Compression Format 建议选 Brotli,这是现在浏览器支持率最高的压缩格式,能显著减小包体积,生成速度也快。Memory Size 我设成了 256MB,这个值如果太小,复杂场景加载时容易崩溃,热词里“unity 发布 webgl 使用 idbfs 写入失败”就和这个还有浏览器存储有关。
打包部署时,最简单的方式是本地建一个 HTTP 服务来托管生成的文件夹,因为直接双击 index.html 用 file:// 协议打开,浏览器会禁止加载很多资源,画面通常是灰的或有跨域报错。部署到正式服务器要保证 HTTPS,WebGL 的很多浏览器 API,包括 IndexedDB 和全屏,在非安全上下文里会受到限制。
6.2 IndexedDB 写入失败问题排查
我在 WebGL 本地服务器上测试时,遇到了“idbfs 写入失败”的报错,日志里会提示 IndexedDB 存储失败。这个问题本质上是浏览器在尝试把 Unity 的持久化数据写入 IndexedDB(一种浏览器内置的数据库),但写入操作没有成功。
常见原因有三个。
第一是浏览器处于隐私模式或无痕模式,很多浏览器在隐私模式下禁止 IndexedDB 写入,这个只能换普通模式测。
第二是存储空间不足,Unity WebGL 默认会把存档数据存进 IndexedDB,如果之前的浏览器历史数据占满了配额,写入就会失败。处理办法是清理浏览器缓存和站点数据,或者在浏览器设置里查看该站点占用的存储空间。
第三是跨域 iframe 的限制。如果你把 WebGL 嵌入到别人的网页的 iframe 里,而父页面没有设置恰当的权限策略,IndexedDB 可能被拒绝。在实际发布场景里,这种问题需要后端设置跨域响应头,但简易演示的话直接在新标签页打开就行。
还有一个排查思路是看 Player Settings 里的 WebGL 内存大小。我遇到过 Memory Size 只能设到 128MB,但场景稍微复杂就局部崩溃的情况,调到 256MB 之后明显稳定很多。但内存也不是越大越好,因为浏览器会为页面预留这么多内存,设置值太高反而会让移动端浏览器加载过慢。
6.3 简易场景必备的优化清单
做 WebGL 发布时,性能优化不能省。这里分享几个我实测下来性价比最高的优化手段。
第一是合批。同一种材质、同一个图集的 UI 和几何体可以被 Unity 自动合批,Draw Call 数量会大幅下降。在做场景时,尽量让同样材质的物件共享同一个材质实例,不要每个物件都新建一个材质。我场景里几十棵树共用一棵树的低模和一个材质,渲染开销非常低。
第二是光照烘焙。方向光的阴影如果仍然用实时计算,室外场景树多的区域会比较吃性能。简易场景里可以不用立即上烘焙,因为场景规模小,实时阴影影响不大。但如果后面扩展到数字孪生这类大场景,一定要学会烘焙光照贴图,把实时光照关掉,效果和性能都会更好。这属于进阶话题,我这里只提出方向,不作为当前项目的必需步骤。
第三是纹理大小和压缩。如果用了外部贴图,记得在导入设置里把 Max Size 调低,比如 1024 或 512,Android 或 WebGL 平台要选择合适的压缩格式。我这次场景基本没使用贴图,全部用纯色或程序化生成的材质,省了很多空间,加载速度也很快。WebGL 打包出来的文件在本地开发服务器上首屏加载大约 5 秒左右,配合 Brotli 压缩,大小控制在 30MB 以内,这个体量对简易场景来说可以接受。
第四是使用 Profiler 面板查看瓶颈。在 Window > Analysis > Profiler 里可以实时看 CPU、GPU 和内存占用,定位到底是哪段代码或者哪个渲染项拖了后腿。我遇到过一个小细节:地形树在远处绘制了大量三角形,通过检查 Profiler 发现 Draw Call 偏高,后来调整了树的距离策略就把数据降了下来。这个习惯要在做项目的初期就养成,不要等发布到浏览器才发现顿卡。
我再补充一个关于热词“unity游戏优化”的心得:对于这种小场景,最主要的优化手段是“少放东西”和“共享材质”,而不是疯狂调整代码。场景里只有 20 棵树、3 个拾取金块、1 座小房子、1 块地形,根本没有多少可渲染对象,帧率自然全程稳定 60。很多游戏卡顿不是引擎不行,是美术资源堆得太满,这在简易场景阶段一定要克制。
7. 实操心得与后续扩展建议
做完整个简易三维场景,我最深的一个体会是:不要一上来就追求大而全,先把完整链路跑通比什么都强。之前我自己也走过弯路,最开始想做一个包含河流、山谷、植被、天气系统的复杂场景,弄了三天连地形都还没刷完,后来果断砍需求,一个 200x200 的小地形加几棵树就完成了第一版。当第一版能跑、能走、能发布的时候,那个成就感是无可替代的。
在使用 Unity 的过程中,我还发现一个容易被忽视的细节:项目文件夹的组织。创建文件夹时就要分成 Art、Scripts、Scenes、Prefabs 等几个目录,后续资源多了会好找很多。这个习惯看似简单,但真的遇到数字孪生、PLC 通信、XR 开发这些进阶需求时,好的工程结构比任何优化技巧都重要。
最后再分享一个实际开发小技巧:在做场景时,尽量把同一个功能的脚本和资源放在一起,并频繁使用 Prefab。比如树做一个 Prefab、金块做一个 Prefab、房子做一个 Prefab,改动一处就能统一更新场景中的所有实例。这次项目里房子最开始是用多个 Cube 手工摆的,后来发现门的位置不对,得一个个调整,浪费了不少时间,改成 Prefab 之后就只需要改一个预制体,所有场景实例自动同步。
如果你打算继续深入,这个简易场景可以往几个方向发展:接上串口或 PLC 数据,把滑块改成实时传感器数值展示,就能变成一个简单的数字孪生演示台;把第一人称换成第三人称,再接上 Mapbox 的大范围地图数据,就能开发城市漫游类项目;如果想跑在 VR 设备上,把摄像机换成 XR Origin,结合 Pico 这类头显,又是一个完全不同的体验方向。这些扩展都基于同样的场景搭建基本功,把这篇文章里这套流程吃透,后面换任一方向都只是换层皮,底层逻辑不会变。