📐

理想の2Dベクトルクラスを考える

2023/09/02に公開
コード
vec2.rb
require "forwardable"

class Vec2
  # https://github.com/godotengine/godot/blob/44e399ed5fa895f760b2995e59788bdb49782666/core/math/math_defs.h#L53
  UNIT_EPSILON = 0.00001
  private_constant :UNIT_EPSILON

  class << self
    def [](...)
      new(...)
    end

    def splat(v)        = new(v, v)

    def zero_element    = 0.0
    def one_element     = 1.0

    def zero            = splat(zero_element)
    def one             = splat(one_element)
    def neg_one         = splat(-one_element)

    def max             = splat(Float::INFINITY)
    def min             = splat(-Float::INFINITY)

    def infinity        = max
    def neg_infinity    = min

    def nan             = splat(0.0 / 0.0)

    def x               = new(one_element, zero_element)
    def y               = new(zero_element, one_element)
    def neg_x           = new(-one_element, zero_element)
    def neg_y           = new(zero_element, -one_element)

    def left            = new(-one_element, zero_element)
    def right           = new(+one_element, zero_element)
    def up              = new(zero_element, -one_element)
    def down            = new(zero_element, +one_element)

    def axes            = [x, y]

    def rand(...)       = new(Kernel.rand(...), Kernel.rand(...))
    def rand_norm(...)  = new(*normal_rand(...))

    private

    def normal_rand(mu: 0, sigma: 1.0, random: Random.new)
      r1 = random.rand
      r2 = random.rand
      z1 = Math.sqrt(-2 * Math.log(r1)) * Math.sin(2 * Math::PI * r2)
      z2 = Math.sqrt(-2 * Math.log(r1)) * Math.cos(2 * Math::PI * r2)
      n1 = mu + sigma * z1
      n2 = mu + sigma * z2
      [n1, n2]
    end
  end

  extend Forwardable
  def_delegators :to_a, :each
  def_delegators :to_a, :==, :hash, :<=>
  def_delegators :to_a, :to_ary, :sum, :[]

  include Enumerable

  attr_accessor :x, :y

  def initialize(x = self.class.zero_element, y = self.class.zero_element)
    @x = x
    @y = y
  end

  def eql?(other)
    self == other
  end

  def to_a
    [x, y]
  end

  def as_json
    { x: x, y: y }
  end

  def to_s
    "(#{x}, #{y})"
  end

  def to_h(prefix: "", suffix: "")
    {
      "#{prefix}x#{suffix}".to_sym => x,
      "#{prefix}y#{suffix}".to_sym => y,
    }
  end

  def inspect
    to_s
  end

  # def scale(s)
  #   self * s
  # end

  def min(other) = self.class.new([x, other.x].min, [y, other.y].min)
  def max(other) = self.class.new([x, other.x].max, [y, other.y].max)

  def clamp(min, max)
    min.cmple(max).all? or raise "clamp: expected min <= max"
    max(min).min(max)
  end

  # https://github.com/godotengine/godot/blob/44e399ed5fa895f760b2995e59788bdb49782666/core/math/vector2.cpp#L138
  def snapped(step)
    self.class.new(Math.snapped(x, step.x), Math.snapped(y, step.y))
  end

  def min_element = to_a.min
  def max_element = to_a.max

  def cmpeq(other) = self.class.new(x == other.x, y == other.y)
  def cmpne(other) = self.class.new(x != other.x, y != other.y)
  def cmpge(other) = self.class.new(x >= other.x, y >= other.y)
  def cmpgt(other) = self.class.new(x >  other.x, y >  other.y)
  def cmple(other) = self.class.new(x <= other.x, y <= other.y)
  def cmplt(other) = self.class.new(x <  other.x, y <  other.y)

  def abs
    self.class.new(x.abs, y.abs)
  end

  def sign
    self.class.new(
      x.negative? ? -1 : 1,
      y.negative? ? -1 : 1,
    )
  end

  def signum
    sign
  end

  def copysign(other)
    abs * other.signum
  end

  def finite?
    all?(&:finite?)
  end

  def nan?
    any?(&:nan?)
  end

  def nan_mask
    self.class.new(x.nan?, y.nan?)
  end

  def zero?    = all?(&:zero?)
  def nonzero? = all?(&:nonzero?)

  def length_squared = dot(self)
  def length         = Math.sqrt(dot(self))

  def norm           = length
  def mag            = length
  def magnitude      = length

  def length_recip   = 1.0 / length

  def distance_to(other)         = (other - self).length
  def distance_squared_to(other) = (other - self).length_squared

  ################################################################################

  def normalize
    normalized = self * length_recip
    normalized.finite? or raise
    normalized
  end

  def try_normalize
    rcp = length_recip
    if rcp.finite? && rcp.positive?
      self * rcp
    end
  end

  def normalize_or_zero
    try_normalize || self.class.zero
  end

  def normalized?
    (length_squared - 1.0).abs < UNIT_EPSILON
  end

  ################################################################################

  def project_onto(other)
    other_len_sq_rcp = 1.0 / other.length_squared
    other_len_sq_rcp.finite? or raise
    other * dot(other) * other_len_sq_rcp
  end

  def reject_from(other)
    self - project_onto(other)
  end

  def project_onto_normalized(other)
    other.normalized? or raise
    other * dot(other)
  end

  def reject_from_normalized(other)
    self - project_onto_normalized(other)
  end

  ################################################################################

  def slide(normal)
    self - project_onto_normalized(normal)
  end

  def bounce(normal)
    self - project_onto_normalized(normal) * 2
  end

  def reflect(normal)
    project_onto_normalized(normal) * 2 - self
  end

  ################################################################################

  def round(...)    = self.class.new(x.round(...), y.round(...))
  def floor(...)    = self.class.new(x.floor(...), y.floor(...))
  def ceil(...)     = self.class.new(x.ceil(...), y.ceil(...))
  def truncate(...) = self.class.new(x.truncate(...), y.truncate(...))
  def trunc(...)    = truncate(...)
  def fract         = self - floor

  ################################################################################

  def exp
    self.class.new(Math.exp(x), Math.exp(y))
  end

  def pow(...)
    self.class.new(x.pow(...), y.pow(...))
  end

  def recip
    self.class.new(1.0 / x, 1.0 / y)
  end

  def lerp(other, s)
    self + (other - self) * s
  end

  def mix(...)
    lerp(...)
  end

  def abs_diff_eq(other, max_abs_diff)
    sub(other).abs.cmple(self.class.splat(max_abs_diff)).all?
  end

  ################################################################################

  def clamp_length(min, max)
    min <= max or raise
    length_sq = length_squared
    if length_sq < min * min
      min * self / Math.sqrt(length_sq)
    elsif length_sq > max * max
      max * self / Math.sqrt(length_sq)
    else
      self
    end
  end

  def clamp_length_max(max)
    length_sq = length_squared
    if length_sq > max * max
      max * self / Math.sqrt(length_sq)
    else
      self
    end
  end

  def clamp_length_min(min)
    length_sq = length_squared
    if length_sq < min * min
      min * self / Math.sqrt(length_sq)
    else
      self
    end
  end

  ################################################################################

  class << self
    def from_angle(angle)
      new(Math.cos(angle), Math.sin(angle))
    end
  end

  # https://github.com/godotengine/godot/blob/44e399ed5fa895f760b2995e59788bdb49782666/core/math/vector2.cpp#L81
  def angle_to(other)
    Math.atan2(cross(other), dot(other))
  end

  def angle_between(other)
    angle_to(other)
  end

  # https://github.com/godotengine/godot/blob/44e399ed5fa895f760b2995e59788bdb49782666/core/math/vector2.cpp#L84
  def angle_to_point(other)
    (other - self).angle
  end

  def angle
    Math.atan2(y, x)
  end

  def rotate(other)
    # https://docs.rs/nannou_core/0.18.0/src/nannou_core/math.rs.html#92
    unless other.kind_of?(self.class)
      other = V.from_angle(other)
    end

    # https://docs.rs/glam/lait/src/glam/f32/vec2.rs.html#662
    self.class.new(
      x * other.x - y * other.y,
      y * other.x + x * other.y,
    )
  end

  # Experimental
  def rotate_angle_add(rad)
    self.class.from_angle(angle + rad) * length
  end

  ################################################################################

  def dot(other)
    x * other.x + y * other.y
  end

  def inner_product(...) = dot(...)

  ################################################################################

  def cross(other)
    x * other.y - y * other.x
  end

  def perp_dot(...)      = cross(...)
  def outer_product(...) = cross(...)

  ################################################################################

  def perp
    right90
  end

  def right90
    self.class.new(-y, x)
  end

  def left90
    self.class.new(y, -x)
  end

  ################################################################################

  def add(other)
    if other.kind_of?(self.class)
      self.class.new(x + other.x, y + other.y)
    else
      self.class.new(x + other, y + other)
    end
  end

  def sub(other)
    if other.kind_of?(self.class)
      self.class.new(x - other.x, y - other.y)
    else
      self.class.new(x - other, y - other)
    end
  end

  def mul(other)
    if other.kind_of?(self.class)
      self.class.new(x * other.x, y * other.y)
    else
      self.class.new(x * other, y * other)
    end
  end

  # pow and `**` are different
  # float doesn't have pow, but it does have `**`.
  def **(other)
    if other.kind_of?(self.class)
      self.class.new(x**other.x, y**other.y)
    else
      self.class.new(x**other, y**other)
    end
  end

  def /(other)
    if other.kind_of?(self.class)
      self.class.new(x / other.x, y / other.y)
    else
      self.class.new(x / other, y / other)
    end
  end

  def div(other)
    if other.kind_of?(self.class)
      self.class.new(x.div(other.x), y.div(other.y))
    else
      self.class.new(x.div(other), y.div(other))
    end
  end

  def fdiv(other)
    if other.kind_of?(self.class)
      self.class.new(x.fdiv(other.x), y.fdiv(other.y))
    else
      self.class.new(x.fdiv(other), y.fdiv(other))
    end
  end

  def ceildiv(other)
    if other.kind_of?(self.class)
      self.class.new(x.ceildiv(other.x), y.ceildiv(other.y))
    else
      self.class.new(x.ceildiv(other), y.ceildiv(other))
    end
  end

  def modulo(other)
    if other.kind_of?(self.class)
      self.class.new(x.modulo(other.x), y.modulo(other.y))
    else
      self.class.new(x.modulo(other), y.modulo(other))
    end
  end

  def +(other)
    add(other)
  end

  def -(other)
    sub(other)
  end

  def *(other)
    mul(other)
  end

  def %(other)
    modulo(other)
  end

  def coerce(other)
    [self, other]
  end

  def -@
    self.class.new(-x, -y)
  end

  def +@
    self
  end

  def neg
    -self
  end

  # def reverse
  #   -self
  # end

  # def mul_add(a, b)
  #   self * a + b
  # end

  ################################################################################

  def xy = self
  def yx = self.class.new(y, x)
  def xx = self.class.new(x, x)
  def yy = self.class.new(y, y)

  ################################################################################

  def center = self * 0.5
  def middle = self * 0.5

  def cover?(other, padding: 0)
    true &&
      ((x - padding)..(x + padding)).cover?(other.x) &&
      ((y - padding)..(y + padding)).cover?(other.y)
  end

  ################################################################################

  def replace(other)
    self.x, self.y = other.to_a
    self
  end

  # def add!(other)    = replace(add(other))
  # def sub!(other)    = replace(sub(other))
  # def mul!(other)    = replace(mul(other))
  # def div!(other)    = replace(div(other))
  # def fdiv!(other)   = replace(fdiv(other))
  # def modulo!(other) = replace(modulo(other))

  if false
    prepend Module.new {
      def initialize(...)
        super
        freeze
      end
    }
  end

  ################################################################################

  module AngleConvert
    TAU = 2 * Math::PI
    ONE_TURN_DEGREES = 360.0

    def rad_to_deg
      self * ONE_TURN_DEGREES / TAU
    end

    def rad_to_turn
      self / TAU
    end

    def rad_to_rad
      self
    end

    def deg_to_rad
      self * TAU / ONE_TURN_DEGREES
    end

    def deg_to_turn
      self / ONE_TURN_DEGREES
    end

    def deg_to_deg
      self
    end

    def turn_to_deg
      self * ONE_TURN_DEGREES
    end

    def turn_to_rad
      self * TAU
    end

    def turn_to_turn
      self
    end
  end

  ################################################################################

  module MathExt
    # https://github.com/godotengine/godot/blob/44e399ed5fa895f760b2995e59788bdb49782666/core/math/math_funcs.cpp#L120
    def snapped(value, step)
      if step.nonzero?
        value = (value / step + 0.5).floor * step
      end
      value
    end
  end

  ################################################################################

  ::Numeric.include(AngleConvert)

  ::Math.extend(MathExt)

  ::V = self
