结冰的离季
本帖最后由 结冰的离季 于 2022-4-19 20:58 编辑

前言

硬要求
本文也发布于博客(阅读体验更好):https://www.iseason.top/mc-scope-mining

背景故事

了解需要注意的问题
    起因是我需要实现一个范围挖掘 功能,类似于 x * y * z 这样的一个区域,相信很多人在各种mod\插件都见到过了。开始我以为这么多见的功能应该不是很难实现,但但我真正上手开发时却发现没那么简单。

逻辑
按照一般的逻辑是监听BlockBreakEvent 获取方块 block,然后:
  • 判断玩家方向比如对player用 Entity::getFacing()获取一个 BlockFace 枚举类型,然后对这个枚举类型进行判断你就可以知道玩家面向的方向,然后你就会发现它无法得知玩家向上还是向下,于是利用 LivingEntity::getEyeLocation() 获取玩家眼部位置eyeLocation,然后 使用 Location::getPitch() 方法获取玩家仰角(以实体为中心绕自身X轴旋转的角度,对仰角值进行判断。然后就可以知道玩家上下东西南北的朝向了。
    当然除了使用 Entity::getFacing()方法还可以使用 Location::getYaw()方法(以实体为中心绕自身Y轴旋转的角度,也叫偏航角)

  1. @EventHandler
  2.     fun onBlockBreakEvent(event: BlockBreakEvent) {
  3.         val block = event.block //方块
  4.         val player = event.player //玩家
  5.         var facing = player.facing // BlockFace ->NORTH,EAST,SOUTH,WEST
  6.         val pitch = player.eyeLocation.pitch //0表示水平朝向.90表示向下,-90表示向上
  7.         facing = if (pitch < -45.0) BlockFace.DOWN else if (pitch > 45.0) BlockFace.UP else facing
  8.     }
复制代码
P.S. 这里存在一个问题,要以玩家方向为基准还是以指针在被挖掘方块上的方向为基准





正篇
本篇将解决一个问题: 如何才能做到任意角度任意形状范围挖掘?      
本文不会深入解释理论知识,只谈运用
思考
范围挖掘,其实原版就有,不过原版是在一个点上,也就是指哪挖哪。那么怎么把一个点拓展到一个区域呢?

你可以想象当玩家挖方块时,存在一个过方块中点的平面,且平面与玩家视线垂直 ,从平面上出发且与平面垂直的向量叫平面的法线/法向量


当然范围挖掘不只是一个面,但我们可以从面出发来寻找解决问题办法。

说到点和法向量这你可能想起这个公式:
但是求这个平面对于解决问题没什么帮助,你无法由面得出想要的坐标。(没错,我踩坑过了,浪费了几小时)
以上都是我解决问题中的思考。当我发现不能解决问题时,我换了另一个思路


思路转换
在 3d 建模软件或者游戏引擎 中,对物体的平移、旋转、缩放是基本功能,那么我们的“平面”可以当做从原点经过一系列变换到玩家面前的,而构成 一个面/物体的基本单元就有的存在,这个点就是我们需要求的。

那么在计算机图形学中,要怎么对一个图形/物体进行变换?

答案就是 变换矩阵

变换矩阵

变换矩阵是一种矩阵,属于线性代数知识。
数学上,一个 m x n矩阵是一个由 m行(row)n列(column)元素排列成的矩形阵列。矩阵里的元素可以是数字、符号或数学式。点我了解详情 简单来说,矩阵的存在是为了解决数学问题,比如解线性方程组

说到方程,这里总不会不认识吧。

根据方程的解法可以窥见矩阵的乘法,请自行点击链接文字了解

从二维谈起
在解决我们的问题前,先要搞清楚二维平面上的变换。

推荐文章:仿射变换及其变换矩阵的理解

假设在 XOY 坐标系下 有一个矩形,矩形的4个点用(Xn,Yn)表示,将矩形缩放到原来的1/2。

设缩放后的点为(Xm,Ym),则有以下2个公式

如果是X轴缩放Sx倍,Y轴缩放Sy倍,则公式为
那么如上图 矩阵 A 就是一个变换矩阵。由于它起着缩放的作用,故而又分为缩放矩阵,根据作用,还有平移矩阵,旋转矩阵等,他们都有现成的公式,我就不再推导了,直接用就行。


三维变换矩阵

三维变换矩阵是用于对三维空间点进行变换的矩阵
我这里直接给结果,想知道怎么推导的可自行搜索。参考链接: https://cloud.tencent.com/developer/article/1005561

将以上矩阵相乘即可得出一个综合变换矩阵A ( 4x4的 ),将原始坐标3个值组成的矩阵设为 B(x,y,z,1) 经过变换后的坐标设为C(X,Y,Z,1)
则  C = B X A (左乘)
你会发现我标识了左乘,那就是还有右乘。矩阵的左乘和右乘结果是不一样的。比如旋转矩阵的图,将右边2个交换位置是不能成立的。如何通俗理解矩阵左乘和右乘的区别?


代码实现(Kotlin)
写了这么多该来点实际的了
  1. /**
  2. * 根据变换生成变换矩阵,支持平移、旋转 和缩放
  3. */
  4. fun getTransformMatrix(
  5.     moveX: Double = 0.0,
  6.     moveY: Double = 0.0,
  7.     moveZ: Double = 0.0,
  8.     rotateX: Double = 0.0,
  9.     rotateY: Double = 0.0,
  10.     rotateZ: Double = 0.0,
  11.     scaleX: Double = 1.0,
  12.     scaleY: Double = 1.0,
  13.     scaleZ: Double = 1.0,
  14. ): Matrix<Double> {
  15.     //平移矩阵
  16.     val translationMatrix = matrixOf(
  17.         4, 4, //列和行
  18.         1.0, 0.0, 0.0, moveX,
  19.         0.0, 1.0, 0.0, moveY,
  20.         0.0, 0.0, 1.0, moveZ,
  21.         0.0, 0.0, 0.0, 1.0
  22.     )
  23.     // X 轴旋转矩阵
  24.     val rotationMatrixX = matrixOf(
  25.         4, 4,
  26.         1.0, 0.0, 0.0, 0.0,
  27.         0.0, cos(rotateX), -sin(rotateX), 0.0,
  28.         0.0, sin(rotateX), cos(rotateX), 0.0,
  29.         0.0, 0.0, 0.0, 1.0
  30.     )
  31.     // Y 轴旋转矩阵
  32.     val rotationMatrixY = matrixOf(
  33.         4, 4,
  34.         cos(rotateY), 0.0, sin(rotateY), 0.0,
  35.         0.0, 1.0, 0.0, 0.0,
  36.         -sin(rotateY), 0.0, cos(rotateY), 0.0,
  37.         0.0, 0.0, 0.0, 1.0
  38.     )
  39.     // Z 轴旋转矩阵
  40.     val rotationMatrixZ = matrixOf(
  41.         4, 4,
  42.         cos(rotateZ), -sin(rotateZ), 0.0, 0.0,
  43.         sin(rotateZ), cos(rotateZ), 0.0, 0.0,
  44.         0.0, 0.0, 1, 0.0,
  45.         0.0, 0.0, 0.0, 1.0
  46.     )
  47.     //缩放矩阵
  48.     val scalingMatrix = matrixOf(
  49.         4, 4,
  50.         scaleX, 0.0, 0.0, 0.0,
  51.         0.0, scaleY, 0.0, 0.0,
  52.         0.0, 0.0, scaleZ, 0.0,
  53.         0.0, 0.0, 0.0, 1.0
  54.     )
  55.     // dot 方法 使用了 infix 修饰。与 a.dot(b) 一样 将2个矩阵相乘,是原来是左乘,我改成了右乘
  56.     val rotationMatrix = rotationMatrixY dot rotationMatrixX dot rotationMatrixZ
  57.     val transform = translationMatrix dot rotationMatrix dot scalingMatrix
  58.     return transform
  59. }
复制代码


  1. /**
  2. * 在给定方块周围获取与玩家视角平行的 xyz 矩形区域内的方块
  3. * @param target 起始方块
  4. * @param rangeX 宽度
  5. * @param rangeY 高度
  6. * @param rangeZ 深度
  7. * @return 所有方块的集合(除了 target)
  8. */
  9. fun Player.getScopeBlocksByMatrix(target: Block, rangeX: Int, rangeY: Int, rangeZ: Int): Set<Block> {
  10.     val set = mutableSetOf<Block>()
  11.     val location = target.location
  12.     val world = location.world
  13.     // X轴旋转角(角度 转 弧度)
  14.     val eyePitch = eyeLocation.pitch / 180.0 * Math.PI
  15.     //Y轴旋转角(角度 转 弧度)
  16.     val eyeYaw = eyeLocation.yaw / 180.0 * Math.PI
  17.     //生成变换矩阵
  18.     val transformMatrix =
  19.         getTransformMatrix(
  20.             location.x + 0.5,
  21.             location.y + 0.5,
  22.             location.z + 0.5,
  23.             eyePitch,
  24.             -eyeYaw
  25.         )
  26.     val halfRangeX = rangeX / 2
  27.     val halfRangeY = rangeY / 2
  28.     //宽
  29.     for (x in -halfRangeX until rangeX - halfRangeX) {
  30.         //高
  31.         for (y in -halfRangeY until rangeY - halfRangeY) {
  32.             //深度,从被挖的方块往里
  33.             for (z in 0 until rangeZ) {
  34.                 val block = Location(world, x.toDouble(), y.toDouble(), z.toDouble()).transform(transformMatrix).block
  35.                 set.add(block)
  36.             }
  37.         }
  38.     }
  39.     set.remove(target)
  40.     return set
  41. }
复制代码


拓展
不止是三种基本变换,切变、反射、投影等变换也可以通过变换矩阵来完成,利用矩阵来解决问题其实更适合粒子特效之类的显示功能

利用这个图形学的基础知识,我们便可在MC中构建一个建模引擎。来实现任意形状的显示。如果再加上时间维度,那么像MAYA一样来做动画也并非天方夜谭。

做了个旋转与缩放的动画




另一个解~坐标系转换
    看到这可能会有人说:矩阵好难啊!有没有更简单的办法。答案是有的。

理论
我们都知道MC只有一个世界坐标系(O-xyz 右手笛卡尔坐标系 )。但是我们完全可以创建一个新的坐标系,通过两个坐标系之间的映射关系来转换两个坐标系的点。
假设有如下2个坐标系Oxy 与 O'x'y'
则有
根据单位向量的性质,坐标系中任何一个点的向量都可以由位于该坐标系坐标轴上的单位向量表示,比如点(1,1) = OX+OY ;(1,2) = OX+2*OY

设 O'点在Oxy坐标系下的坐标为(Xo,yo) 则:O' = XoOX + YoOY = Xo(1,0)+Yo(0,1) = (Xo,Yo) = OO'
设P'点在O'X'Y'坐标系下的坐标为 P'(x',y') 则O'P' = (X',Y')

由向量加法: AB+BC = BC 可知 OP' = OO'+O'P' = (Xo,Yo)+(X',Y')= (Xo,Yo)+X' * O'X + Y' * O'Y'

也就是说,在已知O'点的OXY坐标与P'点的O'X'Y'坐标和O'X'Y'坐标系的两个坐标轴的单位向量的情况下,就能得到坐标系O'X'Y'到OXY的映射关系。

简单来说,知道了玩家坐标和以玩家头部为中心的坐标系3个轴向上在世界坐标系中的单位向量,就能够将以玩家为坐标系中的点映射到世界坐标系中。


实践
玩家头部坐标很容易得到,那么只差玩家坐标系了。
在第三人称下打开F3+B 可以开启判定箱,你会发现玩家头部有条蓝色的线,这就是玩家的视线。而BukkitAPI 恰好有个 LivingEntity:;getEyeLocation().getDirection()方法,这个就是玩家视线方向的单位向量

通过查阅MCWIKI:https://minecraft.fandom.com/zh/wiki/%E5%91%BD%E4%BB%A4/execute#rotated
可以知道当玩家的yawpitch都为0时(没有roll,默认为0),玩家朝向的是正南方,也就是世界坐标系Z轴正方向

所以Location里的getDirection()方法得到的单位向量是Z轴的。

但是BukkitAPI并没有提供玩家的另外2个轴,我们需要至少2个单位向量才能够利用正交性算出最后一个单位向量。

通过翻阅getDirection()的源码可以知道它是如何实现的
  1. public Vector getDirection() {
  2.         Vector vector = new Vector();

  3.         double rotX = this.getYaw();
  4.         double rotY = this.getPitch();

  5.         vector.setY(-Math.sin(Math.toRadians(rotY)));

  6.         double xz = Math.cos(Math.toRadians(rotY));

  7.         vector.setX(-xz * Math.sin(Math.toRadians(rotX)));
  8.         vector.setZ(xz * Math.cos(Math.toRadians(rotX)));

  9.         return vector;
  10.     }
复制代码

我们知道Z轴与X轴在yaw上差了90°,其他不变,所以可以求出X轴的单位向量
  1. /**
  2. * 根据坐标yaw和pith值获取X方向的单位向量
  3. * @return X方向的单位向量
  4. */
  5. fun Location.getNormalX(): Vector {
  6.     val vector = Vector()
  7.     val rotX = yaw.toDouble()
  8.     //row =0 , pitch = 0
  9.     vector.x = cos(Math.toRadians(rotX))
  10.     vector.z = sin(Math.toRadians(rotX))
  11.     return vector
  12. }
复制代码

最后我们就可以得到3个轴的单位向量了
  1. /**
  2. * 获取玩家相对坐标系的3个坐标轴在世界坐标系下的单位向量
  3. * @return 一个向量数组, index 0 为 X轴,1 为 Y轴,2 为Z轴
  4. */
  5. fun Player.getRelativeCoordinate(): Array<Vector> {
  6.     val eyeLocation = eyeLocation
  7.     val normalX = eyeLocation.getNormalX()  // X 轴 其实就是getDirection()方法,我改了个名字
  8.     val normalZ = eyeLocation.getNormalZ()  //Z 轴
  9.     val normalY = normalX.clone().crossProduct(normalZ).multiply(-1) //叉乘得出Y轴,测试发现朝下,于是乘以-1使其朝上
  10.     return arrayOf(normalX, normalY, normalZ)
  11. }
复制代码

测试下,完美

然后封装一下由相对坐标计算世界坐标的方法

  1. // 以自身为原点和相对坐标系获取世界坐标
  2. fun Location.getRelativeByCoordinate(
  3.     coordinate: Array<Vector>, //坐标轴的单位向量
  4.     x: Double,
  5.     y: Double,
  6.     z: Double
  7. ): Location {
  8.     return clone().apply {
  9.         add(coordinate[0].clone().multiply(x))
  10.         add(coordinate[1].clone().multiply(y))
  11.         add(coordinate[2].clone().multiply(z))
  12.     }
  13. }
复制代码
最后收尾
  1. /**
  2. * 由相对坐标系算法获取相对范围内的方块
  3. * @param target 起始方块
  4. * @param rangeX 宽度
  5. * @param rangeY 高度
  6. * @param rangeZ 深度
  7. * @return 所有方块的集合(除了 target)
  8. */
  9. fun Player.getScopeBlocksByVector(target: Block, rangeX: Int, rangeY: Int, rangeZ: Int): Set<Block> {
  10.     val set = mutableSetOf<Block>()
  11.     //玩家与准星上的方块之间的距离
  12.     val baseX = target.location.apply {
  13.         x += 0.5
  14.         y += 0.5
  15.         z += 0.5
  16.     }.distance(eyeLocation)
  17.     val halfRangeX = rangeX / 2
  18.     val halfRangeY = rangeY / 2
  19.     //获取相对坐标系
  20.     val relativeCoordinate = getRelativeCoordinate()
  21.     val eyeLocation = eyeLocation
  22.     //宽
  23.     for (x in -halfRangeX until rangeX - halfRangeX) {
  24.         //高
  25.         for (y in -halfRangeY until rangeY - halfRangeY) {
  26.             //深度,从被挖的方块往里
  27.             for (z in 0 until rangeZ) {
  28.                 //相对坐标转世界坐标并获取对应的方块
  29.                 val block = eyeLocation.getRelativeByCoordinate(
  30.                     relativeCoordinate,
  31.                     x.toDouble(),
  32.                     y.toDouble(),
  33.                     baseX + z.toDouble()
  34.                 ).block
  35.                 set.add(block)
  36.             }
  37.         }
  38.     }
  39.     set.remove(target)
  40.     return set
  41. }
复制代码


后话

上述2中算法各有优缺点:
  • 变换矩阵适合复杂变换,世界坐标中的适合物体变换
  • 坐标系转换效率高一些(我写的代码,1W次循环中变换矩阵30ms,坐标系变换5ms),但是仅仅是坐标映射,需要其他变换还是得另做处理


      写这篇文章的目的不是说这个算法多么nb。范围挖掘算法只是一个引子,由实际问题引出图形学知识,为一些在图形显示、动画方面遇到问题的人指明方向。程序员不一定懂算法,懂算法也不一定会图形学,本文没有去推导、深入解释图形学知识,就是为了让不懂的人能够了解图形学有什么用,以后遇到时知道可以从哪个方向解就行。

      MC插件开发者何许多,但大部分插件都缺少创新,其中固有需求的影响,也有能力的限制,将自己局限在MC的世界中。我们不妨向其他游戏对齐,市面上有多游戏引擎,比如Unity、Unreal等,他们创造出了一个广阔的游戏世界,Minecraft又何尝不是一个游戏引擎,别的引擎能做的我们也能做。

      服务端插件的最大局限性是无法修改客户端,只能由服务器端处理,这样固然有极大的限制,但是全交给服务器端来做也未尝不是好事,比如反作弊,大部分游戏外挂的实现都是通过修改客户端的,服务端没有对客户端数据进行检查,于是导致了出现正常情况下不会出现的数据,服务端插件实现的功能全由服务端控制,客户端自然就无法轻易欺骗服务端,反作弊能力大于客户端mod。

      其实我对于图形学我也是一知半解,知识全来自我们的专业课,学的不咋地,不深入探讨也有这个因素,但这并不影响我在MC中推广它,有错误请多多指教。


P.S. bbs 的md语法支持了个寂寞,排版好痛苦




🍞bread
我一开始想的是直接把目标方块周围方块全部移除不就行了,看完大开眼界还好线代知识还没还回去勉强能理解一点

子德
我的评价是:不如转成极坐标系来运算

子德
有没有可能,我只是说可能,获取eyeLocation,然后和玩家头部相邻六个方块的distance取min,就能知道视野朝向了

结冰的离季
本帖最后由 结冰的离季 于 2022-4-14 12:26 编辑
子德 发表于 2022-4-14 08:34
我的评价是:不如转成极坐标系来运算

首先输入点都是笛卡尔坐标系下的,用极坐标也离不开换算成笛卡尔坐标系的点,而且对于在极坐标构建点集来组成想要的形状,太麻烦了。本质上算法跟第二种办法是一样的,只是概念不一样。
而且本文章是讨论图形学在mc中的应用,不是说这样做一定是最好的


baiyi123

guixinyang
看起来很不错,不过学不会

TCmc
《写 一 个 插 件 使 我 拓 展 了 许 多 数 学 知 识》

我式谁
四姑一,大佬啊我看了半天我才想起来我不会编程awa

cmzzsy3433
对于新手来说这个还是挺方便的,大部分的问题上面都有,不得不说解决的很多多多的 问题题

长安离别信
,666666666666666

Ph-苯
旋转矩阵原来是四阶的呀,我之前一直用三维的,结果写的特别复杂……(还是直接用叉乘吧

结冰的离季
Ph-苯 发表于 2022-4-19 20:03
旋转矩阵原来是四阶的呀,我之前一直用三维的,结果写的特别复杂……(还是直接用叉乘吧) ...

因为三维坐标的位移需要4阶的,所以统一用四阶了

Ph-苯
结冰的离季 发表于 2022-4-19 20:24
因为三维坐标的位移需要4阶的,所以统一用四阶了

三维坐标的位移需要四阶?不是只有旋转才要四阶吗?

结冰的离季
Ph-苯 发表于 2022-4-20 21:57
三维坐标的位移需要四阶?不是只有旋转才要四阶吗?

就我所知就缩放不用

cmzzsy3433
奈何自己没文化  一句wc走天下

hb腐竹
大佬啊我看了半天我才想起来我不会编程awa