[{"content":"引入 流场寻路是一种基于向量场寻路的群体寻路算法。通过在网格中计算向量场，单位根据脚下向量场的方向移动即可渐渐向目标移动。因为所有要移动的单位共享一个向量场，计算一次就可以给所以单位导航，实现了群体寻路的低开销。\n可以想象一个高低不同的沙盘，往盘中到水产生了流动（向量场），沙盘上棋子自然会向底处（目标）流去。\n构建数据结构 先使用二维数组构建一个网格数据结构。要包含以下数据：\n表示该网格是否可以通行（该项目中只有障碍物情况）。 该网格到目标的最短路径代价，不可通行设置为MAX值。 该网格包含的向量，指向代价最小的邻接位置。 1 2 3 4 5 6 7 8 9 10 11 12 13 //使用ScriptableObject以在编辑器下预计算并保存障碍数据 public class FieldGridSO : ScriptableObject { public Vector2Int GridSize = new Vector2Int(150, 100); public float CellSize = 1f; public Vector2 origin = Vector2.zero;//网格原点 private bool[,] _obstacleField; private int[,] _costField; private Vector2[,] _flowField; //TODO } 标记障碍数据 可以使用unity的Physics.CheckBox检测是否和场景中物体有碰撞。最直接的方式是逐个网格检查后在obstacleField中标记。但是这样在100x100网格下就要做1万次检测了，静态场景无所谓，但是当场景会变化，要更新障碍信息时会非常消耗时间。\n可以采用类似四叉树检测的方式，先在整个网格区域进行检查，没有碰撞全部标记为无障碍完成计算，有碰撞就将网格四等分，然后在每个等分的网格递归上述步骤，直到最终划分的网格小于等于每个网格的尺寸CellSize。\n以下为具体实现：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 //......续之前代码 //使用栈数据结构防止栈溢出。 //存储参数数据。 struct BakeObstacleFieldArgs { public Vector2 center; public Vector2 halfSize; public BakeObstacleFieldArgs(Vector2 center, Vector2 halfSize) { this.center = center; this.halfSize = halfSize; } } private void BakeObstacleField(Vector2 center, Vector2 halfSize, string obstacleMask) { //用于记录时间。 Stopwatch sw = new Stopwatch(); sw.Start(); if (_obstacleField == null) { _obstacleField = new bool[GridSize.x, GridSize.y]; } else { Array.Clear(_obstacleField, 0, _obstacleField.Length); } Stack\u0026lt;BakeObstacleFieldArgs\u0026gt; _argsStack = new Stack\u0026lt;BakeObstacleFieldArgs\u0026gt;(); BakeObstacleFieldArgs args = new BakeObstacleFieldArgs(center, halfSize); _argsStack.Push(args); while (_argsStack.Count \u0026gt; 0) { args = _argsStack.Pop(); bool isHit = Physics.CheckBox( new Vector3(args.center.x, args.center.y, 0f), new Vector3(args.halfSize.x, args.halfSize.y, 1f), quaternion.identity, 1 \u0026lt;\u0026lt; LayerMask.NameToLayer(obstacleMask) ); //没有碰撞情况下，退出 if (!isHit) { continue; } //划分大小小于等于CellSize情况下，退出 if (args.halfSize.x \u0026lt;= CellSize * 0.5f \u0026amp;\u0026amp; args.halfSize.y \u0026lt;= CellSize * 0.5f) { //计算对应数组位置 Vector2Int intPos = Vector2Int.zero; int x = (int)Math.Floor((args.center.x - origin.x) / CellSize); int y = (int)Math.Floor((args.center.y - origin.y) / CellSize); if (x \u0026gt;= 0 \u0026amp;\u0026amp; x \u0026lt; GridSize.x \u0026amp;\u0026amp; y \u0026gt;= 0 \u0026amp;\u0026amp; y \u0026lt; GridSize.y) { intPos = new Vector2Int(x, y); _obstacleField[intPos.x, intPos.y] = true; } continue; } //划分空间 Vector2 newHalfSize = new Vector2(0.5f * args.halfSize.x, 0.5f * args.halfSize.y); Vector2[] newCenters = { new Vector2(args.center.x - newHalfSize.x, args.center.y - newHalfSize.y), new Vector2(args.center.x + newHalfSize.x, args.center.y + newHalfSize.y), new Vector2(args.center.x - newHalfSize.x, args.center.y + newHalfSize.y), new Vector2(args.center.x + newHalfSize.x, args.center.y - newHalfSize.y), }; foreach (var newCenter in newCenters) { //入栈 BakeObstacleFieldArgs newArgs = new BakeObstacleFieldArgs(newCenter, newHalfSize); _argsStack.Push(newArgs); } } sw.Stop(); Debug.Log(sw.ElapsedMilliseconds + \u0026#34;ms\u0026#34;); } 我又另外写了个直接逐网格检测的函数就行对比。\n可以看到，在1000x1000网格下，直接检测花了351ms,而四叉树优化版本花了12ms。提升了将近96.6%。\n计算代价图 接下来要计算每个网格到目标的代价。可以从目标点开始，使用BFS广度优先遍历张网格。目标点代价为0，接下来访问其邻接节点，代价为之前访问的节点代价加1，整个网格遍历完成后即完成代价图的计算。\n以下是实现：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 //......续之前代码 private void CalculateCostField(Vector2Int target) { if (_costField == null) { _costField = new int[GridSize.x, GridSize.y]; } bool[,] isVisited = new bool[GridSize.x, GridSize.y]; Vector2Int[] dirs = { new Vector2Int(0, -1), // up new Vector2Int(0, 1), // down new Vector2Int(-1, 0), // left new Vector2Int(1, 0), // right new Vector2Int(1, 1), new Vector2Int(-1, 1), new Vector2Int(-1, -1), new Vector2Int(1, -1) }; Queue\u0026lt;int[]\u0026gt; queue = new Queue\u0026lt;int[]\u0026gt;(); queue.Enqueue(new[] { target.x, target.y , 0}); isVisited[target.x, target.y] = true; while (queue.Count != 0) { Int32[] current = queue.Dequeue(); int x = current[0]; int y = current[1]; int lCost = current[2]; _costField[x, y] = lCost; foreach (var dir in dirs) { int x1 = x + dir.x; int y1 = y + dir.y; if (x1 \u0026lt; GridSize.x \u0026amp;\u0026amp; x1 \u0026gt;= 0 \u0026amp;\u0026amp; y1 \u0026lt; GridSize.y \u0026amp;\u0026amp; y1 \u0026gt;= 0 \u0026amp;\u0026amp; isVisited[x1, y1] == false) { isVisited[x1, y1] = true; if (_obstacleField[x1, y1] == true) { queue.Enqueue(new[] { x1, y1, int.MaxValue}); } else { queue.Enqueue(new[] { x1, y1, lCost + 1}); } } } } } 因为只是涉及数组访问和数值计算，速度还是挺快，这里先不考虑优化。\n计算向量图 向量场的计算非常简单，只要遍历所有网格，找到网格周围代价最小的邻接网格，再计算指向它的单位向量即可。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 //......续之前代码 private void CalculateFlowField() { Stopwatch ss = new Stopwatch(); ss.Start(); _flowField = new Vector2[GridSize.x, GridSize.y]; Vector2Int[] dirs = { new Vector2Int(0, -1),//up new Vector2Int(0, 1),// down new Vector2Int(-1, 0), new Vector2Int(1, 0), new Vector2Int(1, 1), new Vector2Int(-1, 1), new Vector2Int(-1, -1), new Vector2Int(1, -1), }; ss.Stop(); Debug.Log(ss.ElapsedMilliseconds + \u0026#34;ms. 初始化数组\u0026#34;); ss.Restart(); for (int x = 0; x \u0026lt; GridSize.x; x++) { for (int y = 0; y \u0026lt; GridSize.y; y++) { int minCostMove = int.MaxValue; Vector2 minDir = new Vector2(x, y); foreach (var dir in dirs) { int ix = x + dir.x; int iy = y + dir.y; if (ix \u0026gt;= 0 \u0026amp;\u0026amp; ix \u0026lt; GridSize.x \u0026amp;\u0026amp; iy \u0026gt;= 0 \u0026amp;\u0026amp; iy \u0026lt; GridSize.y \u0026amp;\u0026amp; _obstacleField[ix, iy] == false) { if (_costField[ix, iy] \u0026lt; minCostMove) { minCostMove = _costField[ix, iy]; minDir = dir; } } } _flowField[x, y] = minDir.normalized; } } ss.Stop(); Debug.Log(ss.ElapsedMilliseconds + \u0026#34;ms. 计算数据\u0026#34;); } 管理器 接下来，简单编写一个unity组件，用于管理该数据。它应该存储一个向量图的引用，负责其初始化，设置向量图目标以开始计算。\n先在FieldGridSO添加相关函数并暴露。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 public void SetTarget(Vector2 target) { Stopwatch sw = new Stopwatch(); sw.Start(); Vector2Int intTarget = Vector2Int.zero; int x = (int)Math.Floor((target.x - origin.x) / CellSize); int y = (int)Math.Floor((target.y - origin.y) / CellSize); if (x \u0026gt;= 0 \u0026amp;\u0026amp; x \u0026lt; GridSize.x \u0026amp;\u0026amp; y \u0026gt;= 0 \u0026amp;\u0026amp; y \u0026lt; GridSize.y) { intTarget = new Vector2Int(x, y); } CalculateCostField(intTarget); CalculateFlowField(); sw.Stop(); Debug.Log(sw.ElapsedMilliseconds + \u0026#34; ms\u0026#34;); } public Vector2 GetFlowFieldVector(Vector2 pos) { if (_costField == null || _flowField == null) { return Vector2Int.zero; } Vector2Int intPos = Vector2Int.zero; int x = (int)Math.Floor((pos.x - origin.x) / CellSize); int y = (int)Math.Floor((pos.y - origin.y) / CellSize); if (x \u0026gt;= 0 \u0026amp;\u0026amp; x \u0026lt; GridSize.x \u0026amp;\u0026amp; y \u0026gt;= 0 \u0026amp;\u0026amp; y \u0026lt; GridSize.y) { intPos = new Vector2Int(x, y); return _flowField[intPos.x, intPos.y]; } else { Vector2Int flowdir = Vector2Int.zero; if (x \u0026lt; 0) { flowdir.x = 1; } else if (x \u0026gt; GridSize.x) { flowdir.x = -1; } if (y \u0026lt; 0) { flowdir.y = 1; } else if (y \u0026gt; GridSize.y) { flowdir.y = -1; } return flowdir; } } 然后是管理器类。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 public class MeshBaker : MonoBehaviour { public Vector2Int GridSize = new Vector2Int(100, 100); public float CellSize = 0.5f; public bool TempMapGizmosEnable = true; public bool ObstacleFieldGizmosEnable = true; public bool GridGizmosEnable = true; public FieldGridSO _fieldGrid; void Update() { if (Input.GetMouseButtonDown(0)) { Vector2 mousePos = Input.mousePosition; Vector3 worldPos = Camera.main.ScreenToWorldPoint(mousePos); if (_fieldGrid != null) { _fieldGrid.SetTarget(worldPos); } } } public Vector2 FindDir(Vector2 pos) { return _fieldGrid.GetFlowFieldVector(pos); } //可以绘制相关信息的函数 private void OnDrawGizmos() { if (_fieldGrid != null \u0026amp;\u0026amp; TempMapGizmosEnable) { _fieldGrid.DrawTempMapGizmos(transform.position); } if (_fieldGrid != null \u0026amp;\u0026amp; ObstacleFieldGizmosEnable) { _fieldGrid.DrawObstacleFieldGizmos(transform.position); } if (GridGizmosEnable \u0026amp;\u0026amp; _fieldGrid != null) { _fieldGrid.DrawGridGizmos(transform.position); } } } 代理器 创建代理器代理单位的寻路。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 public class FieldGridAgent : MonoBehaviour { public MeshBaker baker; private Rigidbody2D rb2d; private void Awake() { rb2d = GetComponent\u0026lt;Rigidbody2D\u0026gt;(); } void Update() { if (baker != null) { Vector2 dir = baker.FindDir(transform.position); rb2d.velocity = dir * 0.5f; } } } 相关优化 计算向量图可以使用unity job system进行优化多线程并行。 因为计算多多少少有点时间，可以使用异步等待计算完成而不必阻塞主线程。不过注意大部分unity的组件API的方法只能在主线程使用。所以使用了Physics.BoxCheck的的BakeObstacleField无法在后台进行或者多线程。\n修改SetTarget为异步函数。 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 public async void SetTarget(Vector2 target) { Stopwatch sw = new Stopwatch(); sw.Start(); Vector2Int intTarget = Vector2Int.zero; int x = (int)Math.Floor((target.x - origin.x) / CellSize); int y = (int)Math.Floor((target.y - origin.y) / CellSize); if (x \u0026gt;= 0 \u0026amp;\u0026amp; x \u0026lt; GridSize.x \u0026amp;\u0026amp; y \u0026gt;= 0 \u0026amp;\u0026amp; y \u0026lt; GridSize.y) { intTarget = new Vector2Int(x, y); } Action act = delegate { CalculateCostField(intTarget); CalculateFlowField(); }; await Task.run(act); sw.Stop(); Debug.Log(sw.ElapsedMilliseconds + \u0026#34; ms\u0026#34;); } 路径优化。直接使用向量图中的方向移动单位，单位间还有行动简单，碰撞和移动不平滑等问题。 因为单位可能同时在不同格子上，所以可以对周围格子的向量进行插值来让移动更平滑一点。 1 2 3 4 5 6 7 8 9 10 //修改获取向量的函数，用周围四格来插值 int lx = intPos.x \u0026lt;= cellPos.x ? -1 : 1; int ly = intPos.y \u0026lt;= cellPos.y ? -1 : 1; float wx = (pos.x - cellPos.x) * 2/ CellSize; float wy = (pos.y - cellPos.y) * 2/ CellSize; Vector2 x1 = Vector2.Lerp(GetFlowField(intPos.x, intPos.y), GetFlowField(intPos.x + lx, intPos.y), wx); Vector2 x2 = Vector2.Lerp(GetFlowField(intPos.x, intPos.y + ly), GetFlowField(intPos.x + lx, intPos.y + ly), wx); //GetFlowField需要对越界进行处理，如返回回到网格的向量 Vector2 res = Vector2.Lerp(x1, x2, wy); return res.normalized; Steering behavior。可以使用类似于Boid鸟群的思想。为壁障，避免单位碰撞分别计算一个力相加来完成这些行为。 github项目地址\n","date":"2026-01-31T21:36:24+08:00","permalink":"https://reimunai.github.io/p/unity%E4%B8%AD%E6%B5%81%E5%9C%BA%E5%AF%BB%E8%B7%AF%E7%9A%84%E5%AE%9E%E7%8E%B0/","title":"Unity中流场寻路的实现"},{"content":"引入 记录以下相关学习和代码。相关代码和思路是从《计算机图形学入门:3d渲染指南》学习的\n开发环境为：\nwindow10 x64 visual studio 2022 c++17 SDL3 glm 基本框架 构建基本的渲染循环，之后就可以专注于光线追踪相关代码实现。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 //Main.cpp #include \u0026lt;SDL3/SDL.h\u0026gt; #include \u0026lt;SDL3/SDL_pixels.h\u0026gt; #include \u0026lt;iostream\u0026gt; #include \u0026#34;softRenderBase.hpp\u0026#34; int main() { //初始化SDL环境 SDL_Init(SDL_INIT_VIDEO); SDL_Window* window = SDL_CreateWindow(\u0026#34;soft-renderer\u0026#34;, WINDOW_WIDTH, WINDOW_HEIGHT, 0); SDL_Renderer* renderer = SDL_CreateRenderer(window, NULL); if (!renderer) { std::cerr \u0026lt;\u0026lt; \u0026#34;渲染器创建失败\u0026#34; \u0026lt;\u0026lt; SDL_GetError() \u0026lt;\u0026lt; std::endl; SDL_DestroyWindow(window); SDL_Quit(); return -1; } SDL_Texture* texture = SDL_CreateTexture( renderer, SDL_PIXELFORMAT_ARGB8888, SDL_TEXTUREACCESS_STREAMING, WINDOW_WIDTH, WINDOW_HEIGHT ); if (!texture) { std::cerr \u0026lt;\u0026lt; \u0026#34;texture创建失败\u0026#34; \u0026lt;\u0026lt; SDL_GetError() \u0026lt;\u0026lt; std::endl; SDL_DestroyRenderer(renderer); SDL_DestroyWindow(window); SDL_Quit(); return -1; } //创建缓冲区存储图像数据 SDL_PixelFormatDetails* format; Uint32* pixelBuffer = new Uint32[WINDOW_WIDTH * WINDOW_WIDTH]; clearBuffer(pixelBuffer, colorFromRGBA(255, 255, 255, 255)); bool quit = false; SDL_Event event; //更新缓冲区 //考虑到使用的cpu进行软渲染，而且我暂时不会进行相关优化。 //只更新一次缓冲区 draw(pixelBuffer); //主循环 while (!quit) { while (SDL_PollEvent(\u0026amp;event)) { if (event.type == SDL_EVENT_QUIT) { quit = true; } } //更新texture上传到窗口表面 SDL_UpdateTexture(texture, NULL, pixelBuffer, WINDOW_WIDTH * sizeof(Uint32)); SDL_RenderClear(renderer); SDL_RenderTexture(renderer, texture, NULL, NULL); SDL_RenderPresent(renderer); } delete[] pixelBuffer; SDL_DestroyTexture(texture); SDL_DestroyRenderer(renderer); SDL_DestroyWindow(window); SDL_Quit(); return 0; } 编写绘制单个像素的函数和将RGBA值转换为uint32_t的值的函数。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 void setPixel(Uint32* buffer, int x, int y, Uint32 color) { if (x \u0026lt; 0 || x \u0026gt; WINDOW_WIDTH || y \u0026lt; 0 || y \u0026gt; WINDOW_HEIGHT) { return; } buffer[y * WINDOW_WIDTH + x] = color; } Uint32 colorFromRGBA(Uint8 r, Uint8 g, Uint8 b, Uint8 a) { return (static_cast\u0026lt;Uint32\u0026gt;(a) \u0026lt;\u0026lt; 24) | (static_cast\u0026lt;Uint32\u0026gt;(r) \u0026lt;\u0026lt; 16) | (static_cast\u0026lt;Uint32\u0026gt;(g) \u0026lt;\u0026lt; 8) | (static_cast\u0026lt;Uint32\u0026gt;(b)); } Uint32 colorFromRGBA(glm::vec4 rgba) { return colorFromRGBA(rgba.r, rgba.g, rgba.b, rgba.a); } 核心代码 基本结构体的定义 视口相当于设备窗口中虚拟三维世界中的对应，长宽比一般和设备窗口对应。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 struct Viewport { float width; float height; float distance; Viewport(float w, float h, float d) { width = w; height = h; distance = d; } }; struct Sphere { glm::vec3 position; float radius; glm::vec3 color; Sphere(glm::vec3 p, float radius) { position = p; this-\u0026gt;radius = radius; color = glm::vec3(255, 255, 255); } } std::vector\u0026lt;Sphere\u0026gt; sceneObj = { Sphere(glm::vec3(0, 0, 7), 1, glm::vec3(0, 128, 0)) }; draw函数 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 void draw(Uint32* buffer) { //遍历像素 for (int pixelX = 0; pixelX \u0026lt; WINDOW_WIDTH; pixelX++) { for (int pixelY = 0; pixelY \u0026lt; WINDOW_HEIGHT; pixelY++) { float x = (float)pixelX * viewport.width / (float)WINDOW_WIDTH - viewport.width / 2; float y = (float)pixelY * viewport.height / (float)WINDOW_HEIGHT - viewport.height / 2; y = -y; float z = viewport.distance; //窗口像素转换为三维空间中viewport对应坐标。 //...TODO } } } 射线碰撞检测实现 要实现在屏幕上渲染场景中的球体，最符合直觉也最简单实现的方式是从眼睛所在位置向视口在屏幕像素对应位置发射射线，碰到哪个球就显示什么颜色。实际上我认为从这一步开始学习是最好，很快就能看到屏幕上显示的球，学校教图形是反而从光栅化，插值开始。\n那么首先就应该判断射线和球是否相交。\n射线需要确定起点和方向。 $S$ 表示射线起点。\n$\\vec{D}$ 表示射线方向。 要表示射线上任意一点$P$为：\n$P = S + t\\vec{D}$\n球是最为熟悉的，任意点$P$在球上满足：\n$(P - O)^2 = R^2$\n$O$为球心，$R$为半径。\n联立是一元二次方程，根据有无解了判断相交，可求得 $t$ 计算$P$点。\n但是要注意的是，射线的t应该是大于零的，对于小于零解要舍弃。和零比较考虑误差用较小值代替。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 struct Ray { glm::vec3 startPoint; glm::vec3 direction; Ray(glm::vec3 p, glm::vec3 dir) { startPoint = p; direction = dir; } }; //optional是c++17引入的用来表示一些函数不存在结果情况（std::nullopt）的类型 std::optional\u0026lt;float\u0026gt; isRayBallIntersect(Ray ray, Sphere ball) { auto ro = ray.startPoint - ball.position; float b = 2.0f * glm::dot(ro, ray.direction); float a = glm::dot(ray.direction, ray.direction); float c = glm::dot(ro, ro) - ball.radius * ball.radius; float result = 0.0f; float delta = b * b - 4.0f * a * c; if (delta \u0026gt;= 1e-6f) { float t1 = (-b + sqrt(delta)) / (2.0f * a); float t2 = (-b - sqrt(delta)) / (2.0f * a); //选取大于零且较小的解 if (t2 \u0026gt; 1e-6f) { return t2; } if (t1 \u0026gt; 1e-6f) { return t1; } } //无解 return std::nullopt; } 添加一个新的函数std::optionalglm::vec3 caculateColor(Ray ray)在draw函数中用于计算颜色。并在其中进行碰撞检测。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 std::optional\u0026lt;glm::vec3\u0026gt; caculateColor(Ray ray) { for (auto\u0026amp; sphere : sceneObj) { auto t = isRayBallIntersect(ray, sphere); if (t.has_value()) { if (t \u0026gt;= 0) { if (!tmin.has_value() || t.value() \u0026lt; tmin.value()) { tmin = t.value(); frontSphere = \u0026amp;sphere; } } } } if (tmin.has_value() \u0026amp;\u0026amp; tmin.value() \u0026gt;= 1e-4f \u0026amp;\u0026amp; frontSphere != nullptr) { //...TODO //可以直接返回颜色试试看结果。 //return frontSphere-\u0026gt;color; } return std::nullopt; }\tPhong光照模型 在这里我只使用考虑光源与物体表面直接光照，即局部光照模型——phong模型。它是不考虑物体间光线相互反射，能量守恒的简化大概符合经验的光照模型。\n其中包括了漫反射，环境光和镜面反射。\n以下为符号约定：\n$K_a, K_b, K_s$为环境光，漫反射和镜面反射对应的材质系数，决定物体对这些类型的反射强弱；\n$I$光源光强;\n$\\vec{N}$为法线；\n$\\vec{L}$为光源方向，点到光源；\n$\\vec{V}$为视线方向，视点到点；\n$\\vec{R}$为光线相对法线的反射。\n环境光\n假设环境光为一个常量，$K_a$材质环境光系数。 物体返回的环境光强度为：\n$i_a = k_a * I_a$ 漫反射光\n强度和夹角余弦相关，物体返回的漫反射光强度为：\n$i_d = k_d * I * max(0, \\vec{N}\\cdot\\vec{L})$，max用于防止负值。 镜面反射光\n$i_s = k_s * I * max(0, \\vec{R}\\cdot\\vec{V})^s$,$s$为镜面反射或者高光指数，用于控制高光范围。 最终返回的光强$I$为 $I = i_a + i_d + i_s$。\n为Sphere添加相关参数。 添加calculateLight并修改caculateColor函数为：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 // 注意，我这里代码忽略了对应的材质系数。 void calculateLight(glm::vec3\u0026amp; p, Sphere* frontSphere, Ray\u0026amp; ray, float\u0026amp; intensity) { for (auto\u0026amp; light : sceneLight) { auto n = glm::normalize(p - frontSphere-\u0026gt;position); auto l = glm::normalize(light.position - p); auto r = glm::normalize(ray.direction); if (light.type == AMBIENT) { //环境光 intensity += light.intensity; } else if (light.type == POINT) { //漫反射光 intensity += light.intensity * std::max(0.0f, glm::dot(n, l)); //镜面反射 if (frontSphere-\u0026gt;specular != -1) { auto reflectLight = 2.0f * n * glm::dot(n, l) - l; intensity += light.intensity * std::powf(glm::dot(r, reflectLight), frontSphere-\u0026gt;specular); } } } } std::optional\u0026lt;glm::vec3\u0026gt; caculateColor(Ray ray) { ······ if (tmin.has_value() \u0026amp;\u0026amp; tmin.value() \u0026gt;= 1e-4f \u0026amp;\u0026amp; frontSphere != nullptr) { color = frontSphere-\u0026gt;color; glm::vec3 p = ray.startPoint + tmin.value() * ray.direction; float intensity = 0.0f; calculateLight(p, frontSphere, ray, intensity); color *= intensity; color = glm::clamp(color, glm::vec3(0), glm::vec3(255)); //可以直接返回颜色试试看结果。 //return color; } return std::nullopt; } 关于镜像——Whitted—style Ray tracing 镜像已经是属于间接光照了，一个简单的想法是，根据光路可逆，计算视线相对法线反射光线。 再以这反射光线为视角，计算它“看到了什么颜色”，用这个颜色参与最终颜色的计算。\n这里可以使用递归实现， 先为Sphere添加reflect参数。再添加一个全局常量const int MAX_TRACE_DEPTH设置最大递归深度。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 std::optional\u0026lt;glm::vec3\u0026gt; caculateColor(Ray ray) { static int depth = 0; ······ //计算为颜色后，根据是否有镜像属性决定是否开始递归。 color *= intensity; color = glm::clamp(color, glm::vec3(0), glm::vec3(255)); if (frontSphere-\u0026gt;reflect \u0026gt; 0 \u0026amp;\u0026amp; frontSphere-\u0026gt;reflect \u0026lt; 1 \u0026amp;\u0026amp; depth \u0026lt; MAX_TRACE_DEPTH) { auto n = glm::normalize(p - frontSphere-\u0026gt;position); auto l = glm::normalize(-ray.direction); //视线相对法线反射光线 auto reflectDir = 2.0f * n * glm::dot(n, l) - l; depth++; auto flectColor = caculateColor(Ray(p + 1e-6f * n, reflectDir)); if (flectColor.has_value()) { return glm::mix(color, flectColor.value(), frontSphere-\u0026gt;reflect); } else { return color; } } } ","date":"2026-01-24T23:13:48+08:00","permalink":"https://reimunai.github.io/p/c-%E7%AE%80%E5%8D%95%E5%85%89%E7%BA%BF%E8%BF%BD%E8%B8%AA%E8%BD%AF%E6%B8%B2%E6%9F%93/","title":"C++简单光线追踪软渲染"},{"content":"引入 从老师给出的题目中选出该课题后。首先便浮现以下几个问题：\n要在什么类型游戏下使用LLM实现敌人的AI？ AI要全权接管敌人AI，还是介入部分，又要介入哪个部分呢？ 因为LLM返回结果需要一定时间，且结果有不确定性，要怎么解决？ 在第一个问题上，我最终选择2d俯视角射击游戏。他对于我们而言足够简单，特别是在有AI辅助编写代码下，可以快速完成原型开发。而在关键的敌人身上也只有移动和射击两个基本操作，但是却可以设计出足够复杂的行为来体现LLM的作用。\n随后我采用有限状态机混合LLM的方式来驱动敌人的行动。现阶段LLM肯定不能时时刻刻调用来确定敌人的行动，应该像人体一样：感官收集信息，由大脑进行决策，又有小脑，脊髓和肌肉记忆负责简单动作和反射。设计感知模块，决策模块，执行模块，由LLM担任敌人AI的指挥官。\n最后通过提示词设计，让LLMAPI返回Json格式结果，限定了其输出，一定程度上保证了结果的准确性。同时在客户端验证结果，设计非法回复处理。\n开发环境搭建 由于小组里共有三人可以负责程序的开发，我决定引入开源项目来解决敌人寻路问题。先是了解到unity的packages中有ai.navigation包集成了A*的实现，可以简化敌人寻路AI开发。又发现navigation对于2d游戏的实现不佳。通过网络搜索和询问AI最后引入了navmeshplus来支持2d寻路。\n所以最终初始环境为：\nunity 2022.3.6f1c1 package unity.ai.navigation\u0026quot;: \u0026ldquo;1.1.7\u0026rdquo; \u0026ldquo;com.h8man.2d.navmeshplus\u0026rdquo;: \u0026ldquo;https://github.com/h8man/NavMeshPlus.git#master\" 项目架构设计 2d俯视角游戏基本设置 PlayerController\n基本玩家控制脚本，只有移动和射击两种操作。 SmoothFllowCamera\n简单相机跟踪 HealthInteractor\n即可以处理玩家又可以处理敌人的血量变化和交互的脚本。 Bullet\n子弹预制体的MonoBehaviour脚本 LLM API调用 DeepSeekChatManager 调用DeepSeep大模型API的模块。 核心模块，敌人AI 参考人类思考和行动的方式，进行了分层设计，大致为感知，决策，执行层。\n中间桥梁感知层EnvPerceiver\n通过该脚本检测环境信息，包装为自然语言传递给下流决策层进行决策，和给执行层锁定追踪对象，寻找掩体。 顶层决策层LLMPlanner\n接收感知层信息，调用LLMAPI调用模块，传达命令给执行层。\n底层执行层，共两层 StrategyExecuter\n接收命令，发出状态切换的指令。 低级执行层，预编写的各种状态 IdleState待机状态 RetreatState撤退状态 ChaseState追击状态 WanderState游荡状态 最终结果 实际开发中的难点 根据自己开发的部分和小组成员的反馈。在AI辅助开发情况下，难度不高。主要是未考虑其他条件和提供给AI的实际项目上下文不足导致的bug。如：\nAPI调用没有使用异步或者协程导致阻塞主线程。 空引用不存在的模块。 API调用超时，非法输出处理。通过引入状态延续，默认状态机制解决。\n实际最困难的部分，反而是状态机部分。撤退状态开始时涉及了掩体躲避和反击，但是用AI编写的代码不和预期，最终因为时间关系，只能改为单纯尝试远离玩家的设计。 还有一些参数的设置问题，为了控制API调用频率，降低成本。我们引入了API调用频率控制的参数。太慢显得敌人呆呆，太快消耗token。最后引入根据环境是否高压，动态控制思考调用频率，即减少token消耗，又保留敌人的高智能。 总结 这次架构设计让我深刻理解了“确定性系统”与“非确定性 AI”的结合之道。通过将 LLM 限制在“策略层”，我们既获得了智能的战术决策，又保留了传统代码的稳定性。\n但是还有一些未来可以改进优化的地方。\n最开始是准备引入unityPackage的ML-Agent包，在底层执行使用强化学习让敌人行为更智能。考虑到DDL要到了，需要的python方面AI编码和训练时间，最终没有加入。\n目前只有一个敌人，而且代码只考虑孤军作战的情况。放多几个敌人是没有相应的合作行为。未来或许可以加入指挥官系统，考虑多ai合作。\n考虑给LLM更多的权限，比如更多状态，放开状态参数调整权限，让有限状态产生更多变化。\n","date":"2026-01-20T19:24:51+08:00","permalink":"https://reimunai.github.io/p/%E5%9C%A8unity%E7%94%A8llm%E5%A4%A7%E6%A8%A1%E5%9E%8B-fsm%E6%9C%89%E9%99%90%E7%8A%B6%E6%80%81%E6%9C%BA%E6%9E%84%E5%BB%BA%E6%99%BA%E8%83%BD%E6%95%8C%E4%BA%BAai/","title":"在Unity用LLM大模型+FSM有限状态机构建智能敌人AI"},{"content":"引入 简介 UnityWebRequest 对象用于与 Web 服务器进行通信。 UnityWebRequest 处理 HTTP 与 Web 服务器进行通信的流程。其他对象 - 特别是 DownloadHandler 和 UploadHandler - 分别管理数据的下 和上传。\n\u0026ndash;来自unity文档\n基本用法示例 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 using system.Collections; using UnityEngine; using UnityEngine.Networking; // 下载文本数据 //using 语法是为了确保这些对象在不再需要时能够及时释放资源。 Ienumertor DownloadText(string url) { using(UnityWebRequest request = UnityWebRequest.Get(url)) { yield return request.SendWebRequest(); if(request.result == UnityWebRequest.Result.Success) { string text = request.downloadHandler.text; Debug.log(\u0026#34;Download success: \u0026#34; + text); } } } // 下载文件并保存 IEnumerator DownloadFile(string url, string savePath) { using(UnityWebRequest request = UnityWebRequest.Get(url)) { request.downloadHandler = new DownloadHandlerFile(savePath); yield return request.SendWebRequest(); } } // 上传表单数据 IEnumerator UploadData(string url, string data) { using(UnityWebRequest request = UnityWebRequest.Post(url, data)) { byte[] jsonToSend = Encoding.UTF8.GetBytes(data); request.uploadHandler = new UploadHandlerRaw(jsonToSend); request.downloadHandler = new DownloadHandlerBuffer(); request.SetRequestHeader(\u0026#34;Content-Type\u0026#34;, \u0026#34;application/json\u0026#34;); yield return request.SendWebRequest(); } } 常通过以下途径创建UnityWebRequest对象\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 //构造函数，method表示HTTP协议的GET, Post, PUT,不附加method和handler默认为GET method public UnityWebRequest (); public UnityWebRequest (string url); public UnityWebRequest (string url, string method); public UnityWebRequest (string url, string method, Networking.DownloadHandler downloadHandler, Networking.UploadHandler uploadHandler); //通过静态函数返回 //这些函数可返回针对许多常见用例相应配置的 UnityWebRequest 对象。请参阅：Get、Post、Put、GetTexture。 public static Networking.UnityWebRequest Get (string uri); //发送 HTTP HEAD 请求的 UnityWebRequest public static Networking.UnityWebRequest Head (string uri); //将原始数据上传到远程服务器的 UnityWebRequest public static Networking.UnityWebRequest Post (string uri, string postData); //向 uri 传输 bodyData的 UnityWebRequest public static Networking.UnityWebRequest Put (string uri, byte[] bodyData); public static Networking.UnityWebRequest Put (string uri, string bodyData); 可以发现UnityWebRequest包含一些重要变量，常用的是downloadHandler和uploadHandler类的引用,定义如何处理从远程服务器接收的 HTTP 响应体数据。\n还有之后的request.SendWebRequest()向url发送请求的方法。这里一般用yield return等待请求完成，防止阻塞主线程。\n","date":"2025-11-15T16:49:10+08:00","permalink":"https://reimunai.github.io/p/unitywebrequest/","title":"UnityWebRequest"},{"content":"引入 在编写如延时操作，UI和其他动画控制，加载资源（场景，模型和贴图等），等待网络请求等操作时，需要将处理操作分布多帧中或者为了不阻塞主线程需要异步编程。考虑现在可能只需要简单延时操作，UI动画控制，加载等操作，先从unity的协程coroutine和C#的Task类考虑。\nunity协程 首先需要知道unity的大部分游戏逻辑，输入处理和渲染都是在主线程处理处理，是单线程的。大部分API只能在主线程调用，子线程尝试调用可能有异常和未定义错误。而unity协程本质上也是在主线程上运行的，协程函数任务被分配到不同帧中处理。\n语法 协程方法通过C#的迭代器关键字IEnumerator，定义一个返回迭代器的函数实现；并在函数体通过yiled return YieldInstruction(waitForSeconds,waitForFixedUpdate等的基类)暂停协程函数和决定什么时候恢复执行；最后通过StartCoroutine(IEnumerator routine)开始协程函数。\n示例代码如下：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 using UnityEngine; //协程是在MonoBehavour实现，只能在MonoBegaviour脚本中使用 public class TestExample : MonoBehaviour { private IEnumerator Test() { for(int i = 0; i \u0026lt; 10; i++) { Debug.Log(i + \u0026#34; loop, in \u0026#34; + Time.frameCount + \u0026#34; frame.\u0026#34;); yield return null; } } void Start() { StartCoroutine(Test()); } } //最终输出： //0 loop, in 1 frame //1 loop, in 2 frame //2 loop, in 3 frame //... //3 loop, in 10 frame yield return null即暂停后在下一帧执行。 还可以使用：\n1 2 3 4 yield return new WaitForSeconds(1);//等待1秒，受到TimeScale影响。 yield return new WaitForSecondsRealTime(1);//等待1秒，不受到TimeScale影响。 yield return new WaitForFixedUpdate();//等待到下一次fixedUpdate执行。 yield return StartCoroutine(anoTher Coroutine);//等待另一个协程执行完成后。 局限 使用范围 协程只能在继承了MonoBehaviour的类在启动，依赖于组件类型脚本和场景中物体或者空物体。\n生命周期问题 协程和挂载脚本的GamwObject绑定，对象销毁，协程停止而不会销毁。可能引发内存泄漏，空引用或者未知错误。\n性能问题 yield return 时候频繁创建返回对象造成GC。\n由于实际是在主线程上，过多协程还是会造成阻塞。\n如果涉及到数据读取，yield return null不好把握一帧读取多少。因为单线程，一帧读取多会阻塞，少了浪费时间。\n功能实现局限 协程函数无法返回值\n协程函数不能使用try，catch进行错误捕获。\nc# Task类 c# Task类是.Net框架下提供异步操作的核心类，基于Thread，ThreadPool的高度抽象。能够以直观，高效的方式编写异步，多线程代码，而不需要关心线程的生命周期，如创建，启动，暂停和恢复等。\n基本用法 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 using System.Threading.Tasks; private async Task\u0026lt;string\u0026gt; TestAsync() { Debug.Log(\u0026#34;async Task start!\u0026#34;); await Task.Delay(3000); return \u0026#34;Task completed! return result\u0026#34;; } private async void Start() { string res = await TestAsync(); Debug.log(\u0026#34;res\u0026#34;); } private void Update() { Debug.log(\u0026#34;Update!\u0026#34;); } //预期输出： //async Task start //Update! //Update: //..... //after 3s //Task completed! return result async关键字允许函数进行异步操作，即可以在函数中使用await关键字。当遇到需要等待另一个任务完成，使用await关键字会暂停函数将控制权返回到调用方，等待任务完成后再回到该异步函数。\nTask常用函数 1 2 3 4 5 6 using System.Thread.Tasks; Task.Run(Action action);//委托或者lambda函数表达式。 Task.Delay(Int time);//创建在time秒后结束的任务。 Task.WhenAll(Task[] tasks)//等待数组中所有任务完成。 Task.WhenAny(Task[] tasks)//等待数组中任一任务完成。 除此以外还有其他高级用法，现阶段可能还用不上，等有更高级的需求在补充。\n","date":"2025-09-05T23:04:59+08:00","image":"https://reimunai.github.io/p/asyncprogramminginunity/image_hu_4f9b705beb782405.png","permalink":"https://reimunai.github.io/p/asyncprogramminginunity/","title":"AsyncProgrammingInUnity"},{"content":"引入 之前使用webgl实现的波浪效果，只要去除顶点之间连线，渲染顶点圆，其实也可以算是粒子动画。但是那样的方法对于复杂粒子系统动画就有点捉襟见肘了。对于波浪，我们使用不同频率正弦波叠加，传入uniform变量time给gpu。每个顶点都可以简单地计算出每时每刻的位置后，进行顶点变换再渲染。如果系统中粒子要实时响应外部因素变化(如受到速度属性，外部力属性，质量属性)，粒子存在相互作用(碰撞，引力)和模型复杂的时候，我们更希望根据上次粒子的位置，速度属性，递推计算粒子位置变换。这时候就可以使用webgl2的transform feedback功能，将前一帧的位置计算结果写入缓冲区供下一帧计算使用。\n关于webgl2 webgl2包含了许多webgl没有的新特性，支持glsl 3.0 es。\nwebgl2的API获取和glsl编写与webgl主要有以下区别：\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 //webgl2上下文接口获取 var canvas = document.getElementById(\u0026#34;glcanvas\u0026#34;); const gl2 = canvas.getContext(\u0026#34;webgl2\u0026#34;); //使用glsl 3.0 es //in相当于attribute变量 //out相当于varying变量，可以传递值个fragmentShader const vertexShaderSource = `#version 300 es in vec4 aPosition; out vec4 pos; uniform float deltaTime; void main() { gl_Position = aPosition; } ` //不再使用设置gl_FragColor变量作为输出颜色。 //自己设置out变量作为输出。 const fragmentShaderSource = `#version 300 es out vec4 outColor； void main() { outColor = vec4(1.); } ` webgl2也可以向下兼容webgl，你依旧可以使用webgl的写法。\nwebgl2有许多新的特性可以使用，这里主要变换反馈对象transformFeedback。\n着色器编写 我们来创建一个简单的粒子动画，收到指向固定点恒力的粒子系统。\n我们需要两个gpu程序(即program)，一个用于更新粒子的顶点位置，后写入缓冲区，一个用于渲染，读取顶点位置并渲染。\n由于更新位置的program不需要渲染出来，我们可以在js中停止片段着色器的调用。链接的时候，片段着色器可以链接渲染程序的片段着色器，满足编译要求即可。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 //读取old位置，速度属性，计算新的位置和速度，赋值给out变量，之后写入缓冲区。 const updateVertexShaderSource = `#version 300 es in vec3 oldPosition; in vec3 oldVelocity; in float mass; uniform vec3 gPoint; uniform float gForce; uniform float deltaTime; out vec3 newPosition; out vec3 newVelocity; void main() { float dt = deltaTime / 1000.; newPosition = oldPosition + oldVelocity * dt; vec3 a = normalize(gPoint - newPosition) * gForce / mass; newVelocity = oldVelocity + a * dt; if(length(newPosition - gPoint) \u0026lt;= 0.1) { newVelocity = oldVelocity - 5. * a * dt; } } ` //渲染点的俩个着色器。 const renderVertexShaderSource = `#version 300 es in vec4 a_position; uniform mat4 mvp; void main() { gl_Position = mvp * a_position; gl_PointSize = 5.; } ` const renderFragmentShaderSource = `#version 300 es precision highp float; out vec4 outColor; void main() { vec2 coord = gl_PointCoord - vec2(0.5); float dist = length(coord); if(dist \u0026gt; 0.43) { discard; } outColor = vec4(1.0, 0.7, 0.7, 1.0); outColor.a *= 1.0 - smoothstep(0.4, 0.5, dist); } ` 设置transformFeedback 设置transformFeedbackVarying 要设置transformFeedback，需要在使用shaderSource创建好shader，创建program并把shader附加到program后，通过transformFeedbackVaryings函数告诉gpu，哪些out变量要写入缓冲区。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 const updateVertexShader = WebglUtil.createShader(gl2, gl2.VERTEX_SHADER, updateVertexShaderSource); const renderVertexShader = WebglUtil.createShader(gl2, gl2.VERTEX_SHADER, renderVertexShaderSource); const renderFragmentShader = WebglUtil.createShader(gl2, gl2.FRAGMENT_SHADER, renderFragmentShaderSource); const updateProgram = gl2.createProgram(); gl2.attachShader(updateProgram, updateVertexShader); gl2.attachShader(updateProgram, renderFragmentShader); gl2.transformFeedbackVaryings( updateProgram,//指定program [\u0026#39;newPosition\u0026#39;, \u0026#39;newVelocity\u0026#39;],//指定变量 gl2.SEPARATE_ATTRIBS//变量将被写入不同缓冲区 ); gl2.linkProgram(updateProgram); if(!gl2.getProgramParameter(updateProgram, gl2.LINK_STATUS)) { throw new Error(gl.getProgramParameter(updateProgram)); } 设置transformFeedback buffer 接下来设置要写入的缓冲区。我们使用双缓冲区，以更新位置属性为例，一个存储旧位置用于读取，计算后写入另一个缓冲区。计算好新位置和渲染了旧位置后，交换用于读写缓冲区的指针指向，就可以循环计算每一帧的位置了。可以编写以下函数返回一个匿名类。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 function createDoubleBuffer() { return { write : gl2.createBuffer(), read : gl2.createBuffer(), swap : function(){ const temp = this.write; this.write = this.read; this.read = temp; } } } var positionDoubleBuffer = createDoubleBuffer(); gl2.bindBuffer(gl2.ARRAY_BUFFER, positionDoubleBuffer.write); gl2.bufferData(gl2.ARRAY_BUFFER, 120000, gl2.DYNAMIC_DRAW);//设置缓冲区大小 gl2.bindBuffer(gl2.ARRAY_BUFFER, null);//解除引用。 创建完双缓冲区后，进行以下操作：\n1 2 3 4 const tf = gl2.createTransformFeedback(); gl2.bindTransformFeedback(gl2.TRANSFORM_FEEDBACK, tf); gl2.bindBufferBase(gl2.TRANSFORM_FEEDBACK_BUFFER, 0, positionDoubleBuffer.write); gl2.bindBufferBase(gl2.TRANSFORM_FEEDBACK_BUFFER, 1, velocityDoubleBuffer.write); 在渲染循环中启用transformFeedback 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 function draw(currentTime)//渲染循环函数 { //初始化 gl2.viewport(0, 0, canvas.width, canvas.height); gl2.clearColor(0., 0., 0., 1.0); gl2.clearDepth(1.0); gl2.enable(gl2.DEPTH_TEST); gl2.clear(gl2.COLOR_BUFFER_BIT | gl2.DEPTH_BUFFER_BIT); //先绘制旧位置 gl2.useProgram(renderProgram); //设置相关属性和uniform。 //... gl2.drawArrays(gl2.POINTS, 0, 10000); //再更新属性 gl2.useProgram(updateProgram); gl2.enable(gl2.RASTERIZER_DISCARD);//不调用片段着色器。 //设置相关属性和uniform。 //... gl2.bindTransformFeedback(gl2.TRANSFORM_FEEDBACK, tf); gl2.beginTransformFeedback(gl2.POINTS); gl2.drawArrays(gl2.POINTS, 0, 10000); gl2.endTransformFeedback(); gl2.bindTransformFeedback(gl2.TRANSFORM_FEEDBACK, null); gl2.disable(gl2.RASTERIZER_DISCARD);//允许调用片段着色器。 //交换读写缓冲区。 positionDoubleBuffer.swap(); velocityDoubleBuffer.swap(); //重新设置相关属性的缓冲区引用。 //... } 效果(cameraZ为0有bug) ","date":"2025-08-25T17:45:23+08:00","image":"https://reimunai.github.io/p/webgl%E5%AE%9E%E7%8E%B0%E7%AE%80%E5%8D%95%E7%B2%92%E5%AD%90%E5%8A%A8%E7%94%BB/image_hu_abd6dbfe43427d78.png","permalink":"https://reimunai.github.io/p/webgl%E5%AE%9E%E7%8E%B0%E7%AE%80%E5%8D%95%E7%B2%92%E5%AD%90%E5%8A%A8%E7%94%BB/","title":"webgl实现简单粒子动画"},{"content":"之前写关于C#的博客发现在对应markdown文档里插入图片，在构建成静态网站后并不能加载出来。\n先是重新构建，不行。\n改变图片格式，不行。\n\u0026hellip;\n最后是在浏览器按F12看报错发现，发现试图加载图片的路径是http://websize/p/c。而我文件里有p/c#XXXX，根据我的文章titile生成的。#后面的都不见了。我在文件命名是已经避免了特殊符号，没想到是hugo生成的文件有特殊符号，可能#号是在这个hugo网站模板有特殊的解析方式，导致读取图片的时候路径错误。不过我也不敢用ai教的胡乱该模板原代码，只能在title避免#号了。\n","date":"2025-08-19T13:13:05+08:00","image":"https://reimunai.github.io/p/%E5%85%B3%E4%BA%8Ehugo%E7%9A%84%E4%B8%80%E4%BA%9B%E7%A2%8E%E7%A2%8E%E5%BF%B5/image_hu_a58159332337656d.png","permalink":"https://reimunai.github.io/p/%E5%85%B3%E4%BA%8Ehugo%E7%9A%84%E4%B8%80%E4%BA%9B%E7%A2%8E%E7%A2%8E%E5%BF%B5/","title":"关于hugo的一些碎碎念"},{"content":"C#委托 介绍 在C#里委托是一种类型安全的函数指针，类似于C/C++中的函数指针，相当于C#的引用之于C/C++的指针一样，提供类似指针的操作，但是更加安全。\n且委托允许指向多个函数，即多播。\n语法 委托的声明。\n1 2 3 4 //委托声明 public delegate 返回类型 委托名(参数列表); //如下 public delegate int MathOperation(int x, int y); 在声明委托之后，通过new来实例化一个委托。之后就可以通过委托来调用其引用的函数。\n1 2 3 4 5 6 7 8 9 10 //委托声明 public delegate int MathOperation(int x, int y); public int add(int x, int y) { return x + y; } //实例化时，构造参数不能为空。 MathOperation mo = new MathOperation(add); int res = mo(3, 5); //res = 8 相同类型的委托可以进行合并。让所有委托时调用多个函数。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 //续之前代码 int a = 1; public delegate void ChangeA(int b); public void add(int b) { a += b; } public void multi(int b) { a *= b } ChangeA ca; ChangeA ca1 = new ChangeA(add); ChangeA ca2 = new ChangeA(multi); ca = ca1; ca += ca2; ca(5); //a = 30; 委托还可以通过-=来移除引用的函数。\n1 ca -= multi; 委托可以作为函数参数。\n1 2 3 4 5 //如 public int testFunc(int a, MathOperation mo) { return mo(a, a); } 其他 C#提供了几种常见的泛型委托类型，可以使用这些直接实例化常见的委托。\n1 2 3 4 5 6 7 8 //Action表示不返回值的函数，最多接收16个参数。 public Action\u0026lt;string, int\u0026gt; action = func; //Action表示有返回值的函数，最多接收16个参数。 //只有一个泛型参数时，代表返回值类型。 //多个时最后一个为返回类型，其他为输入类型。 public Func\u0026lt;int, int, int\u0026gt; addFunc = func; //Predicate表示返回bool值的函数，泛型参数为输入类型。 public Predicate\u0026lt;int, int\u0026gt; isTrue = func; 另外C#匿名函数的使用也会使用delegate关键字。或者类似C++11Lambda表达式那样更简洁的写法。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 //在需要函数(即委托)作为参数时，可以直接使用匿名函数。 public void test(Func\u0026lt;int, int\u0026gt; func) { console.writeLine(func(2)); } //delegate关键字创建匿名函数。 test(delegate(int a){return a * a;}); test(delegate(int a){return a + 5;}); //lambda表达式创建匿名函数 test((int a) =\u0026gt; {return a * a;}); test((int a) =\u0026gt; a * a); //输出: //4 //7 但是因为匿名函数是没有名字的函数，所以如果在委托中添加一个匿名函数，是没办法减掉这个匿名函数的。\n如果匿名函数中使用了外部函数的变量，可能导致该变量的生命周期延长，产生异常结果。可以使用static声明不捕获外部变量的匿名函数。\nC#事件 介绍 知道了委托的用处，第一反应就是可以用于游戏中的事件处理。\n但是同时会发现使用委托实现观察者模式(又名发布-订阅模式，我个人更喜欢这个名字)，特别是一个发布，多个订阅时，会发现委托不能直接添加函数，添加订阅时需要创建多个委托后进行合并，比较麻烦。其实C#已经提供了实现发布-订阅模式，且使用方便的事件类型。\nC#事件就是对委托进行了封装，来实现发布-订阅模式的一个类型。\n语法 1 2 3 4 5 6 7 8 9 10 //声明事件之前，需要声明该事件的委托类型(即该事件发生时调用什么样的函数)。 public delegate void testHandler(string args); //再基于声明的委托定义事件。 public event testHandler Ontest; //事件的触发,事件只能在定义事件类里面触发即事件由发布者发布。 //？用于检查事件中委托引用的函数是否为空，保证只要有订阅者才触发。 Ontest?.Invoke(s); //事件的订阅和取消订阅。提供-=，+= Ontest += funcName; Ontest -= funcName; 完整的发布-订阅模式实现 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 //事件的委托类型经常命名为XXHandler public delegate void testHandler(string args); //发布者 public class Pulisher { public event testHandler Ontest; public void OntestInvoke(string a) { Ontest?.Invoke(s); } //模拟事件的触发 public void StartTest() { OntestInvoke(\u0026#34;sss\u0026#34;); } } //订阅者 public class subscriber { public void subscribe(Pulisher pulisher) { pulisher.Ontest += react; } //订阅者对事件发生做出反应 private void react(string s) { Console.WriteLine(s); } } static void main(sting[] args) { Pulisher p = new Pulisher(); subscriber s = new subscriber(); s.subscribe(p); subscriber s1 = new subscriber(); s1.subscribe(p); //多个订阅者 //事件触发 p.StartTest(); //输出： //sss //sss //做出了反应。 } unityEvent 发布-订阅模式是游戏中常用的设计模式，robloxstudio和godot都提供了在编辑器配置事件的方法，unity也有相关功能。\nunityEvent是unity提供的对事件操作的api，unityevent允许在inspector直接添加或者删除订阅者函数，简化了代码的书写。\n1 2 3 4 5 6 7 8 9 10 11 using UnityEngine.Events; public class tester : MonoBehavior { public UnityEvent unityEvent; //模拟事件触发 public void Invoke() { unityEvent?.Invoke(); } } 这样就可以在inspector看见类似button的onclick的事件ui。\n想要带有参数需要重载unityEvent\u0026lt;T0\u0026gt;,unityEvent\u0026lt;T0, T1\u0026gt; 抽象类。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 using UnityEngine.Events; [Serializable] public class FloatEvent : UnityEvent\u0026lt;float\u0026gt;{}; public class tester : MonoBehavior { public UnityEvent unityEvent; public FloatEvent floatEvent; //模拟事件触发 public void Invoke() { unityEvent?.Invoke(); } public void FloatInvoke() { FloatEvent?.Invoke(0.5f); } } 也可以在代码中添加和删除监听\n1 2 unityEvent.AddListener(funcName); unityEvent.RemoveListener(funcName); 相关链接:\n委托的菜鸟教程相关页面。\n事件的菜鸟教程相关页面。\n匿名函数和lambda菜鸟教程相关页面。\nCSDN,匿名函数和lambda。\nCSDNunity事件。\nUnity官方，UnityEvent相关文档手册部分和脚本API部分。\n","date":"2025-08-17T22:28:25+08:00","image":"https://reimunai.github.io/p/csharp%E4%B8%AD%E7%9A%84%E5%A7%94%E6%89%98%E4%BA%8B%E4%BB%B6%E5%92%8Cunity%E4%B8%AD%E7%9A%84%E7%89%B9%E6%AE%8A%E4%BA%8B%E4%BB%B6%E7%B1%BB%E5%9E%8B/image_hu_4f9b705beb782405.png","permalink":"https://reimunai.github.io/p/csharp%E4%B8%AD%E7%9A%84%E5%A7%94%E6%89%98%E4%BA%8B%E4%BB%B6%E5%92%8Cunity%E4%B8%AD%E7%9A%84%E7%89%B9%E6%AE%8A%E4%BA%8B%E4%BB%B6%E7%B1%BB%E5%9E%8B/","title":"Csharp中的委托，事件和Unity中的特殊事件类型"},{"content":"Json方案 简介 JSON: JavaScript Object Notation(JavaScript 对象表示法)\nJSON是存储和交换文本信息的语法，类似 XML。JSON比 XML 更小、更快，更易解析。 JSON易于人阅读和编写。C、Python、C++、Java、PHP、Go 等编程语言都支持 JSON。\n方法 文件读写 在unity进行json文件读写操作需要引入命名空间System.IO中的File类。使用其中相关静态函数。\n1 2 3 4 5 using System.IO; //写入 File.WriteAllText(string path, string text, json); //读取 string json = File.ReadAllText(string path); 存储位置 Application.persistentDataPath\n适用于保存需要长期保留的用户数据（如设置、进度、关卡数据等）。\n在各平台上的实际路径不同，但都在应用可写的目录。\n转为json字符串 将数据转换为Json字符串使用unity提供的类JsonUtility中的静态函数。\n1 2 3 4 //转换为json。 string json = JsonUtility.ToJson(object obj, bool prettyPrint = false); //转换为对象。 Data data = JsonUtility.FromJson\u0026lt;Data\u0026gt;(string json); 示例 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 using System.IO; public static class JsonHelper { public static void SaveJson(string path, object obj) { string finalPath = Application.persistentDataPath + \u0026#34;/\u0026#34; + path; //或者string finalPath = Path.Combine(Application.persistentDataPath, path); string jsonData = JsonUtility.ToJson(obj, true); //可以在write这里进行适当的错误处理 File.WriteAllText(finalPath, jsonData, json); } public static T LoadJson\u0026lt;T\u0026gt;(string path) { string finalPath = Application.persistentDataPath + \u0026#34;/\u0026#34; + path; if(File.Exists(finalPath)) { string jsonData = File.ReadAllText(path); //担心转换的对象有问题可以在这里进行适当的错误处理。 return JsonUtility.FromJson\u0026lt;T\u0026gt;(jsonData); } } } 注意 限制 JsonUtility.ToJson支持任何普通类和结构体，和派生自MonoBehaviour或scriptableOnject的类。且只支持其中unity受序列化器支持的字段。虽然可以将C#原始类型传入，但是只会在Json中生成空对象字段。\n虽然ToJson方法支持派生自MonoBehaviour或scriptableOnject的类，但是JsonUtility.FromJson只支持创建普通类和结构；不支持派生自UnityEngine.Object的类（如MonoBehaviour或ScriptableObject）。\n如果想从Json读取并创建或者覆写怕派生自UnityEngine.Object的类，可以额外创建一个只包含数据的普通类，再提供这个数据类进行对象的创建或者覆写。\n或者想对更多数据类型的支持，如字典，列表等，可以使用其他的api如Newtonsoft.Json（Json.NET）— 适合更复杂的结构。\n优点：支持字典、列表、嵌套对象等，序列化选项灵活。\n","date":"2025-08-16T20:40:49+08:00","image":"https://reimunai.github.io/p/unity%E6%95%B0%E6%8D%AE%E6%8C%81%E4%B9%85%E5%8C%96%E5%AD%98%E5%82%A8/image_hu_4f9b705beb782405.png","permalink":"https://reimunai.github.io/p/unity%E6%95%B0%E6%8D%AE%E6%8C%81%E4%B9%85%E5%8C%96%E5%AD%98%E5%82%A8/","title":"Unity数据持久化存储"},{"content":"场景中物体类的编写 相机 定义相机类，包含其位置，观察位置，向上方向的属性。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 class Camera { constructor( position = [0.0, 0.0, 1.0], gazePosition = [0.0, 0.0, 0.0], viewUpVector3 = [0.0, 1.0, 0.0], viewPort = {width: 1.0, height: 1.0, d: 1.0} ) { this.position = position; this.gazePosition = gazePosition; this.viewUpVector3 = viewUpVector3; this.viewPort = viewPort; } } 还有获取变换到视图空间和到裁切空间的两个矩阵的方法\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 //续以上类中代码 getViewMatrix() { const res = mat4.create(); mat4.lookAt(res, this.position, this.gazePosition, this.viewUpVector3); return res; } getProjectionMatrix(canvas) { const res = mat4.create(); mat4.perspectiveNO(res, this.fovy, canvas.clientWidth / canvas.clientHeight, this.near, this.far); return res; } 平面 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 class PlaneMesh { constructor( position = [0.0, 0.0, 0.0], size = [5, 5],//大小 sub = 20,//细分等级 rotateXYZ = [0, 0, 0]//旋转，默认朝向Z ) { this.position = position; this.size = size; this.sub = sub; this.rotateXYZ = rotateXYZ; } } 由于渲染需要面的顶点，同时想要渲染模拟水面效果需要面具有尽量多的顶点属性中定义了面的细分等级。\n现在还需要一个方法可以获取面的所有顶点。\n1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 //续以上类中代码 getVertexData() { var data = []; var meshCoordSize = []; meshCoordSize[0] = this.size[0] / (this.sub + 1); meshCoordSize[1] = this.size[1] / (this.sub + 1); for(var i = 0; i \u0026lt; (this.sub + 1); i++) { var p1 = [0, i * meshCoordSize[1], 0]; var p2 = [0, (i+ 1) * meshCoordSize[1], 0]; var p3 = [p1[0] + meshCoordSize[0], p1[1], 0]; for(var j = 0; j \u0026lt; ((this.sub + 1) * 2); j++) { data.push(p1[0], p1[1], p1[2]); data.push(p2[0], p2[1], p2[2]); data.push(p3[0], p3[1], p3[2]); p1 = p2; p2 = p3; if(p1[0] == p2[0]) { p3 = [p1[0] + meshCoordSize[0], p1[1], 0]; } else { p3 = [p2[0], p2[1] + meshCoordSize[1], 0]; } } } var res = new Float32Array(data); return res; } 以上代码生成的坐标是四边形一角放在原点，在第一象限且两个邻边对齐坐标轴，之后还要进行矩阵变换到对应position和rotateXYZ。 webgl中面按三角面渲染，顶点数据会有重复顶点。其实可以通过顶点索引数据来避免顶点数据重复，但是同样位置的顶点在不同的面可能具有不同的顶点颜色和法线，视情况而定。这里采用重复情况。\n同时顶点的读取要满足三个三个构成正确的三角形，要注意顶点填入的顺序。这里参考webgl，drawArray函数绘制TRIANGLES_STRIP情况下的绘制顺序填入。\n既对于ABCDEF点,先填入ABC，然后BCD，CDE和DEF共4个三角面。\nhtml环境配置 创建canvas和获取webgl上下文 1 2 3 4 5 6 7 8 \u0026lt;body\u0026gt; \u0026lt;canvas id=\u0026#39;glcanvas\u0026#39; width=\u0026#39;1920\u0026#39; heigth=\u0026#39;1080\u0026#39; style=\u0026#39;border: 2px solid black;\u0026#39;\u0026gt;\u0026lt;/canvas\u0026gt; \u0026lt;/body\u0026gt; \u0026lt;script\u0026gt; const canvas = document.getElementById(\u0026#34;glcanvas\u0026#34;); const gl = canvas.getContext(\u0026#39;webgl\u0026#39;); \u0026lt;/script\u0026gt; 着色器的创建，编译和链接 创建着色器资源 通过以下代码创建着色器资源\n1 2 3 4 5 6 7 const vertexShaderSource = ` 你的顶点着色器代码 ` const fragmentShaderSource = ` 你的片员着色器代码 ` 着色器创建，编译 1 2 3 4 5 const shader = gl.createShader(type); //type为gl.VERTEX_SHADER,gl.FRAGMENT_SHADER gl.shaderSource(shader, source); gl.compileShader(shader); gl.getShaderParameter(shader, gl.COMPILE_STATUS); 链接,创建program 1 2 3 4 const program = gl.createProgram(); gl.attachShader(program, vertexShader); gl.attachShader(program, fragmentShader); gl.linkProgram(program); 以上代码在各个项目可以多次使用，可以封装为函数写在mjs文件中。\n创建渲染场景 实例化上文创建的Camera类，PlaneMesh类。如果可以也可以创建并实例化灯光类。\n1 2 3 const camera = new Camera(); const plane = new PlaneMesh(); // const light = new Light(); 渲染循环 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 function draw(currentTime) { gl.viewport(0, 0, gl.canvas.width, gl.canvas.height); gl.clearColor(1.0, 1.0, 1.0, 1.0); gl.enable(gl.DEPTH_TEST); gl.clear(gl.COLOR_BUFFER_BIT | gl.DEPTH_BUFFER_BIT); gl.useProgram(program); //变换矩阵的计算 const m = mat4.create(); const v = camera.getViewMatrix(); const p = camera.getProjectionMatrix(canvas); const m_mvp = mat4.create(); mat4.multiply(m_mvp, v, m); mat4.multiply(m_mvp, p, m_mvp); //配置program中着色器的uniform和attribute //其他配置 gl.drawArrays(mode, first, count); } function render(currentTime) { draw(currentTime); requestAnimationFrame(render); } render(); 配置program中着色器的uniform和attribute uniform 1 2 3 4 //示例 const uniformLoc = gl.getUniformLocation(program, \u0026#34;着色器中对应变量名\u0026#34;); gl.uniform1f(uniformLoc, 数据); //1f为float；2f，3f和4f为vec2，vec3和vec4；Matrix4f是4 x 4矩阵；1fv加一个v是数组 attribute 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 //示例 const positionBuffer = gl.createBuffer(); gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer); gl.bufferData(gl.ARRAY_BUFFER, vertexData, gl.STATIC_DRAW); const positionAttribLoc = gl.getAttribLocation(program, \u0026#39;a_position\u0026#39;); gl.enableVertexAttribArray(positionAttribLoc); gl.bindBuffer(gl.ARRAY_BUFFER, positionBuffer); gl.vertexAttribPointer( positionAttribLoc, 3, gl.FLOAT, false, 0, 0 ); 封装 以上代码根据着色器需要的参数有长有短，一般情况就需要顶点位置，颜色，法线和mvp变换矩阵。 所以可以和着色器资源封装在一起，简化调用，提高可读性。如下：\n1 2 3 4 5 6 7 8 9 //配置代码 const waveUniformInfoLoc = waveShader.getuniformInfoLoc(gl, program); const waveUniformInfo = { mvp : m_mvp, resolution : [gl.canvas.width, gl.canvas.height], time : currentTime }; waveShader.setAttribute(gl, program, vertexData); waveShader.setUniformInfo(gl, waveUniformInfoLoc, waveUniformInfo); 着色器shader代码编写 顶点着色器vertexShader 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 const vertexShaderSource = ` attribute vec3 a_position;//顶点 uniform mat4 u_mvp;//mvp变换矩阵 uniform float u_time; //顶点颜色和法线 varying vec3 vColor; varying vec3 vNormal; float computeWaveHeight(vec2 pos, float time) { float height = 0.; height += sin(pos.x * 3. + time * 1.5) * 2.; height += sin(pos.x * 3. + pos.y * 1.2 + time * 1.5) * 3.; return height; } vec3 calculateNormal(vec3 pos, float time) { float eps = 0.1; // 使用中心差分法计算法向量 float hL = computeWaveHeight(vec2(pos.x - eps, pos.z), time); float hR = computeWaveHeight(vec2(pos.x + eps, pos.z), time); float hD = computeWaveHeight(vec2(pos.x, pos.z - eps), time); float hU = computeWaveHeight(vec2(pos.x, pos.z + eps), time); vec3 normal = normalize(vec3(hL - hR, 2.0 * eps, hD - hU)); return normal; } void main(){ vec3 wavePosition = a_position; wavePosition.y += computeWaveHeight(wavePosition.xz, u_time) ; vNormal = calculateNormal(wavePosition, u_time);//法线计算 //因为没有传入光照参数就直接在这里写了，假设点光在(0, 10, 0) vec3 l = normalize(vec3(0., 10., 0) - wavePosition);//光线方向 float zita = dot(vNormal, l);//角度计算 float I = max(cos(zita), 0.);//简单的光强计算 vColor = mix(vec3(0., 0., 0.5), vec3(0.7, 0.7, 1.), I);//根据光强混合顶点颜色 gl_Position = u_mvp * vec4(wavePosition, 1.0); } ` 片元着色器fragShader 1 2 3 4 5 6 7 8 9 10 const fragmentShaderSource = ` precision mediump float; varying vec3 vColor; varying vec3 vNormal; void main() { gl_FragColor = vec4(vColor, 1.0); } `; 最终效果 可以把plane的大小，旋转，细分，相机位置，观察位置和着色器中计算浪高的参数暴露出来，使用html的lable 和input修改来调整画面到想要的效果。\n遇到的问题 变换矩阵计算 计算变换到NDC空间的矩阵是由从模型空间-\u0026gt;世界空间-\u0026gt;视图空间-\u0026gt;透视裁剪空间三次变换。\n分别使用modelMatrix，viewMatrix，ProjectionMatrix，乘在一起叫mvp矩阵。\n写成式子应该是 $P\\times V\\times M\\times vertexPoint$,顺序出错会导致变换出问题，看不到内容。\n也有可能是计算各个矩阵时参数没有设定好，如使用glmatrix库，计算透视ProjectionMatrix，near和far参数应该是大于0的数，且far \u0026gt; near。\n模糊问题 最开始在canvas属性的设置中，没看清代码，只设置CSSstyle中的width，height。 但是应该设置canvas的html属性，这个才是和分辨率相关的。CSSstyle的是画布的物理尺寸和分辨率无关。\n1 2 3 4 //正确写法 \u0026lt;canvas id=\u0026#34;glcanvas\u0026#34; style=\u0026#34;border: 2px solid black;\u0026#34; width=\u0026#34;1920\u0026#34; height=\u0026#34;1080\u0026#34;\u0026gt;\u0026lt;/canvas\u0026gt; //错误写法 \u0026lt;canvas id=\u0026#34;glcanvas\u0026#34; style=\u0026#34;border: 2px solid black; width: 100%; heigth: 100%\u0026#34;\u0026gt;\u0026lt;/canvas\u0026gt; 相关链接:\n本文矩阵计算使用glmatrix。\nwebgl代码及代码组织学习自MDN Web Docs和webgl基础\n","date":"2025-08-10T23:31:11+08:00","image":"https://reimunai.github.io/p/webgl%E6%B8%B2%E6%9F%93%E6%B0%B4%E9%9D%A2%E6%B3%A2%E6%B5%AA/image-3_hu_3bbd4ba14f6c4388.png","permalink":"https://reimunai.github.io/p/webgl%E6%B8%B2%E6%9F%93%E6%B0%B4%E9%9D%A2%E6%B3%A2%E6%B5%AA/","title":"webgl渲染水面波浪"}]