end

if $0 == __FILE__
  require "rspec/autorun"

  RSpec.configure do |config|
    config.expect_with :test_unit
  end

  describe Vec2 do
    it "sort" do
      assert { [V[3, 4], V[1, 2]].sort == [V[1, 2], V[3, 4]] }
    end

    it "hash key" do
      assert { V[3, 4] == V[3, 4]  }
      assert { V[3, 4].hash == V[3, 4].hash }
      assert { V[3, 4].eql?(V[3, 4])  }
      a = {V[3, 4] => true}
      assert { a[V[3, 4]] }
    end

    it "rand_norm" do
      assert { V.rand_norm.finite? }
    end

    describe "rotation" do
      before do
        @v0 = V.from_angle(45.deg_to_rad).mul(5)
      end

      it "rotate with radian" do
        v1 = @v0.rotate(45.deg_to_rad)
        assert { v1.angle.rad_to_deg.round(2) == 90.0 }
        assert { v1.length == 5.0 }
      end

      it "rotate_angle_add" do
        v1 = @v0.rotate_angle_add(45.deg_to_rad)
        assert { v1.angle.rad_to_deg.round(2) == 90.0 }
        assert { v1.length == 5.0 }
      end
    end

    it "Enumerable" do
      assert { V[true, true].all?    }
      assert { V[true, true].any?    }
      assert { V[false, false].none? }
      assert { V.one.sum == 2        }
    end

    it "min, max" do
      assert { V[3, 6].min(V[4, 5]) == V[3, 5] }
      assert { V[3, 6].max(V[4, 5]) == V[4, 6] }
    end

    it "normalized?" do
      assert { 1000.times.collect.all? { V.rand.normalize.normalized? } }
    end

    it "pow" do
      assert { V[2, 3]**2     == V[4, 9] }
      assert { V[2, 3].pow(2) == V[4, 9] }
    end

    it "**" do
      assert { V[2.0, 3.0]**2.0 == V[4.0, 9.0] }
    end

    it "distance_to" do
      a = V.zero
      b = V.one
      assert { a.distance_to(b) == b.distance_to(a) }
    end
  end
