此内容是为使用Unity开发的简单课程作业项目的实验报告,留作日后参考。
1 场景、光照与昼夜
为了使虚拟环境呈现出如现实中户外场景一般的实现效果,我们需要为场景提供不同昼夜条件下的背景和光照效果。在Unity场景中,场景的背景效果由“天空盒”的材质提供,有点光源、平行光源等不同呈现效果的光源提供光照。在这里,我们准备了三种具有不同天空的天空盒材质,来分别呈现日间、阴天和黑夜的天空背景效果,使用一个平行光源物体模拟太阳光照,使用多个点光源来为阴天和黑夜下的场景提供照明。通过脚本调整平行光源强度以模拟不同昼夜条件下的太阳光强。
在我们搭建出的虚拟世界场景中,玩家(用户)本质上是在通过场景中被称为“摄像机”的物体从特定角度来观察特定范围内的虚拟世界。因此,我们为摄像机添加自定义天空盒,并通过脚本调整每个摄像机的天空盒材质,以切换玩家所看到的场景昼夜背景。
public void changeSkybox (AmbientType skyNumber) {
for (int i = 0; i < cameras.Length; i++)
{//对为脚本选定的每个摄像机切换天空盒材质
cameras[i].gameObject.GetComponent<Skybox>().material = skyboxMaterials[(int)skyNumber];
}
if (skyNumber == AmbientType.AMBIENT_SKYBOX_SUNNY)
{
lightObject.GetComponent<Light>().intensity = 1.3f;//调整平行光源的强度,模拟不同光强的太阳光。
spotLightObject.SetActive(false);//调整了一个位于玩家摄像机位置的点光源,为黑夜下玩家视角提供照明
changeShadow(true);//调整阴影效果
}
else if (skyNumber == AmbientType.AMBIENT_SKYBOX_CLOUD)
{
lightObject.GetComponent<Light>().intensity = 0.4f;
spotLightObject.SetActive(false);
changeShadow(true);
}
else if (skyNumber == AmbientType.AMBIENT_SKYBOX_NIGHT)
{
lightObject.GetComponent<Light>().intensity = 0.1f;
spotLightObject.SetActive(true);
changeShadow(false);
}
}
2 天气效果与粒子系统
通过Unity的粒子系统,我们可以制作粒子动画来呈现天气效果,以及为场景添加表现更生动的物体。
由于玩家实质上通过摄像机观察世界,我们只需要在摄像机物体视角的位置布置上相应的粒子系统动画,便可以让玩家观察到的场景呈现出相应的天气效果。
通过在粒子系统中调整粒子的材质、大小、密度、速度等属性,可以使粒子系统呈现出不同的粒子动画效果。
3 动画与动画器应用
我们在Adobe旗下的模型动画素材库中下载了所需的人物动画,得到包含人物动画信息的.fbx文件。
将带有动画信息的模型导入,提取出其中的动画信息。创建一个动画控制器,在控制器中定义几种我们准备为加入的动作状态,为动作状态添加相应的动画。并定义参数对动作状态的转换进行控制。
在资产列表中选中导入的角色模型,将Rig项中的动画类型设置为人形,Unity会根据人物模型的骨骼生成用于应用人形模型动画的Avatar:
在场景中添加角色。然后为场景中的角色模型添加动画器,控制器选择之前创建的动画控制器,Avatar选择该角色模型的Avatar,对其他人物的模型同样处理(控制器均使用同一个,Avatar选择与角色对应的Avatar)。
完成角色模型动画控制器的设置后,应用在角色模型上的脚本就可以通过控制动画控制器定义的参数来控制角色要应用的动画,并驱动角色的骨骼应用相应的动作。以如下角色移动动画控制函数为例,通过将此函数在控制角色移动的函数和每帧调用的Update()函数中调用,脚本可以检测角色当前速度,根据角色速度控制running参数的值,以控制角色在跑步与闲置两个动作状态之间切换。
private void HandleMovementAnimation()
{
// 获取当前速度
float speed = agent.velocity.magnitude;
// 如果角色正在移动
if (speed > 1f) // 设置一个小阈值避免误触
{
animator.SetBool("running", true); // 设置running为true
}
else
{
animator.SetBool("running", false); // 设置running为false
}
}
4 路径导航
使用Unity的AI Navigation包,在包括了所有场景中行走部分的物体的父物体上建立导航网格面(NavMeshSurface),引擎可以在当前场景中根据对象物体(称为导航代理Agent)的高度、半径、步高和爬坡坡度,在场景物体的行走面上烘焙导航网格,实现人物对象的自动导航与路径规划。
在需要进行导航移动的角色物体上添加导航网格代理(NavMeshAgent),使用同样的代理类型,根据需要进行配置:
完成导航网格面和导航代理的配置后,可以在脚本中调用agent相关方法,控制角色在导航网格面上进行导航移动,如在角色物体上附加脚本并调用函数agent.SetDestination(targetDestination);可以控制角色前往指定坐标,角色前往指定坐标的寻路可以自动导航完成。
5 角色操作
为直观体现人物寻路导航和人群移动模拟的目标功能,我们设计实现了WASD控制移动、鼠标点击位置移动和根据规划好的路径点逐个进行寻路移动三种角色操作方式。
实现WASD控制移动的核心函数如下,此函数被加入每帧调用的Update()函数以检测用户的键盘输入,在获取到WASD相应按键正在被按下时在相应方向上提供速度分量。函数中同时考虑了角色根据速度变化进行动作切换和角色朝向、空中速度、重力等,使角色移动尽可能自然。
private void HandleMovement()
{
float Horizontal = 0;
float Vertical = 0;
//使用WASD 键进行前后左右平移
if (Input.GetKey(KeyCode.A)) Horizontal = -1f;
if (Input.GetKey(KeyCode.D)) Horizontal = 1f;
if (Input.GetKey(KeyCode.W)) Vertical = 1f;
if (Input.GetKey(KeyCode.S)) Vertical = -1f;
Vector3 direction = new Vector3(Horizontal, 0, Vertical);
if (direction.magnitude > 1f)
{
direction.Normalize(); // 防止移动速度超过设定值
}
// 将方向转换为相对于摄像机的方向
direction = Camera.main.transform.TransformDirection(direction);
direction.y = 0; // 忽略 Y 轴方向的影响
if (characterController.isGrounded)
{
moveDirection = direction * moveSpeed;
// 根据移动状态更新动画
HandleMovementAnimation();
}
else
{
// 在空中保持水平方向速度
moveDirection.x = direction.x * moveSpeed;
moveDirection.z = direction.z * moveSpeed;
}
// 应用重力
moveDirection.y -= gravity * Time.deltaTime;
// 执行移动
characterController.Move(moveDirection * Time.deltaTime);
// 面向移动方向
if (direction.magnitude > 0)
{
transform.rotation = Quaternion.LookRotation(direction);
}
}
实现鼠标点击移动的核心函数如下,函数在脚本的Update()函数中调用,检测用户的鼠标点击输入,检测到用户鼠标点击时,通过射线检测获取到最接近的坐标位置若此位置在NavMeshSurface上,则将坐标作为角色Agent代理的移动目标。
private void MouseMovement ()
{
// 将鼠标坐标从屏幕坐标转换为 GUI 坐标
Vector2 mousePosition = new Vector2(Input.mousePosition.x, Screen.height - Input.mousePosition.y);
// 检测鼠标是否在按钮区域内
if (buttonRect.Contains(mousePosition))
{
return; // 如果鼠标悬停在按钮上,则不处理其他鼠标事件
}
// 获取 PlayerController 脚本的引用
SkyController controller = gamecontroller.GetComponent<SkyController>();
// 检查鼠标左键是否被按下,(左键移动不在GUI菜单被展开时响应)
if (controller.menuVisible == false && Input.GetMouseButtonDown(0))
{
// 创建一个射线
Ray ray = Camera.main.ScreenPointToRay(Input.mousePosition); //从鼠标点击位置发出
RaycastHit hit;
// 如果射线与地面相交
if (Physics.Raycast(ray, out hit))
{
// 获取点击位置
Vector3 targetPosition = hit.point;
// 确保点击在 NavMesh 上
if (!hit.collider.CompareTag("Ocean"))
{
// 设置角色的目标位置为射线的碰撞点
// 设置NavMeshAgent的目标位置,使角色前往点击位置
// 为防止人数较多时多个角色互相推挤,设置一个目标地点偏移量
Vector3 randomOffset = new Vector3(Random.Range(-1f, 1f), 0f, Random.Range(-1f, 1f));
Vector3 newDestination = hit.point + randomOffset;
agent.SetDestination(newDestination);
}
}
}
HandleMovementAnimation(); // 更新移动相关动画
}
实现路径点寻路移动的核心函数如下,脚本维护了一个Vector3数组,存放了一组设定的路径点坐标,当运行这一脚本时,角色会通过NavMesh寻路,按照相应顺序向这些路径点依次前进
private void MoveAlongWaypoints()
{
// 只有当导航代理正在移动时才检查
if (agent.remainingDistance <= agent.stoppingDistance)
{
print(isAtWaypoint);
if(isAtWaypoint == false)
{
// 到达当前目标点,选择下一个目标点
isAtWaypoint = true;
if (currentWaypointIndex < waypoints.Count - 1)
{
currentWaypointIndex++;
Vector3 randomOffset = new Vector3(Random.Range(-1f, 1f), 0f, Random.Range(-1f, 1f));
Vector3 newDestination = waypoints[currentWaypointIndex] + randomOffset;
agent.SetDestination(newDestination);
print(currentWaypointIndex);
}
else
{
// 到达最后一个目标点,停止移动
agent.isStopped = true;
animator.SetBool("running", false); // 如果有跑步动画,可以在此停止跑步动画
}
}
}
else
{
isAtWaypoint = false;
}
}
6 摄像机操作与图像捕获
摄像机为玩家提供直接观察虚拟世界的视角。我们在场景布置了三个不同的摄像机,置于一个命名为Cameras的物体下,分别提供自由移动视角、固定路径自动移动视角和主操作角色第三人称移动视角。
其中,自由移动摄像机物体使用的脚本文件的Update()函数会每帧监听用户的鼠标与键盘输入,允许用户通过方向键控制摄像机视角的移动,通过按住鼠标右键拖动旋转视角,通过按住中键或Alt键同时拖动鼠标来移动视角,通过鼠标滚轮缩放视角。
private void Update()
{
HandleRotation();
HandleMovement();
HandleZoom();
}
private void HandleRotation()
{
if (Input.GetMouseButton(1)) // 右键旋转
{
float horizontal = Input.GetAxis("Mouse X");
float vertical = Input.GetAxis("Mouse Y");
// 计算旋转
transform.Rotate(Vector3.up, horizontal * rotationSpeed, Space.World);
transform.Rotate(Vector3.left, vertical * rotationSpeed, Space.Self);
}
}
private void HandleMovement()
{
// 按住中键或 Alt 键平移
if (Input.GetMouseButton(2) || Input.GetKey(KeyCode.LeftAlt))
{
float moveX = Input.GetAxis("Mouse X");
float moveY = Input.GetAxis("Mouse Y");
Vector3 move = new Vector3(moveX, moveY, 0);
transform.Translate(move * movementSpeed * Time.deltaTime, Space.Self);
}
float moveHorizontal = 0;
float moveVertical = 0;
// 使用方向键 / 不使用WASD 键进行前后左右平移
//moveHorizontal = Input.GetAxis("Horizontal");
//moveVertical = Input.GetAxis("Vertical");
//if (Input.GetKey(KeyCode.W) || Input.GetKey(KeyCode.A) || Input.GetKey(KeyCode.S) || Input.GetKey(KeyCode.D))
//{
// moveHorizontal = 0f;
// moveVertical = 0f;
//}
if (Input.GetKey(KeyCode.LeftArrow)) moveHorizontal = -1f;
if (Input.GetKey(KeyCode.RightArrow)) moveHorizontal = 1f;
if (Input.GetKey(KeyCode.UpArrow)) moveVertical = 1f;
if (Input.GetKey(KeyCode.DownArrow)) moveVertical = -1f;
// 计算相对于当前摄像机视角的移动方向
Vector3 forward = Camera.main.transform.forward; // 摄像机的前向方向
Vector3 right = Camera.main.transform.right; // 摄像机的右向方向
//Vector3 moveDirection = new Vector3(moveHorizontal, 0, moveVertical);
// 将水平和垂直输入转化为相对摄像机的移动方向
Vector3 moveDirection = forward * moveVertical + right * moveHorizontal;
transform.Translate(moveDirection * movementSpeed * Time.deltaTime, Space.World);
}
private void HandleZoom()
{
// 鼠标滚轮缩放
float scroll = Input.GetAxis("Mouse ScrollWheel");
if (scroll != 0)
{
currentZoom -= scroll * zoomSpeed * 100f * Time.deltaTime;
currentZoom = Mathf.Clamp(currentZoom, minZoom, maxZoom);
}
// 更新摄像机的距离
Camera.main.fieldOfView = Mathf.Lerp(Camera.main.fieldOfView, currentZoom, Time.deltaTime * 10);
}
固定路径自动移动摄像机不响应用户操作,只使用了一个动画器使视角提供沿着固定路径移动。
主操作角色第三人称移动视角摄像机的脚本用于实现玩家操控角色时的第三人称角色观察视角。需要指定主操作角色,会每帧获取角色物体的位置和速度,使摄像机跟随主操控角色进行移动,摄像机位置位置保持在主操控角色物体旁边。同时也会每帧获取用户的鼠标移动输入,允许用户通过拖动鼠标旋转视角。当摄像机位置与场景物体发生冲突时获取碰撞点信息,重新计算位置。
void Start()
{
if (target != null)
{
gameObject.layer = target.gameObject.layer = 2;
Cursor.lockState = CursorLockMode.Locked;
Cursor.visible = false;
}
}
private void LateUpdate()
{
if (target != null)
{
xMouse += Input.GetAxis("Mouse X") * linearSpeed;
yMouse -= Input.GetAxis("Mouse Y") * linearSpeed;
yMouse = Mathf.Clamp(yMouse, -30, 80);//限制垂直方向的角度
distanceFromTarget -= Input.GetAxis("Mouse ScrollWheel") * 10;//拉近或拉远人物镜头
distanceFromTarget = Mathf.Clamp(distanceFromTarget, 2, 15);
//用户操作的鼠标旋转和相机旋转的切换
Quaternion targetRotation = Quaternion.Euler(yMouse, xMouse, 0);
//相机移动的目标位置
CamCheck(out RaycastHit hit, out float dis);
Vector3 targetPostion = target.position + targetRotation * new Vector3(xOffset, 0, -dis) + target.GetComponent<CapsuleCollider>().center * 1.75f;
//对速度进行插值,使之更有冲刺感
speed = target.GetComponent<Rigidbody>().velocity.magnitude > 0.1f ?
Mathf.Lerp(speed, 7, 5f * Time.deltaTime) : Mathf.Lerp(speed, 25, 5f * Time.deltaTime);
//使用Lerp插值,实现相机的跟随
transform.position = Vector3.Lerp(transform.position, targetPostion, Time.deltaTime * speed);
transform.rotation = Quaternion.Lerp(transform.rotation, targetRotation, Time.deltaTime * 25f);
}
}
private void CamCheck(out RaycastHit raycast, out float dis)
{
//用来检测碰撞
#if UNITY_EDITOR
Debug.DrawLine(target.position + target.GetComponent<CapsuleCollider>().center * 1.75f,
target.position + target.GetComponent<CapsuleCollider>().center * 1.75f +
(transform.position - target.position - target.GetComponent<CapsuleCollider>().center * 1.75f).normalized * distanceFromTarget
, Color.blue);
#endif
//如果碰撞到物体,获取碰撞点信息,重新计算距离,否则返回默认值
if (Physics.Raycast(target.position + target.GetComponent<CapsuleCollider>().center * 1.75f,
(transform.position - target.position - target.GetComponent<CapsuleCollider>().center * 1.75f).normalized, out raycast,
distanceFromTarget, ~Physics.IgnoreRaycastLayer))
{
dis = Vector3.Distance(target.position + target.GetComponent<CapsuleCollider>().center * 1.75f + new Vector3(xOffset, 0, 0), raycast.point);
}
else
dis = distanceFromTarget;
}
实现图像捕获功能的核心函数如下,脚本会捕获当前摄像机所呈现的图像,将渲染结果存储为PNG文件,根据截图时间格式化命名并输出到指定路径。
private void CaptureImage()
{
// 创建一个 RenderTexture 作为相机的渲染目标
RenderTexture renderTexture = new RenderTexture(width, height, 24);
captureCamera.targetTexture = renderTexture;
// 创建一个 Texture2D 用于存储捕获的图像
Texture2D screenShot = new Texture2D(width, height, TextureFormat.RGB24, false);
// 将渲染结果读取到 Texture2D 中
captureCamera.Render();
RenderTexture.active = renderTexture;
screenShot.ReadPixels(new Rect(0, 0, width, height), 0, 0);
screenShot.Apply();
// 重置渲染目标
captureCamera.targetTexture = null;
RenderTexture.active = null;
Destroy(renderTexture);
// 保存捕获的图像为 PNG 文件
SaveTextureToFile(screenShot);
}
private void SaveTextureToFile(Texture2D texture)
{
byte[] bytes = texture.EncodeToPNG();
// 获取当前时间
DateTime currentTime = DateTime.Now;
// 格式化为 YYYY/MM/DD/hh:mm:ss 格式
string formattedTime = currentTime.ToString("yyyy-MM-dd-HH-mm-ss");
string save = savePath + "Screenshot-" + formattedTime + ".png";
File.WriteAllBytes(save, bytes); // 保存为 PNG 文件
Debug.Log("Image saved to: " + save);
}
将图像捕获功能脚本和摄像机切换脚本添加到一个物体上,摄像机切换脚本允许用户通过快捷键在三个摄像机之间进行切换。
7 GUI控制
为了提高演示中的直观性,我们在游戏操作界面中使用了一些GUI来控制部分功能的启用,包括昼夜信誉天气效果的控制和对主操作角色及人群角色操控模式的分别调整,如下图所示。
GUI的定义在下图中名为SkyController的脚本中,此脚本和控制昼夜效果、控制天气效果的脚本置于同一物体下,便于互相调用脚本内部函数,统一实现控制功能。
GUI的定义函数如下,
void OnGUI()
{
if (menuVisible == true)
{
GUI.BeginGroup(new Rect(50, 50, Screen.width - 100, 270));
GUI.Box(new Rect(0, 0, Screen.width - 100, 270), "Control Menu");
if (GUI.Button(new Rect(Screen.width - 100 - 50, 10, 40, 40), "X"))
{
menuVisible = false;
}
// ---------- Sky Control ----------
GUI.Label(new Rect(20, 40, 100, 30), "Sky Control");
if (GUI.Button(new Rect(20, 60, 80, 40), "Sunny"))
{
this.GetComponent<AmbientController>().changeSkybox(AmbientController.AmbientType.AMBIENT_SKYBOX_SUNNY);
}
if (GUI.Button(new Rect(110, 60, 80, 40), "Cloud"))
{
this.GetComponent<AmbientController>().changeSkybox(AmbientController.AmbientType.AMBIENT_SKYBOX_CLOUD);
}
if (GUI.Button(new Rect(200, 60, 80, 40), "Night"))
{
this.GetComponent<AmbientController>().changeSkybox(AmbientController.AmbientType.AMBIENT_SKYBOX_NIGHT);
}
// ---------- Effect Control ----------
GUI.Label(new Rect(20, 180, 100, 30), "Effect Control");
if (GUI.Button(new Rect(20, 200, 80, 40), "None"))
{
this.GetComponent<AmbientController>().changeParticle(AmbientController.ParticleType.PARTICLE_NONE);
}
if (GUI.Button(new Rect(110, 200, 80, 40), "Wind"))
{
this.GetComponent<AmbientController>().changeParticle(AmbientController.ParticleType.PARTICLE_WIND);
}
if (GUI.Button(new Rect(200, 200, 80, 40), "Rain"))
{
this.GetComponent<AmbientController>().changeParticle(AmbientController.ParticleType.PARTICLE_RAIN);
}
// ---------- Main Chara Controller Control ----------
GUI.Label(new Rect(320, 40, 200, 30), "Main Chara Controller");
if (GUI.Button(new Rect(320, 60, 150, 40), "WASD control"))
{
player.GetComponent<CharaController>().enabled = true;
player.GetComponent<MouseController>().enabled = false;
player.GetComponent<NavMeshAgent>().isStopped = true;
player.GetComponent<AIController>().enabled = false;
}
if (GUI.Button(new Rect(110+300+70, 60, 150, 40), "Mouse click control"))
{
player.GetComponent<CharaController>().enabled = false;
player.GetComponent<MouseController>().enabled = true;
player.GetComponent<NavMeshAgent>().isStopped = false;
player.GetComponent<AIController>().enabled = false;
}
if (GUI.Button(new Rect(320, 110, 80, 40), "AI walk"))
{
player.GetComponent<CharaController>().enabled = false;
player.GetComponent<MouseController>().enabled = false;
player.GetComponent<NavMeshAgent>().isStopped = false;
player.GetComponent<AIController>().enabled = true;
}
// ---------- Group Charas Controller Control ----------
GUI.Label(new Rect(670, 40, 200, 30), "Group Chara Controller");
if (GUI.Button(new Rect(320+350, 60, 150, 40), "WASD control"))
{
for (int i = 0; i < groupchara.Length; i++)
{
groupchara[i].GetComponent<CharaController>().enabled = true;
groupchara[i].GetComponent<MouseController>().enabled = false;
groupchara[i].GetComponent<NavMeshAgent>().isStopped = true;
groupchara[i].GetComponent<AIController>().enabled = false;
}
}
if (GUI.Button(new Rect(110 + 300 + 70+350, 60, 150, 40), "Mouse click control"))
{
for (int i = 0; i < groupchara.Length; i++)
{
groupchara[i].GetComponent<CharaController>().enabled = false;
groupchara[i].GetComponent<MouseController>().enabled = true;
groupchara[i].GetComponent<NavMeshAgent>().isStopped = false;
groupchara[i].GetComponent<AIController>().enabled = false;
}
}
if (GUI.Button(new Rect(200 + 300 + 140+350, 60, 80, 40), "AI walk"))
{
for (int i = 0; i < groupchara.Length; i++)
{
groupchara[i].GetComponent<CharaController>().enabled = false;
groupchara[i].GetComponent<MouseController>().enabled = false;
groupchara[i].GetComponent<NavMeshAgent>().isStopped = false;
groupchara[i].GetComponent<AIController>().enabled = true;
}
}
GUI.EndGroup();
}
else
{
// ---------- Menu Button ----------
if (GUI.Button(new Rect(Screen.width - 120, 20, 100, 40), "Menu"))
{
menuVisible = true;
}
}
}
脚本定义了GUI的显示形式,并建立GUI控件和函数调用的关系。其中对昼夜背景和天气效果的控制通过调用另一脚本内操作相应物体属性的函数实现,对角色操控模式的控制通过直接对指定的角色所使用的操作脚本的活动状态进行切换实现。