【unity实战】用unity实现一个3D俯视角暗杀潜行恐怖类游戏,主要是实现视野范围可视化效果

发布于:2025-08-07 ⋅ 阅读:(12) ⋅ 点赞:(0)

最终效果

在这里插入图片描述

前言

在潜行类游戏中,视野范围的可视化是塑造紧张氛围和策略玩法的核心机制之一。无论是《刺客信条》中的敌人警戒系统,还是《合金装备》的锥形视野检测,良好的视野可视化不仅能增强玩家的沉浸感,还能提供清晰的信息反馈,帮助玩家制定战术决策。

在本文中,我们将使用 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


专栏推荐

地址
【unity游戏开发入门到精通——C#篇】
【unity游戏开发入门到精通——unity通用篇】
【unity游戏开发入门到精通——unity3D篇】
【unity游戏开发入门到精通——unity2D篇】
【unity实战】
【制作100个Unity游戏】
【推荐100个unity插件】
【实现100个unity特效】
【unity框架/工具集开发】
【unity游戏开发——模型篇】
【unity游戏开发——InputSystem】
【unity游戏开发——Animator动画】
【unity游戏开发——UGUI】
【unity游戏开发——联网篇】
【unity游戏开发——优化篇】
【unity游戏开发——shader篇】
【unity游戏开发——编辑器扩展】
【unity游戏开发——热更新】
【unity游戏开发——网络】

完结

好了,我是向宇,博客地址:https://xiangyu.blog.csdn.net,如果学习过程中遇到任何问题,也欢迎你评论私信找我。

赠人玫瑰,手有余香!如果文章内容对你有所帮助,请不要吝啬你的点赞评论和关注,你的每一次支持都是我不断创作的最大动力。当然如果你发现了文章中存在错误或者有更好的解决方法,也欢迎评论私信告诉我哦!
在这里插入图片描述


网站公告

今日签到

点亮在社区的每一天
去签到