end
# >> .......
# >>
# >> Finished in 0.01079 seconds (files took 0.07285 seconds to load)
# >> 7 examples, 0 failures
# >>

短かく書けるようにする

V = Vec2

整数と同じぐらい手短に書きたいので、V = Vec2 として V を使いたい。

クラスメソッド

生成する

V.new        # => (0.0, 0.0)
V.new(3, 4)  # => (3, 4)

new は冗長なのであまり使いたくない。

[] が使える

V[]      # => (0.0, 0.0)
V[3, 4]  # => (3, 4)

基本的に [] を使う。

要素には何でも入れられる

V[true, false]      # => (true, false)
V[3, 4]             # => (3, 4)
V[3.0, 4.0]         # => (3.0, 4.0)
V["Left", "Right"]  # => (Left, Right)

といっても数値か論理値を想定している。数値が整数なら浮動小数点に変換したりなどしない。でも整数が途中で浮動小数点になることはある。

同じ値で生成する

V.splat(3)  # => (3, 3)

角度から生成する

v = V.from_angle(45.deg_to_rad)  # => (0.7071067811865476, 0.7071067811865475)
v.angle.rad_to_deg.round         # => 45

あらかじめ決まった値で生成する

V.zero     # => (0.0, 0.0)
V.one      # => (1.0, 1.0)
V.neg_one  # => (-1.0, -1.0)
V.nan  # => (NaN, NaN)
V.max  # => (Infinity, Infinity)
V.min  # => (-Infinity, -Infinity)
V.x      # => (1.0, 0.0)
V.neg_x  # => (-1.0, 0.0)
V.y      # => (0.0, 1.0)
V.neg_y  # => (0.0, -1.0)
V.right  # => (1.0, 0.0)
V.left   # => (-1.0, 0.0)
V.down   # => (0.0, 1.0)
V.up     # => (0.0, -1.0)
V.axes  # => [(1.0, 0.0), (0.0, 1.0)]

