自由学习记录(104)
换书Unity_Ebook_Universal-Render-Pipeline_Cookbook
NikLever/Unity-URP-Cookbook: Examples from the Unity URP Cookbook e-book
Records are of no use. You know you won't look at them. They are a buffer for thinking. If you need more, it will only make you confused. If you need less, but think, you will ignore it. But at least at this moment, you are thinking
Emissive/GI/Lighting
This render target contains the Material emissive output and baked lighting. Unity fills this field during the G-buffer Pass.
During the deferred shading pass, Unity stores lighting results in this render target.
Render target format:
-
B10G11R11_UFloatPack32, unless:
-
Quality > HDR is turned on, and the target Player platform does not support HDR.
-
Player Settings,PreserveFramebufferAlpha is true.
-
-
R16G6B16A16_SFloat, if Unity cannot use B10G11R11_UFloatPack32 because of the project settings.
If Unity cannot use these formats , it uses what the following method returns: SystemInfo.GetGraphicsFormat(DefaultFormat.HDR)
.
the Layers tell Unity to render specific meshes with a specific set of Lights
uses the culling mask system.
Deferred Rendering Path cannot use the layer system with light culling masks---shading is deferred
不同的渲染路径(或渲染流程)通常涉及不同的渲染事件,尤其是在Web浏览器中,因为不同的路径会经历不同的构建和处理步骤来将代码转换为屏幕像素。渲染路径是指浏览器将HTML、CSS和JavaScript转换为像素显示在屏幕上的步骤序列,优化该路径可以提升性能。
通常来说,前向渲染(Forward Rendering)不使用 G-Buffer,而延迟渲染(Deferred Rendering)是依赖 G-Buffer 的。前向渲染直接计算并输出颜色到最终图像,而延迟渲染则先把场景几何信息存储到 G-Buffer 中,然后再逐个光源对 G-Buffer 进行着色计算,以优化处理效率。
经常让我感觉很怪,这是引擎给我的感觉,这是设计的原因,而不是基于底层更优的方式,,他给的东西很自由松放,,render feature里居然可以又修改摄像机的参数,看不太懂
那也就是说摄像机不应该是想象中的终端,摄像机之外的控制层
可以覆盖摄像机的参数,可以设置影响的对象layer
对象layer里的设置包含的,,
two Render Objects features named Mask and SeeThrough
disable the SeeThrough option, the desk disappears
part of a filtered-out layer that’s using the Opaque Layer Mask, it’s not a part of the default render
it only gets rendered because of the Render Objects feature
Mask is set to use the Event BeforeRenderingOpaques and be filtered
so it only works on rendered pixels in the Mask Layer
render data里的filter,过滤掉层级里的物体,不参与渲染,如果把原本“批处理”的一群物体,挑出一部分来,不参与“渲染”,意思是,这个物体不会用材质去产生计算影响屏幕像素的生成,所以也不会利用这个模型上的顶点信息
但是gameobject还是存在于scene里,这些数据不参与计算,但依然存在于内存里
一般来说这样的内存占用很大吗,所以需要内存分析,,如果大了就想想可以减小吗,,性能优化不能走在太前面
“render data 里被被filter的gameobject还是存在于scene里,这些顶点数据,贴图,不参与计算,不主动在scene中删除,依然会存在于内存里”
即使 GameObject 在渲染流程中被过滤器(filter)剔除(culling),它仍然存在于场景(Scene)中,并且其相关的渲染数据(顶点数据、纹理等)仍会占用内存。这是因为:
- GameObject 和其组件的生命周期:在 Unity 等游戏引擎中,GameObject 在场景中被创建后,其生命周期和内存占用是由其自身和其包含的组件管理的。即使在渲染时被剔除,游戏对象和相关资源(如 Mesh、Texture)不会自动从内存中移除。
- 渲染剔除(Rendering Culling)的机制:渲染剔除(如 Frustum Culling、Occlusion Culling)是一种优化技术,用于防止将不在摄像机视野内或被其他物体遮挡的 GameObject 进行渲染。它只是告诉渲染管线“不绘制”这些对象,但并不会从内存中卸载它们。
- 内存占用:被剔除的 GameObject 及其包含的渲染数据(如顶点缓冲、纹理)仍然在内存中,因为引擎需要保持这些数据以备后续的渲染或访问,例如当相机移动到新的视角时。
- 显式管理:要从内存中彻底移除这些数据,需要显式地在代码中销销毁 GameObject,或者通过资源管理系统进行卸载。
Mask is set to use the Event BeforeRenderingOpaques and be filtered
render objects,首先的mask layer ,控制了
观察圆内,mask 作为layer
桌子作为see through ,
mask在渲染之前
mask有自己的shader控制效果,利用的是stencil的override
无关
不是我该考虑的,,我没有考虑的知识储备,额外使用render feature通过layer 的分开,添加两个额外的新layer,这样的性能消耗不能是 我考虑的东西
实际的消耗并没有我想象的那么巨大,只是在渲染顺序上额外加了两个pass,我知道pass不要太多就可以,性能的使用换算比是游戏的体验感,如果消耗大体验感强,这样的性能就是不应该被批判的
两个材质同时打开了stencil
stencil的存储形式也会是一张贴图
Since no override material
will use the materials defined by the objects in this Mask Layer(the lens with the MaskMat material and the StencilMask shader)
set to use the Event AfterRenderingOpaques, meaning it will apply after the Stencil buffer has been set .
set to SeeThrough and Value set to 1 . If the Value 1 is found(not equal), the pixel shouldn’t be rendered
This Render Objects pass will only read from the Stencil buffer but not write to it(因为都是keep,不动原stencil里的值)
It leaves the default render only where the lens is
changing the Compare Function to Equal to flip the result so the desk appears in the lens only .
Default - Returns X and Y values that represent the normalized Screen Position. The normalized Screen Position is the Screen Position divided by the clip space position W component. The X and Y value ranges are between 0 and 1 with position float2(0,0)
at the lower left corner of the screen.
The Z and W values aren't used in this mode, so they're always 0.
https://docs.unity3d.com/Packages/com.unity.shadergraph@14.0/manual/Screen-Position-Node.html
https://docs.unity3d.com/Packages/com.unity.shadergraph@14.0/manual/Dither-Node.html
This technique is useful for creating geometry that appears to be transparent but has the advantages of rendering as opaque, such as writing to the depth buffer or rendering using a deferred rendering path.
https://discussions.unity.com/t/undocumented-node-in-shadergraph-for-urp-2022-2-what-does-it-do/902639
Starting from version 10.0.x, URP can generate a normal texture called _CameraNormalsTexture
. To render to this texture in your custom shader, add a Pass with the name DepthNormals
. For example, refer to the implementation in Lit.shader
.
原本写的这个用的会出问题
Shader Graph 的“Graph Settings > Graph Inspector”可以直接添加 Stencil
Block(URP 12+ 支持)。
Inspector 字段 | ShaderLab Stencil 对应参数 |
---|---|
Value | Ref |
Compare Function | Comp (或 comparisonOperation ) |
Pass | Pass (passOperation ) |
Fail | Fail (failOperation ) |
Z Fail | ZFail (zFailOperation ) |
if (StencilCompare(stencilBuffer & readMask, ref & readMask) == false) {Apply(FailOperation); // Fail
} else {if (DepthTest == false) {Apply(ZFailOperation); // ZFail} else {Apply(PassOperation); // Pass}
}
Dither 节点的原理
-
这个节点(或类似自定义函数)通常会:
-
把屏幕坐标映射到某个 2D 规律图案(棋盘、Bayer matrix、蓝噪点等)。
-
根据坐标的小数部分生成一个阈值。
-
-
它只关心模式的相对重复,不需要 1:1 对应到真实像素坐标。
uint index = (uint(uv.x) % 4) * 4 + uint(uv.y) % 4;
-
uint(uv.x)
和uint(uv.y)
将浮点坐标转换为整数。 -
% 4
(模4运算)是关键。它将整个屏幕划分成无数个 4x4 的像素块。任何一个像素的x坐标模4的结果必然是0, 1, 2, 3
中的一个;y坐标同理。 -
uint(uv.x) % 4
确定了像素在 4x4 块中的列索引。 -
uint(uv.y) % 4
确定了像素在 4x4 块中的行索引。 -
col * 4 + row
是一种将二维索引(行、列)转换为一维数组索引的方式(列主序,Column-Major)。这意味着该代码会将 4x4 的拜耳矩阵在屏幕上平铺(tile)开来。
-
屏幕上像素坐标为
(10, 5)
的像素:-
x 索引:
uint(10) % 4 = 2
-
y 索引:
uint(5) % 4 = 1
-
最终索引
index = 2 * 4 + 1 = 9
。所以这个像素会使用DITHER_THRESHOLDS[9]
(即12.0 / 17.0
)作为阈值。
-
-
屏幕上像素坐标为
(14, 9)
的像素:-
x 索引:
uint(14) % 4 = 2
-
y 索引:
uint(9) % 4 = 1
-
最终索引
index = 2 * 4 + 1 = 9
。这个像素会使用相同的阈值。
-
后续,,这样的办法,,也可以处理屏幕上的像素的单独的各自的透明通道,,,
,,前提是连接到了透明上,区分成有或者没有step,可见或者不可见,
You will also notice that it takes a screen position as its second parameter, rather than a UV coordinate. That means this pattern will be applied in screen space, rather than over the surface of the object. That’s perfect for our use case!
We can wire this node directly to the Alpha Clip Threshold output, hit Save Asset, come back to the Scene View, and now if we play around with the alpha component of the Base Color
property, the object sort of looks like it’s fading out, but every pixel that is being rendered is still opaque!
https://danielilett.com/2023-12-11-tut7-5-intro-to-shader-graph-part-3/#:~:text=When%20we%20attach%20the%20material,it%20works%20in%20Shader%20Graph.
如果这些知识没有提前加载,工作记忆要一边解析新术语一边建立心像,瞬时容量超限就会“卡死”。
在 Unity 的 Shader Graph 中,有一个名为 Grass Wave 的子图,用来让草的顶点随风摆动。
拆解:
-
文件位置:Scenes > Instancing > Common > Grass Wave。
-
目标:只改动顶点的 X 坐标,依据风速 (WindSpeed)、风偏移强度 (WindShiftStrength)、风强度 (WindStrength)。
-
随机差异:用 Noise 节点 让每根草的运动不完全相同。
-
顶点处理:Y、Z 直接输出,不改;X 通过 Lerp 节点(线性插值)计算。
-
插值控制:Lerp 的 T 输入 取自草片 UV 坐标的 V 值。
-
草根 V=0 → 输出 A(原始位置)。
-
草尖 V=1 → 输出 B(加入风的偏移)。
-
一句话总结:
“按 UV 垂直坐标从根到尖,线性插值原始 X 与风扰动 X,让草尖随风摆动而草根保持固定。”
Unity - Scripting API: MeshFilter.mesh
Unity - Manual: Mesh Filter component
render a deformable mesh, use a Skinned Mesh Renderer instead. A Skinned Mesh Renderer component does not need a Mesh Filter component.
MeshFilter 中的 "Filter "是什么意思?关于 Unity 名称的小知识。_哔哩哔哩_bilibili
【硬核科普】AI是如何看见的?_哔哩哔哩_bilibili
Unity中Mesh和subMesh的区别-CSDN博客
This Renderer casts two-sided shadows. This means that single-sided objects like a plane or a quad can cast shadows, even if the light source is behind the mesh.
For Baked Global Illumination or Enlighten Realtime Global Illumination to support two-sided shadows, the material must support Double Sided Global Illumination.
Shadows Only | This Renderer casts shadows, but the Renderer itself isn’t visible. |
在不拖慢发布速度的前提下保障性能的自动化QA
-
厨师(开发者):负责烹饪美味的菜肴(编写代码、制作美术资源)。
-
美食评论家(QC - 质量检查):在菜肴出锅后,品尝它,判断它“这一份”是否好吃、有没有问题(比如菜里有头发)。这是事后的检查。
-
餐厅经理(QA - 质量保证):他/她不只关心一道菜,而是关心整个流程。比如,确保厨房卫生标准、制定标准的烹饪流程、培训厨师、采购新鲜食材。目标是从源头上保证每一道出品的菜都是高质量的。这是事前的预防。
在软件开发中:
-
QA(质量保证):是过程导向的。它关注于“我们如何能更好地构建软件,以防止缺陷产生?”它建立流程、制定标准、使用自动化工具。
-
QC(质量检查):是产品导向的。它关注于“在产品完成后,我们能发现哪些缺陷?”它主要通过测试(手动或自动化)来执行。
但在日常口语中,人们经常把 QA 泛指所有与“测试”和“保证质量”相关的工作。
Unity开发、CI工具(Jenkins)、代码提交 的语境下,“cl”有超过90%的几率指的是 ChangList(变更列表),它与Git中的“Commit”是类似的概念。
Unity Analytics 和 GameAnalytics 这类游戏分析工具为例,它们能实现数据收集与分析,背后的运行原理主要涉及数据采集、数据传输、数据存储和处理、数据分析与可视化
数据采集
游戏分析工具通过在游戏代码中集成 SDK(软件开发工具包)来实现数据采集。具体来说:
- 事件监听:SDK 会在游戏中设置各种事件监听器,比如当玩家完成一个关卡、购买一件道具、死亡一次等,这些行为都会触发相应的事件。以玩家购买道具为例,当购买操作完成时,游戏代码会调用 SDK 中预先定义好的函数,将这次购买事件相关的信息(如购买的道具名称、价格、购买时间等)记录下来。
- 性能指标监测:对于帧率、内存占用、CPU 使用率等性能数据,SDK 会与游戏引擎进行交互。在 Unity 中,它可以利用引擎提供的 API 获取每帧的渲染时间、当前内存分配情况等信息。例如,通过调用 Unity 提供的获取当前内存使用量的 API,SDK 可以定时记录游戏运行过程中的内存占用数据。
- 用户属性记录:SDK 还会收集玩家的设备信息(如设备型号、操作系统版本、屏幕分辨率)、游戏账号信息(如玩家 ID、注册时间)等用户属性数据。当玩家首次启动游戏时,SDK 就会获取这些信息并存储下来。
数据传输
采集到的数据需要传输到分析工具的服务器端进行进一步处理。
- 网络请求:SDK 会将收集到的数据封装成特定格式的数据包(通常是 JSON 格式),然后通过 HTTP 或 HTTPS 协议向服务器发送网络请求。例如,当玩家完成一局游戏后,SDK 会将这局游戏的相关数据(如游戏时长、得分、击杀数量等)打包成 JSON 数据,通过 POST 请求发送到服务器。
- 数据缓存与批量发送:为了减少网络请求次数,避免对游戏性能造成影响,SDK 会在本地对数据进行缓存。当缓存的数据量达到一定阈值或者经过一定时间间隔后,SDK 会将缓存的数据一次性批量发送到服务器。比如,每 5 分钟或者当缓存数据达到 100 条时,进行一次批量发送。
数据存储和处理
- 数据存储:服务器端接收到数据后,会将其存储在数据库中。常见的数据库类型有关系型数据库(如 MySQL)和非关系型数据库(如 MongoDB)。关系型数据库适合存储结构化数据,如玩家的账号信息;非关系型数据库则更擅长处理大量的非结构化或半结构化数据,像玩家的游戏行为日志。
- 数据清洗与预处理:在存储之前,服务器会对数据进行清洗和预处理。这包括去除重复数据、处理缺失值、对数据进行格式转换等操作。例如,如果采集到的玩家年龄数据存在空值,服务器会根据一定的规则(如用平均年龄填充)进行处理;对于格式错误的时间数据,会进行格式校正。
- 数据聚合与索引:为了方便后续的数据分析,服务器会对数据进行聚合和建立索引。比如,按日期对玩家的登录次数进行聚合,统计每天的登录总人数;对玩家 ID 建立索引,以便快速查询某个玩家的所有游戏数据。
数据分析与可视化
- 数据分析算法:服务器端会运用各种数据分析算法对存储的数据进行分析。例如,使用聚类分析算法将玩家按照游戏行为模式进行分组,找出不同类型的玩家群体;运用回归分析算法预测玩家的消费行为,评估不同因素(如游戏时长、等级)对玩家消费的影响。
- 数据可视化:分析工具会将分析结果以直观的图表形式展示给开发者,如柱状图、折线图、饼图等。比如,用折线图展示游戏上线后每天的活跃用户数量变化趋势,用饼图展示不同道具的购买比例。开发者可以通过可视化界面,快速了解游戏的各项数据指标,发现潜在问题和机会点。
Win32 API
(Windows 32-bit Application Programming Interface,32 位 Windows 应用程序编程接口)是微软为 Windows 操作系统提供的一套系统级编程接口,是 Windows 平台下开发原生应用程序的基础。它包含了成千上万的函数、数据结构和常量,开发者通过调用这些接口,可以直接与 Windows 操作系统内核交互,实现窗口创建、消息处理、文件操作、设备控制等核心功能。
为什么需要 Win32 API?
Windows 操作系统本身是一个复杂的 “黑盒”,而 Win32 API 就是这个黑盒对外提供的 “操作手册”。任何想在 Windows 上运行的程序(无论是记事本、浏览器,还是游戏),最终都需要通过 Win32 API 让操作系统 “干活”—— 比如让显示器显示窗口、让鼠标键盘的输入被程序接收、让数据写入硬盘等。
没有 Win32 API,程序就无法与 Windows 系统通信,更无法实现任何交互功能。
Win32 API 的核心作用(结合 Windows 编程场景)
在 Windows 编程中,Win32 API 的核心功能围绕 “窗口程序” 展开(Windows 是典型的 “窗口式操作系统”),主要包括:
-
窗口创建与管理通过
CreateWindowEx
函数创建窗口,ShowWindow
显示窗口,UpdateWindow
刷新窗口内容,DestroyWindow
销毁窗口等。比如记事本的主窗口、按钮、输入框,都是通过这些 API 创建的。 -
消息循环与事件处理Windows 程序是 “事件驱动” 的,所有用户操作(点击鼠标、按下键盘)都会被系统转化为 “消息”(如
WM_LBUTTONDOWN
表示鼠标左键点击)。程序通过GetMessage
获取消息,DispatchMessage
分发消息,再通过自定义的 “窗口过程”(Window Procedure)处理消息(比如点击按钮后执行某个函数)。这是 Windows 程序的核心运行机制。 -
图形绘制与显示通过
BeginPaint
、EndPaint
获取绘图上下文(DC),用LineTo
画直线、Ellipse
画椭圆、TextOut
显示文字等,实现窗口内的图形渲染。早期的 Windows 程序界面绘制全靠这些 API。 -
文件与设备操作比如
CreateFile
打开文件,ReadFile
/WriteFile
读写文件,CloseHandle
关闭文件;还有操作打印机、注册表等设备 / 系统资源的 API。 -
进程与线程管理通过
CreateProcess
启动新进程,CreateThread
创建线程,WaitForSingleObject
等待线程结束等,实现多任务和并发。
Win32 API 的特点与现状
- 底层性:它直接与操作系统内核交互,效率高,但使用复杂(需要手动管理内存、处理大量结构体和常量)。
- 历史悠久:从 Windows 95 时代的 16 位 API(Win16)演进到 32 位(Win32),再到现在的 64 位扩展(Win64,兼容 Win32),是 Windows 生态的 “基石”。
- 逐步被封装:现代开发中,很少直接手写 Win32 API 代码(太繁琐),而是通过更高层的框架(如 MFC、.NET WinForms/WPF、Qt)间接调用,这些框架本质上是对 Win32 API 的封装。但无论用什么框架,最终执行时仍会转化为 Win32 API 调用。
Pushing Unity to the Limit: Optimize Performance for AAA-Quality Gameplay
构建平台适配版本,确保实机表现
你发布的并非单一版本,而是需要针对从移动设备到主机的不同平台进行定制。每个平台都有其独特的技术限制。
-
为不同平台选择合适的图形接口(如Vulkan, Metal, DirectX)。
-
应用针对特定平台的纹理压缩与光照策略。
-
质量保证测试必须在低端硬件上进行,而不能仅限开发机。
-
Unity性能分析器、Android GPU检查器及主机专用诊断工具能提供必要的洞察,帮助平衡画质与设备性能。
(内存信息)
-
Reserved = Unity 向系统申请的内存总量。
-
Allocated = 当前实际使用的内存。
-
Mono = Mono 虚拟机堆内存(C# 脚本对象占用)。
最下面(RAM 图表)
-
颜色条对应上面三类内存(Reserved / Allocated / Mono)。
-
可以直观看出内存变化,比如 GC 回收、内存增长。
ShaderGraph 里节点的工作机制
-
UV 节点输出的是每个顶点的 UV 坐标。
-
Split → G 通道就是取 V 值(纵向 UV)。
-
Lerp:在原始位置(A)和扰动位置(B)之间,按照 V 值做插值。
-
V=0 → 100% 原始位置(根部不动)。
-
V=1 → 100% 扰动位置(尖部完全被风吹动)。
-
V 在 0~1 之间 → 线性混合,过渡。
-
换句话说,确实就是在用 UV 的 V 值做插值控制。
顶点阶段 vs 像素阶段
你担心的“中间部分是不是额外计算位移”——答案是:不会单独算。
-
Unity ShaderGraph 这种 Position 修改 节点最终会进到 Vertex Shader(顶点着色器)。
-
顶点移动是在 顶点级别完成的。
-
GPU 不会在三角形中间点再去执行一次 UV 插值来决定位移。
但是,光栅化本身就会插值:
-
顶点 shader 输出的 位置 (SV_POSITION) 被 GPU 光栅化阶段线性插值。
-
所以你看到的“中间草叶随风摆动”效果,其实就是:顶点 shader 里只算了顶点,片元的位置是由 GPU 插值出来的。
-
中间部分“跟着摆动”,是因为三角形顶点位置发生了偏移,GPU 光栅化时自然会把整片三角形拉扯开。
这么一看这张图就很精髓了
在指定大小的区域内,按照设定的密度均匀生成一片草地,并给每株草的位置添加随机偏移,避免排列过于整齐。
using System;
using UnityEngine;
using UnityEngine.Serialization;
using Random = UnityEngine.Random;namespace GPUInstancedGrass.Common
{/// <summary>/// 草地场管理器:负责生成草地网格、管理视锥体剔除,控制草的渲染范围/// </summary>public class GrassField : MonoBehaviour{/// <summary>/// 草地密度(二维网格的边长,总草数量 = 密度×密度)/// </summary>public static int GrassDensity = 250;/// <summary>/// 是否启用视锥体剔除(只渲染相机可见范围内的草)/// </summary>private static bool _performCulling;/// <summary>/// 剔除更新事件:当剔除状态变化时触发/// </summary>private static event Action UpdateCulling;/// <summary>/// 控制是否启用剔除的属性(设置时会触发剔除更新事件)/// </summary>public static bool PerformCulling{get => _performCulling;//返回底层字段 _performCulling 的当前值set{_performCulling = value;//set 访问器里,value 是一个隐含的变量,表示你正在赋给属性的新值UpdateCulling?.Invoke(); // 状态变化时通知更新剔除范围}}/// <summary>/// 草的抽象绘制器(具体绘制逻辑由子类实现,如GPU实例化)/// </summary>[SerializeField]private AbstractGrassDrawer _abstractGrassDrawer;/// <summary>/// 草地区域的大小(x=宽度,y=长度)/// </summary>[FormerlySerializedAs("_size")][SerializeField]private Vector2 _fieldSize;/// <summary>/// 每个网格单元格的大小(用于划分草地区域)/// </summary>private Vector2 _cellSize;/// <summary>/// 主相机(用于计算可见范围)/// </summary>private Camera _camera;/// <summary>/// 地面平面(y=0的平面,用于计算相机射线与地面的交点)/// </summary>private Plane _plane;/// <summary>/// 草地区域的起始位置(左下角坐标,以区域中心为原点)/// </summary>private Vector2 _startPosition;/// <summary>/// 初始化:生成草的位置并设置剔除逻辑/// </summary>private void Awake(){// 生成草地位置// 计算草地左下角起点(区域中心向左下偏移一半大小)_startPosition = -_fieldSize / 2.0f;// 计算每个单元格的尺寸(区域大小 ÷ 密度)_cellSize = new Vector2(_fieldSize.x / GrassDensity, _fieldSize.y / GrassDensity);// 创建二维数组存储所有草的位置var grassEntities = new Vector2[GrassDensity, GrassDensity];// 单元格半尺寸(用于随机偏移范围)var halfCellSize = _cellSize / 2.0f;// 遍历网格,计算每株草的位置for (var i = 0; i < grassEntities.GetLength(0); i++){for (var j = 0; j < grassEntities.GetLength(1); j++){// 基础位置 = 单元格坐标×尺寸 + 起始位置// 再叠加随机偏移(单元格内随机位置,避免整齐排列)grassEntities[i, j] =new Vector2(_cellSize.x * i + _startPosition.x, _cellSize.y * j + _startPosition.y) + new Vector2(Random.Range(-halfCellSize.x, halfCellSize.x), // x方向随机偏移Random.Range(-halfCellSize.y, halfCellSize.y)); // y方向随机偏移}}// 初始化绘制器,传入所有草的位置和区域大小_abstractGrassDrawer.Init(grassEntities, _fieldSize);// 初始化剔除相关_camera = Camera.main; // 获取主相机_plane = new Plane(Vector3.up, 0.0f); // 创建地面平面(法向量向上,y=0)UpdateCameraCells(); // 初始计算可见单元格范围// 注册剔除更新事件(当剔除状态变化时,重新计算可见范围)UpdateCulling += UpdateCameraCells;}/// <summary>/// 销毁时移除事件监听,避免内存泄漏/// </summary>private void OnDestroy(){UpdateCulling -= UpdateCameraCells;}/// <summary>/// 每帧检测相机是否移动,若移动则更新可见范围/// </summary>private void Update(){if (_camera.transform.hasChanged){UpdateCameraCells(); // 相机位置/旋转变化时,重新计算可见单元格_camera.transform.hasChanged = false; // 重置变化标记}}/// <summary>/// 从屏幕点发射射线到地面,返回射线与地面的交点(世界坐标)/// </summary>/// <param name="position">屏幕坐标(像素)</param>/// <returns>地面上的交点坐标</returns>private Vector3 Raycast(Vector3 position){var ray = _camera.ScreenPointToRay(position); // 从相机到屏幕点创建射线_plane.Raycast(ray, out var enter); // 计算射线与地面平面的交点距离return ray.GetPoint(enter); // 返回交点的世界坐标}/// <summary>/// 更新相机可见的草单元格范围,只渲染可见范围内的草(视锥体剔除)/// </summary>private void UpdateCameraCells(){// 若不启用剔除,则渲染所有草if (!PerformCulling){// 传入全范围(从0,0到密度-1,密度-1)_abstractGrassDrawer.UpdatePositions(Vector2Int.zero, new Vector2Int(GrassDensity, GrassDensity));return;}// 计算相机视口四个角在地面上的投影点(世界坐标)var bottomLeftCameraCorner = Raycast(Vector3.zero); // 屏幕左下角(0,0)var topLeftCameraCorner = Raycast(new Vector3(0.0f, Screen.height)); // 屏幕左上角(0,屏幕高)var topRightCameraCorner = Raycast(new Vector3(Screen.width, Screen.height)); // 屏幕右上角(屏幕宽,屏幕高)// 计算可见范围的左下角单元格坐标(网格索引)var bottomLeftCameraCell = new Vector2Int(// 将世界坐标转换为网格索引,并限制在0~密度-1范围内Mathf.Clamp(Mathf.FloorToInt((topLeftCameraCorner.x - _startPosition.x) / _cellSize.x), 0, GrassDensity - 1),Mathf.Clamp(Mathf.FloorToInt((bottomLeftCameraCorner.z - _startPosition.y) / _cellSize.y), 0, GrassDensity - 1));// 计算可见范围的右上角单元格坐标(网格索引)var topRightCameraCell = new Vector2Int(// +1 是为了包含边界,避免边缘草被剔除Mathf.Clamp(Mathf.FloorToInt((topRightCameraCorner.x - _startPosition.x) / _cellSize.x) + 1, 0, GrassDensity - 1),Mathf.Clamp(Mathf.FloorToInt((topRightCameraCorner.z - _startPosition.y) / _cellSize.y) + 1, 0, GrassDensity - 1));// 通知绘制器更新渲染范围(只渲染可见单元格内的草)_abstractGrassDrawer.UpdatePositions(bottomLeftCameraCell, topRightCameraCell);}}
}
整个脚本做了两件事:
- 按规则生成一片位置随机分布的草地;
- 根据相机视野动态调整渲染范围,只画能看到的草,以此提升性能。逻辑上通过 “网格划分” 简化位置计算,通过 “射线投影” 判断可见区域,最终实现高效的草地渲染管理。