最终效果
文章目录
前言
在潜行类游戏中,视野范围的可视化是塑造紧张氛围和策略玩法的核心机制之一。无论是《刺客信条》中的敌人警戒系统,还是《合金装备》的锥形视野检测,良好的视野可视化不仅能增强玩家的沉浸感,还能提供清晰的信息反馈,帮助玩家制定战术决策。
在本文中,我们将使用 Unity 实现一个 3D俯视角潜行恐怖游戏 的敌人视野系统,重点探讨如何:
动态生成锥形视野范围(使用Mesh渲染)
利用Stencil Buffer实现遮挡效果(如墙壁后的敌人不可见)
优化性能(减少不必要的射线检测)
结合光照与阴影增强恐怖氛围
我们将从基础实现开始,逐步优化,最终打造一个高效、可扩展的视野检测系统,适用于各种潜行、恐怖或战术类游戏。如果你想要学习如何在Unity中高效处理视野检测与可视化,那么本教程将为你提供完整的实现思路和代码解析。
注意:本项目里墙壁的生成,我使用的是之前的迷宫实战项目的方法,如果想了解可以查看:【unity实战】使用unity程序化随机生成3D迷宫,不过不看也没关系,因为两个功能完全是独立的。
实战
1、俯视角角色移动控制
如果对Character Controller不了解的,可以参考:【零基础入门unity游戏开发——unity3D篇】unity CharacterController 3D角色控制器最详细的使用介绍,并实现俯视角、第三人称角色控制(附项目源码)
using UnityEngine;
public class PlayerMovement : MonoBehaviour
{
CharacterController characterController;
Camera mainCamera;
float horizontal;
float vertical;
Vector3 direction;
public float speed = 5f; // 玩家移动的速度
void Start()
{
characterController = GetComponent<CharacterController>(); // 初始化角色控制器
mainCamera = Camera.main;
}
void Update()
{
SetPlayerMove();
SetPlayerRotation();
}
//处理角色移动
void SetPlayerMove()
{
horizontal = Input.GetAxis("Horizontal");
vertical = Input.GetAxis("Vertical");
direction = new Vector3(horizontal, 0, vertical);
characterController.SimpleMove(direction.normalized * speed);
}
// 处理角色旋转
void SetPlayerRotation()
{
// 将鼠标屏幕坐标转换为世界坐标,设置z值为相机y位置
Vector3 mousePos = mainCamera.ScreenToWorldPoint(new Vector3(
Input.mousePosition.x,
Input.mousePosition.y,
mainCamera.transform.position.y));
// 保持角色原有y轴高度
mousePos.y = transform.position.y;
// 让当前物体朝向鼠标世界坐标
transform.LookAt(mousePos);
}
}
挂载脚本
运行效果
2、创建角色视野,并获取视野内的目标
新增FieldOfView 脚本
using UnityEngine;
using System.Collections;
using System.Collections.Generic;
public class FieldOfView : MonoBehaviour {
public float viewRadius; // 视野半径
[Range(0,360)]
public float viewAngle; // 视野角度(0-360度)
public LayerMask targetMask; // 目标层级
public LayerMask obstacleMask; // 障碍物层级
public List<Transform> visibleTargets = new List<Transform>(); // 可见目标列表
void Start() {
// 开始协程,每隔一定时间查找目标
StartCoroutine (nameof(FindTargetsWithDelay), .2f);
}
// 延迟查找目标的协程
IEnumerator FindTargetsWithDelay(float delay) {
while (true) {
yield return new WaitForSeconds (delay); // 等待指定延迟
FindVisibleTargets (); // 查找可见目标
}
}
// 查找可见目标的方法
void FindVisibleTargets() {
visibleTargets.Clear (); // 清空可见目标列表
// 获取视野半径内的所有目标碰撞体
Collider[] targetsInViewRadius = Physics.OverlapSphere (transform.position, viewRadius, targetMask);
for (int i = 0; i < targetsInViewRadius.Length; i++) {
Transform target = targetsInViewRadius [i].transform; // 获取目标变换组件
Vector3 dirToTarget = (target.position - transform.position).normalized; // 计算到目标的归一化方向
// 检查目标是否在视野角度内
if (Vector3.Angle (transform.forward, dirToTarget) < viewAngle / 2) {
float dstToTarget = Vector3.Distance (transform.position, target.position); // 计算到目标的距离
// 检查目标之间是否有障碍物遮挡
if (!Physics.Raycast (transform.position, dirToTarget, dstToTarget, obstacleMask)) {
visibleTargets.Add (target); // 如果没有遮挡,添加到可见列表
}
}
}
}
}
我们将遮挡墙壁层级设置为Wall,可见目标设置为Target层,至于场景怎么绘制就大家自由发挥了。
挂载脚本并配置参数
效果,角色只要靠近目标,就会在visibleTargets列表追加对应目标数据
3、场景视图可视化辅助线
目前我们看不到角色具体的检测范围,可以编写脚本Editor,在场景视图绘制出可视化辅助线
修改FieldOfView代码
// 根据角度获取方向向量
public Vector3 DirFromAngle(float angleInDegrees, bool angleIsGlobal) {
if (!angleIsGlobal) {
angleInDegrees += transform.eulerAngles.y; // 如果不是全局角度,加上物体的y轴旋转
}
return new Vector3(Mathf.Sin(angleInDegrees * Mathf.Deg2Rad),0,Mathf.Cos(angleInDegrees * Mathf.Deg2Rad));
}
新增FieldOfViewEditor脚本
using UnityEngine;
using UnityEditor;
[CustomEditor (typeof (FieldOfView))]
public class FieldOfViewEditor : Editor {
void OnSceneGUI() {
FieldOfView fow = (FieldOfView)target; // 获取目标FieldOfView组件
Handles.color = Color.white; // 设置手柄颜色为白色
// 绘制视野半径的圆弧
Handles.DrawWireArc (fow.transform.position, Vector3.up, Vector3.forward, 360, fow.viewRadius);
// 计算视野角度的左右边界方向
Vector3 viewAngleA = fow.DirFromAngle (-fow.viewAngle / 2, false);
Vector3 viewAngleB = fow.DirFromAngle (fow.viewAngle / 2, false);
// 绘制视野角度的边界线
Handles.DrawLine (fow.transform.position, fow.transform.position + viewAngleA * fow.viewRadius);
Handles.DrawLine (fow.transform.position, fow.transform.position + viewAngleB * fow.viewRadius);
Handles.color = Color.red; // 设置手柄颜色为红色
// 绘制到所有可见目标的连线
foreach (Transform visibleTarget in fow.visibleTargets) {
Handles.DrawLine (fow.transform.position, visibleTarget.position);
}
}
}
效果
如果你只想显示扇形视野范围,可以使用下面的脚本
using UnityEngine;
using UnityEditor;
// FieldOfView 组件的自定义编辑器
[CustomEditor(typeof(FieldOfView))]
public class FieldOfViewEditor : Editor
{
// 在场景视图中绘制GUI
void OnSceneGUI()
{
// 获取当前编辑的目标对象(FieldOfView组件)
FieldOfView fow = (FieldOfView)target;
// 设置绘制颜色为白色
Handles.color = Color.white;
// 计算视野的左右边界方向向量
// viewAngleA: 视野左边界方向(负角度方向)
Vector3 viewAngleA = fow.DirFromAngle(-fow.viewAngle / 2, false);
// viewAngleB: 视野右边界方向(正角度方向)
Vector3 viewAngleB = fow.DirFromAngle(fow.viewAngle / 2, false);
// 绘制视野范围的扇形弧线
// 参数说明:
// - 中心点位置:fow.transform.position
// - 法线方向:Vector3.up (Y轴向上)
// - 起始方向:viewAngleA
// - 扇形角度:fow.viewAngle
// - 扇形半径:fow.viewRadius
Handles.DrawWireArc(fow.transform.position, Vector3.up, viewAngleA, fow.viewAngle, fow.viewRadius);
// 绘制从中心点到左边界的线段
Handles.DrawLine(fow.transform.position, fow.transform.position + viewAngleA * fow.viewRadius);
// 绘制从中心点到右边界的线段
Handles.DrawLine(fow.transform.position, fow.transform.position + viewAngleB * fow.viewRadius);
// 注意:这里绘制的是视野范围的示意图形
// 实际游戏中的视野检测逻辑应该在FieldOfView脚本中实现
}
}
效果
4、游戏视图显示实际视野
修改FieldOfView
[Header("视野网格参数")]
public float meshResolution; // 网格分辨率(每度有多少条射线)
public int edgeResolveIterations; // 边缘解析迭代次数(用于平滑边缘)
public float edgeDstThreshold; // 边缘距离阈值(决定何时需要细分边缘)
[Header("网格引用")]
public MeshFilter viewMeshFilter; // 用于显示视野的网格过滤器
Mesh viewMesh; // 视野网格实例
void Start()
{
// 初始化视野网格
viewMesh = new Mesh();
viewMesh.name = "View Mesh";
viewMeshFilter.mesh = viewMesh;
// 开始协程,每隔一定时间查找目标
StartCoroutine(nameof(FindTargetsWithDelay), .2f);
}
void LateUpdate()
{
DrawFieldOfView(); // 在LateUpdate中绘制视野,确保在所有更新完成后执行
}
// 绘制视野范围
void DrawFieldOfView()
{
int stepCount = Mathf.RoundToInt(viewAngle * meshResolution); // 计算需要多少步(射线)
float stepAngleSize = viewAngle / stepCount; // 每一步的角度大小
List<Vector3> viewPoints = new List<Vector3>(); // 存储所有视野边界点
ViewCastInfo oldViewCast = new ViewCastInfo();
for (int i = 0; i <= stepCount; i++)
{
// 计算当前角度(从左侧开始到右侧)
float angle = transform.eulerAngles.y - viewAngle / 2 + stepAngleSize * i;
ViewCastInfo newViewCast = ViewCast(angle); // 发射射线检测碰撞
// 从第二步开始检查边缘
if (i > 0)
{
bool edgeDstThresholdExceeded = Mathf.Abs(oldViewCast.dst - newViewCast.dst) > edgeDstThreshold;
// 如果命中状态改变或距离超过阈值,需要查找边缘
if (oldViewCast.hit != newViewCast.hit || (oldViewCast.hit && newViewCast.hit && edgeDstThresholdExceeded))
{
EdgeInfo edge = FindEdge(oldViewCast, newViewCast); // 查找精确边缘
if (edge.pointA != Vector3.zero)
{
viewPoints.Add(edge.pointA);
}
if (edge.pointB != Vector3.zero)
{
viewPoints.Add(edge.pointB);
}
}
}
viewPoints.Add(newViewCast.point); // 添加当前点
oldViewCast = newViewCast; // 保存当前点供下次比较
}
// 创建网格
int vertexCount = viewPoints.Count + 1; // 顶点数(所有边界点+中心点)
Vector3[] vertices = new Vector3[vertexCount];
int[] triangles = new int[(vertexCount - 2) * 3]; // 三角形数
vertices[0] = Vector3.zero; // 第一个顶点是中心点(本地坐标)
for (int i = 0; i < vertexCount - 1; i++)
{
// 将世界坐标转换为本地坐标
vertices[i + 1] = transform.InverseTransformPoint(viewPoints[i]);
// 构建三角形(扇形)
if (i < vertexCount - 2)
{
triangles[i * 3] = 0;
triangles[i * 3 + 1] = i + 1;
triangles[i * 3 + 2] = i + 2;
}
}
// 更新网格
viewMesh.Clear();
viewMesh.vertices = vertices;
viewMesh.triangles = triangles;
viewMesh.RecalculateNormals();
}
// 查找两个ViewCast之间的精确边缘
EdgeInfo FindEdge(ViewCastInfo minViewCast, ViewCastInfo maxViewCast)
{
float minAngle = minViewCast.angle;
float maxAngle = maxViewCast.angle;
Vector3 minPoint = Vector3.zero;
Vector3 maxPoint = Vector3.zero;
// 使用二分法迭代查找精确边缘
for (int i = 0; i < edgeResolveIterations; i++)
{
float angle = (minAngle + maxAngle) / 2;
ViewCastInfo newViewCast = ViewCast(angle);
bool edgeDstThresholdExceeded = Mathf.Abs(minViewCast.dst - newViewCast.dst) > edgeDstThreshold;
if (newViewCast.hit == minViewCast.hit && !edgeDstThresholdExceeded)
{
minAngle = angle;
minPoint = newViewCast.point;
}
else
{
maxAngle = angle;
maxPoint = newViewCast.point;
}
}
return new EdgeInfo(minPoint, maxPoint);
}
// 从指定角度发射射线检测
ViewCastInfo ViewCast(float globalAngle)
{
Vector3 dir = DirFromAngle(globalAngle, true);
RaycastHit hit;
if (Physics.Raycast(transform.position, dir, out hit, viewRadius, obstacleMask))
{
// 如果命中障碍物,返回命中信息
return new ViewCastInfo(true, hit.point, hit.distance, globalAngle);
}
else
{
// 如果没有命中,返回最大距离的点
return new ViewCastInfo(false, transform.position + dir * viewRadius, viewRadius, globalAngle);
}
}
// 根据角度获取方向向量
public Vector3 DirFromAngle(float angleInDegrees, bool angleIsGlobal)
{
if (!angleIsGlobal)
{
angleInDegrees += transform.eulerAngles.y; // 如果不是全局角度,加上物体的y轴旋转
}
return new Vector3(Mathf.Sin(angleInDegrees * Mathf.Deg2Rad), 0, Mathf.Cos(angleInDegrees * Mathf.Deg2Rad));
}
// 视野投射信息结构体
public struct ViewCastInfo {
public bool hit; // 是否命中障碍物
public Vector3 point; // 命中点或最大距离点
public float dst; // 距离
public float angle; // 角度
public ViewCastInfo(bool _hit, Vector3 _point, float _dst, float _angle) {
hit = _hit;
point = _point;
dst = _dst;
angle = _angle;
}
}
// 边缘信息结构体
public struct EdgeInfo {
public Vector3 pointA; // 边缘点A
public Vector3 pointB; // 边缘点B
public EdgeInfo(Vector3 _pointA, Vector3 _pointB) {
pointA = _pointA;
pointB = _pointB;
}
}
在角色下新增显示视野空物体,添加Mesh Filter和Mesh Renderer组件即可,Mesh Filter默认不需要有任何网格内容
配置参数
效果
5、只渲染视野内的内容
5.1 新增模板掩码着色器。
这个着色器在渲染时不会显示任何可见内容,但会在模板缓冲区中标记它所覆盖的像素为1,为后续的Stencil Object创建可见区域。
Shader "Custom/Stencil Mask"
{
Properties
{
_Color ("Color", Color) = (1,1,1,1)
_MainTex ("Albedo (RGB)", 2D) = "white" {}
_Smoothness ("Smoothness", Range(0,1)) = 0.5
_Metallic ("Metallic", Range(0,1)) = 0.0
}
SubShader
{
Tags
{
"RenderType" = "Opaque"
"RenderPipeline" = "UniversalPipeline"
"Queue" = "Geometry-100"
}
LOD 200
ColorMask 0
ZWrite Off
Stencil
{
Ref 1
Pass Replace
}
Pass
{
Name "StencilMask"
Tags { "LightMode" = "SRPDefaultUnlit" }
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
struct Attributes
{
float4 positionOS : POSITION;
float2 uv : TEXCOORD0;
};
struct Varyings
{
float2 uv : TEXCOORD0;
float4 positionHCS : SV_POSITION;
};
CBUFFER_START(UnityPerMaterial)
float4 _MainTex_ST;
half4 _Color;
half _Smoothness;
half _Metallic;
CBUFFER_END
TEXTURE2D(_MainTex);
SAMPLER(sampler_MainTex);
Varyings vert(Attributes IN)
{
Varyings OUT;
OUT.positionHCS = TransformObjectToHClip(IN.positionOS.xyz);
OUT.uv = TRANSFORM_TEX(IN.uv, _MainTex);
return OUT;
}
half4 frag(Varyings IN) : SV_Target
{
// We don't actually output any color (ColorMask 0)
// But we still sample the texture to maintain the same discard behavior
half4 color = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, IN.uv) * _Color;
return 0;
}
ENDHLSL
}
}
FallBack "Universal Render Pipeline/Unlit"
}
5.2 新增模板对象着色器。
这个着色器只会渲染在模板缓冲区中值为1的区域(即被Stencil Mask标记过的区域),实现选择性渲染效果。
Shader "Custom/Stencil Object"
{
Properties
{
_Color ("Color", Color) = (1,1,1,1)
_MainTex ("Albedo (RGB)", 2D) = "white" {}
_Smoothness ("Smoothness", Range(0,1)) = 0.5
_Metallic ("Metallic", Range(0,1)) = 0.0
}
SubShader
{
Tags
{
"RenderType" = "Opaque"
"RenderPipeline" = "UniversalPipeline"
"Queue" = "Geometry"
}
LOD 300
Stencil
{
Ref 1
Comp Equal
}
Pass
{
Name "ForwardLit"
Tags { "LightMode" = "UniversalForward" }
HLSLPROGRAM
#pragma vertex vert
#pragma fragment frag
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Core.hlsl"
#include "Packages/com.unity.render-pipelines.universal/ShaderLibrary/Lighting.hlsl"
struct Attributes
{
float4 positionOS : POSITION;
float2 uv : TEXCOORD0;
float3 normalOS : NORMAL;
};
struct Varyings
{
float2 uv : TEXCOORD0;
float4 positionHCS : SV_POSITION;
float3 normalWS : TEXCOORD1;
float3 positionWS : TEXCOORD2;
};
CBUFFER_START(UnityPerMaterial)
float4 _MainTex_ST;
half4 _Color;
half _Smoothness;
half _Metallic;
CBUFFER_END
TEXTURE2D(_MainTex);
SAMPLER(sampler_MainTex);
Varyings vert(Attributes IN)
{
Varyings OUT;
OUT.positionHCS = TransformObjectToHClip(IN.positionOS.xyz);
OUT.uv = TRANSFORM_TEX(IN.uv, _MainTex);
OUT.normalWS = TransformObjectToWorldNormal(IN.normalOS);
OUT.positionWS = TransformObjectToWorld(IN.positionOS.xyz);
return OUT;
}
half4 frag(Varyings IN) : SV_Target
{
// Sample the texture
half4 color = SAMPLE_TEXTURE2D(_MainTex, sampler_MainTex, IN.uv) * _Color;
// Lighting calculations
InputData lightingInput = (InputData)0;
lightingInput.positionWS = IN.positionWS;
lightingInput.normalWS = normalize(IN.normalWS);
lightingInput.viewDirectionWS = GetWorldSpaceNormalizeViewDir(IN.positionWS);
lightingInput.shadowCoord = TransformWorldToShadowCoord(IN.positionWS);
SurfaceData surfaceInput = (SurfaceData)0;
surfaceInput.albedo = color.rgb;
surfaceInput.alpha = color.a;
surfaceInput.metallic = _Metallic;
surfaceInput.smoothness = _Smoothness;
surfaceInput.occlusion = 1.0; // You can add occlusion texture if needed
// Apply lighting
half4 finalColor = UniversalFragmentPBR(lightingInput, surfaceInput);
return finalColor;
}
ENDHLSL
}
}
FallBack "Universal Render Pipeline/Lit"
}
5.3 将Stencil Object材质添加到墙壁、地面、可见目标上
我们可以根据Stencil Object创建几个不同颜色的材质,分别将材质附加到墙壁、地面、可见目标上,如果有需要还可以添加贴图
效果,所有物品默认都消失不见了
5.4 修改相机背景颜色为黑色
效果
5.5 给ViewMesh视野网格添加Stencil Mask材质
5.6 为了让效果更好,我们可以调大视野范围和修改视野角度
5.7 运行效果
6、阴影跟随视角移动
修改FieldOfView
public float maskCutawayDst = 0.15f; //阴影摆动幅度
//...
vertices[i + 1] = transform.InverseTransformPoint(viewPoints[i]) + Vector3.forward * maskCutawayDst;
//...
效果
7、场景视图显示所有对象
目前如果不运行游戏,场景视图不会显示任何东西,这非常不利于我们调试和搭建场景。
我们可以新增一个Plane,将Stencil Mask材质配置给它,并将它的范围扩大,覆盖整个区域,并设置y轴比相机高。
这样我们在场景视图就可以看到物品了,而不影响游戏视图。
项目源码
https://gitee.com/xiangyuphp/unity-urpfield-of-view-game
参考
https://www.youtube.com/watch?v=xkcCWqifT9M&list=PLFt_AvWsXl0dohbtVgHDNmgZV_UY7xZv7&index=3
专栏推荐
完结
好了,我是向宇
,博客地址:https://xiangyu.blog.csdn.net,如果学习过程中遇到任何问题,也欢迎你评论私信找我。
赠人玫瑰,手有余香!如果文章内容对你有所帮助,请不要吝啬你的点赞评论和关注
,你的每一次支持
都是我不断创作的最大动力。当然如果你发现了文章中存在错误
或者有更好的解决方法
,也欢迎评论私信告诉我哦!