乱数で生成する

V.rand       # => (0.5488135039273248, 0.7151893663724195)
V.rand(100)  # => (67, 67)

引数は Kernel.rand と同じ

正規分布で生成する

V.rand_norm            # => (0.40748125033346566, -2.074030950141654)
V.rand_norm(sigma: 2)  # => (7.988729915610515, 3.3272191006819147)
V.rand_norm(mu: 100)   # => (100.30702747675242, 99.31250729390302)
  • sigma → 1σ区間の幅 (初期値: 1.0)
  • mu → 中心 (初期値: 0.0)

インスタンスメソッド

配列化する

V[3, 4].to_a  # => [3, 4]

次のメソッドたちは to_a に委譲している。

v = V.one

v.each     # => #<Enumerator: [1.0, 1.0]:each>
v == v     # => true
v.eql?(v)  # => true
v.hash     # => 1192593652170734305
v <=> v    # => 0
v.to_ary   # => [1.0, 1.0]
v[0]       # => 1.0

Enumerable 対応

V[true, true].all?     # => true
V[false, true].any?    # => true
V[false, false].none?  # => true
V.one.sum              # => 2.0
V.one.count            # => 2

文字列化する

V[3, 4].to_s  # => "(3, 4)"

文字列化する (デバッグ用)

V[3, 4].inspect  # => "(3, 4)"

[] を使うと配列と区別がつかないため () を使う。

Hash 化する

V[3, 4].to_h               # => {:x=>3, :y=>4}
V[3, 4].to_h(prefix: "a")  # => {:ax=>3, :ay=>4}
V[3, 4].to_h(suffix: "1")  # => {:x1=>3, :y1=>4}

suffix の指定で Ruby 2D のような座標をシンボルで書くタイプにも変換しやすくする。

各成分同士の最小・最大

V[3, 6].min(V[4, 5])  # => (3, 5)
V[3, 6].max(V[4, 5])  # => (4, 6)

指定の範囲内に入れる

V[1, 9].clamp(V[3, 3], V[4, 4])  # => (3, 4)

大きい方・小さい方の要素を得る

V[3, 4].min_element  # => 3
V[3, 4].max_element  # => 4

比較

v = V[3, 4]
v.cmpeq(v)  # => (true, true)
v.cmpne(v)  # => (false, false)
v.cmpge(v)  # => (true, true)
v.cmpgt(v)  # => (false, false)
v.cmple(v)  # => (true, true)
v.cmplt(v)  # => (false, false)

結果で条件分岐する場合はさらに any? all? none? などを呼ぶ。

絶対値

V[-3, -4].abs  # => (3, 4)

符号のみを保持したベクトルを返す

V[3, -4].signum  # => (1, -1)
V[-3, 4].signum  # => (-1, 1)
V[3, -4].sign  # => (1, -1)
V[-3, 4].sign  # => (-1, 1)

符号のみをコピーする

V[-3, 6].copysign(V[7, -8])  # => (3, -6)

コピーといっても破壊的ではない。

有限?

V[3, 4].finite?  # => true

NaN か?

V.nan.nan?  # => true

NaN の要素だけ true なベクトルを返す

V[1.0, 0.0 / 0.0].nan_mask  # => (false, true)

二乗したときの長さ

V[3, 4].length_squared  # => 25

長さ

V[3, 4].length  # => 5.0
V[3, 4].norm       # => 5.0
V[3, 4].mag        # => 5.0
V[3, 4].magnitude  # => 5.0

ライブラリによってメソッド名が異なるためいろんな alias を用意している。Ruby の Matrix の Vector にある r は半径と誤読するため alias にしない。

長さの逆数

V[3, 4].length_recip  # => 0.2

これがあると割り算を掛け算に変換できる。

100 / V[3, 4].length        # => 20.0
100 * V[3, 4].length_recip  # => 20.0

対象との差分の長さの二乗

V[4, 5].distance_squared_to(V[1, 1])  # => 25

対象との差分の長さ

V[4, 5].distance_to(V[1, 1])  # => 5.0

正規化

V[3, 4].normalize         # => (0.6000000000000001, 0.8)
V[3, 4].normalize.length  # => 1.0

0ベクトルは正規化できない

V.zero.normalize rescue $!  # => RuntimeError

正規化できない場合は nil を返す版

V.zero.try_normalize  # => nil

正規化できない場合は zero を返す版

V.zero.normalize_or_zero  # => (0.0, 0.0)

正規化してあるか?

V[3, 4].normalized?            # => false
V[3, 4].normalize.normalized?  # => true

射影

a  = V[2.0, 5.0]
b  = V[6.0, 3.0]

a から b への正射影

a.project_onto(b)  # => (3.6, 1.8)

これは b を地面と考えたとき太陽視点での a の影または b を地面として垂直にジャンプした a の着地点に相当する。project_onto がやっていることは

  1. 地面の方を正規化して内積を求めると影の長さが求まる
  2. それを地面の方向に伸ばす(正規化した b だけスケールする)と位置が求まる
a.dot(b.normalize) * b.normalize  # => (3.6, 1.8)

射影先が正規化されていればその処理を省けるので専用のメソッドがある。

a.project_onto_normalized(b.normalize)  # => (3.6, 1.8)

射影できない場合は例外を出す

V.zero.project_onto(V.zero) rescue $!  # => RuntimeError

垂直方向への射影

a.reject_from(b)  # => (-1.6, 3.2)

これは b を地面と考えたときの真横から強い光を当てて壁にできる影または b を地面と考えたときのジャンプした a の高さに相当する。これは単に a - 着地点(a から b への投射) でも求まる。

a - a.project_onto(b)  # => (-1.6, 3.2)

上の射影と同様に地面が正規化されている版もある。

a.reject_from_normalized(b.normalize)  # => (-1.6, 3.2)

小数部補正

V[3.4, 4.5].round     # => (3, 5)
V[3.4, 4.5].floor     # => (3, 4)
V[3.4, 4.5].ceil      # => (4, 5)
V[3.4, 4.5].truncate  # => (3, 4)
V[3.4, 4.5].trunc     # => (3, 4)

小数部のみ残す

V[3.4, 4.5].fract  # => (0.3999999999999999, 0.5)

指数関数

V[3, 4].exp                # => (20.085536923187668, 54.598150033144236)
V[Math::E**3, Math::E**4]  # => (20.085536923187664, 54.59815003314423)

Math::E を参照するより exp を使った方が精度が高いらしい

べき乗

V[3, 4]**2         # => (9, 16)
V[3, 4].pow(2)     # => (9, 16)
V[3, 4].pow(2, 5)  # => (4, 1)

逆数

V[3, 4].recip  # => (0.3333333333333333, 0.25)

逆数を使うと割り算を掛け算に変換できる。

V.splat(100.0) / V[3, 4]        # => (33.333333333333336, 25.0)
V.splat(100.0) * V[3, 4].recip  # => (33.33333333333333, 25.0)

掛け算にすると右辺・左辺を気しなくてよくなるので逆にして

V[3, 4].recip * V.splat(100.0)  # => (33.33333333333333, 25.0)

と書ける。もし割り算だと左右を入れ替えると当然結果が変わってしまう。

V[3, 4] / V.splat(100.0)  # => (0.03, 0.04)

線形補間

V[3, 3].lerp(V[4, 4], 0.0)  # => (3.0, 3.0)
V[3, 3].lerp(V[4, 4], 0.5)  # => (3.5, 3.5)
V[3, 3].lerp(V[4, 4], 1.0)  # => (4.0, 4.0)

alias

V[3, 3].mix(V[4, 4], 0.5)  # => (3.5, 3.5)

誤差を許容した比較

V.zero.abs_diff_eq(V[0.0, 0.01], 0.00)  # => false
V.zero.abs_diff_eq(V[0.0, 0.01], 0.01)  # => true

長さ補正

V[3, 4].length  # => 5.0
V[3, 4].clamp_length(6, 7)         # => (3.6, 4.8)
V[3, 4].clamp_length(6, 7).length  # => 6.0
V[3, 4].clamp_length_min(6)         # => (3.6, 4.8)
V[3, 4].clamp_length_min(6).length  # => 6.0
V[3, 4].clamp_length_max(4)         # => (2.4, 3.2)
V[3, 4].clamp_length_max(4).length  # => 4.0

長さを指定して new したいとき:

V.one.clamp_length_min(5)  # => (3.5355339059327373, 3.5355339059327373)

指定のベクトルの倍数の近い方に四捨五入で合わせる

V[5.0, 14.0].snapped(V[10.0, 10.0])  # => (10.0, 10.0)

2点間の角度差

a = V[2.0, 0.0]
b = V[1.0, 0.0]
b.angle_to(a)       # => 0.0
b.angle_between(a)  # => 0.0
a.angle - b.angle   # => 0.0

自分から相手を見たときの角度

a = V[2.0, 0.0]
b = V[1.0, 0.0]
a.angle_to_point(b)  # => 3.141592653589793

両方を位置ベクトルと見なしたときの a から b 方向への角度を返す。

角度

V[2, 2].angle  # => 0.7853981633974483

回転

v0 = V.from_angle(30.deg_to_rad) * 100

30度(長さ100)のベクトルを60度回転して90度になる例:

v1 = v0.rotate(60.deg_to_rad)
v1.round(2)                # => (0.0, 100.0)
v1.angle.rad_to_deg.round  # => 90
v1.length                  # => 100.0

rotate には方向ベクトルを渡してもよい。

v1 = v0.rotate(V.from_angle(60.deg_to_rad))
v1.round  # => (0, 100)

回転される側の方向に角度を足して再度生成する方法もある。

v1 = V.from_angle(v0.angle + 60.deg_to_rad) * v0.length
v1.round(2)                # => (0.0, 100.0)
v1.angle.rad_to_deg.round  # => 90
v1.length                  # => 100.0

それを簡単にしたのが rotate_angle_add になる。

v1 = v0.rotate_angle_add(60.deg_to_rad)
v1.round(2)                # => (0.0, 100.0)
v1.angle.rad_to_deg.round  # => 90
v1.length                  # => 100.0

後者は glam のメソッドにはなかったが glam の方法より速かったので入れてある。

内積

V[2, 3].dot(V[4, 5])            # => 23
V[2, 3].inner_product(V[4, 5])  # => 23
2 * 4 + 3 * 5                   # => 23
  • a.dot(b) → a.x * b.x + a.y * b.y
  • 底辺 = 斜辺.dot(地面.normalize)

外積

V[2, 3].cross(V[4, 5])          # => -2
V[2, 3].perp_dot(V[4, 5])       # => -2
V[2, 3].outer_product(V[4, 5])  # => -2
2 * 5 - 4 * 3                   # => -2
  • a.cross(b) → a.x * b.y - a.y * b.x
  • 対辺 = 地面.normalize.cross(斜辺)

法線

V[2, 3].perp     # => (-3, 2)
V[2, 3].right90  # => (-3, 2)

法線の逆向き

V[2, 3].left90  # => (3, -2)

left right の動作が画面下方向を正とする座標系に依存しているのがちょっと気持ち悪い。

壁ずり・反射・鏡像ベクトル

a = V[600.0, 340.0]
b = V[200.0,  60.0]
c = V[360.0,  52.0]
d = V[400.0, 200.0]
n = (b - a).perp.normalize  # => (0.5734623443633283, -0.8192319205190405)
speed = d - c               # => (40.0, 148.0)
speed.slide(n)              # => (96.37583892617451, 67.46308724832214)
speed.bounce(n)             # => (152.75167785234902, -13.073825503355721)
speed.reflect(n)            # => (-152.75167785234902, 13.073825503355721)

引数には法線の正規化を渡す。

演算

v0 = V[2, 3]
v1 = V[4, 5]

演算子

v0 + v1  # => (6, 8)
v0 - v1  # => (-2, -2)
v0 * v1  # => (8, 15)
v0 / v1  # => (0, 0)
v0 % v1  # => (2, 3)
-v0      # => (-2, -3)
+v0      # => (2, 3)

メソッド版

v0.add(v1)      # => (6, 8)
v0.sub(v1)      # => (-2, -2)
v0.mul(v1)      # => (8, 15)
v0.div(v1)      # => (0, 0)
v0.fdiv(v1)     # => (0.5, 0.6)
v0.ceildiv(v1)  # => (1, 1)
v0.modulo(v1)   # => (2, 3)
v0.neg          # => (-2, -3)

/divfdiv は数値に対する実行と同じで微妙に結果が異なる。

要素入れ替え

V.x.xy  # => (1.0, 0.0)
V.x.yx  # => (0.0, 1.0)
V.x.xx  # => (1.0, 1.0)
V.x.yy  # => (0.0, 0.0)

中央

v0 = V.one.clamp_length_min(5)
v0.length  # => 5.0
v0.center.length  # => 2.5
v0.middle.length  # => 2.5

alias として middle もある。

位置ベクトルと見なしたとき対象が含まれるか?

v0 = V[3, 4]
v1 = V[4, 5]
v0.cover?(v1)              # => false
v0.cover?(v1, padding: 1)  # => true

v0 の先端との当たり判定を行いたいときに用いる。

配列のようにソートできる

[V[3, 4], V[1, 2]].sort  # => [(1, 2), (3, 4)]

中身が似ていればハッシュキーは同じ

V[3, 4].hash             # => 2814948660905148478
V[3, 4].hash             # => 2814948660905148478
V[3, 4].eql?(V[3, 4])    # => true
h = { V[3, 4] => true }  # => {(3, 4)=>true}
h[V[3, 4]]               # => true

ついでに面倒な角度の扱い簡単にする

  • 一周を 2π とする人間にはわかりにくいがコンピュータにはわかりやすい radian 単位
  • 一周を 360 度とする人間にはわかりやすいがコンピュータはわかりにくい degree 単位
  • 一周を 1.0 とする人間にはまぁまぁわかりやすいがコンピュータはわかりにくい単位

これらをメソッドチェインで相互変換するため Numeric を拡張する。

Math::PI.rad_to_rad   # => 3.141592653589793
Math::PI.rad_to_deg   # => 180.0
Math::PI.rad_to_turn  # => 0.5
180.deg_to_rad   # => 3.141592653589793
180.deg_to_deg   # => 180
180.deg_to_turn  # => 0.5
0.5.turn_to_rad   # => 3.141592653589793
0.5.turn_to_deg   # => 180.0
0.5.turn_to_turn  # => 0.5

Immutable にすべきか Mutable にすべきか?

  • Immutable
    • メリット
      • 予期せぬ変更による不具合を防げる
      • インスタンスを使いまわせる
      • デバッグが楽
      • スレッドセーフ
    • デメリット
      • 値を変更するたびに新しいオブジェクトを生成するぶん遅い
      • 更新するには根元から差し替えないといけないので参照を通して更新ができない
  • Mutable
    • メリット
      • 値を自由に変更できるのであらゆるケースに対応できる
      • 値を変更する際に新しいオブジェクトを生成する必要がないぶん速い
    • デメリット
      • 予期しない変更による不整合が起きる場合がある
      • スレッドセーフではない

といったようにどちらも良し悪しがあり、人によってどちらのメリットを重視するかが異なるので、クラス設計として一方的に決められない。とはいえ、Immutable な制限をして実際に使ってみると参照を通して更新ができないのはつらいので Mutable とした。Ruby の思想としては利用者を信じる傾向にあったり、Immutable から Mutable にするのは難しいが、Mutable から Immutable にするのは簡単だからでもある。かといって破壊的メソッドを積極的には定義しない。やるのはデフォルトで freeze しないのと replace メソッドを用意しておくぐらい。必要であればコンストラクタを継承して freeze すればいい。

参考

https://docs.rs/glam/0.24.1/glam/f32/struct.Vec2.html
https://docs.rs/nannou_core/0.18.0/src/nannou_core/math.rs.html
https://docs.ruby-lang.org/ja/latest/class/Vector.html
https://docs.unity3d.com/ja/2023.2/ScriptReference/Vector2.html
https://p5js.org/reference/#/p5.Vector
https://github.com/ruby-numo/numo-narray

Discussion