tetra3d/ray.go

386 lines
12 KiB
Go

package tetra3d
import (
"errors"
"math"
"sort"
"github.com/hajimehoshi/ebiten/v2"
)
// RayHit represents the result of a raycast test.
type RayHit struct {
Object INode // Object is a pointer to the BoundingObject that was struck by the raycast.
Position Vector // Position is the world position that the object was struct.
from Vector // The starting position of the Ray
Normal Vector // Normal is the normal of the surface the ray struck.
// What triangle the raycast hit - note that this is only set to a non-nil value for raycasts against BoundingTriangle objects
Triangle *Triangle
untransformedPosition Vector // untransformed position of the ray test for BoundingTriangles tests
}
// Slope returns the slope of the RayHit's normal, in radians. This ranges from 0 (straight up) to pi (straight down).
func (r RayHit) Slope() float64 {
return WorldUp.Angle(r.Normal)
}
// Distance returns the distance from the RayHit's originating ray source point to the struck position.
func (r RayHit) Distance() float64 {
return r.from.Distance(r.Position)
}
const ErrorObjectHitNotBoundingTriangles = "error: object hit not a BoundingTriangles instance; no UV or vertex color data can be pulled from RayHit result"
// VertexColor returns the vertex color from the given channel in the position struck on the object struck,
// assuming it was a BoundingTriangles.
// The returned vertex color is linearly interpolated across the triangle just like it would be when a triangle is rendered.
// VertexColor will return a transparent color and an error if the BoundingObject hit was not a BoundingTriangles object, or if the channel index given
// is higher than the number of vertex color channels on the BoundingTriangles' mesh.
func (r RayHit) VertexColor(channelIndex int) (Color, error) {
if r.Triangle == nil {
return NewColor(0, 0, 0, 0), errors.New(ErrorObjectHitNotBoundingTriangles)
}
mesh := r.Object.(*BoundingTriangles).Mesh
if len(mesh.VertexColors[0]) <= channelIndex {
return NewColor(0, 0, 0, 0), errors.New(ErrorVertexChannelOutsideRange)
}
tri := r.Triangle
u, v := pointInsideTriangle(r.untransformedPosition, mesh.VertexPositions[tri.VertexIndices[0]], mesh.VertexPositions[tri.VertexIndices[1]], mesh.VertexPositions[tri.VertexIndices[2]])
vc1 := mesh.VertexColors[tri.VertexIndices[0]][channelIndex]
vc2 := mesh.VertexColors[tri.VertexIndices[1]][channelIndex]
vc3 := mesh.VertexColors[tri.VertexIndices[2]][channelIndex]
output := vc1.Mix(vc2, float32(v)).Mix(vc3, float32(u))
return output, nil
}
// UV returns the UV value from the position struck on the corresponding triangle for the BoundingObject struck,
// assuming the object struck was a BoundingTriangles.
// The returned UV value is linearly interpolated across the triangle just like it would be when a triangle is rendered.
// UV will return a zero Vector and an error if the BoundingObject hit was not a BoundingTriangles object.
func (r RayHit) UV() (Vector, error) {
if r.Triangle == nil {
return NewVector2d(0, 0), errors.New(ErrorObjectHitNotBoundingTriangles)
}
mesh := r.Object.(*BoundingTriangles).Mesh
tri := r.Triangle
u, v := pointInsideTriangle(r.untransformedPosition, mesh.VertexPositions[tri.VertexIndices[0]], mesh.VertexPositions[tri.VertexIndices[1]], mesh.VertexPositions[tri.VertexIndices[2]])
uv1 := mesh.VertexUVs[tri.VertexIndices[0]]
uv2 := mesh.VertexUVs[tri.VertexIndices[1]]
uv3 := mesh.VertexUVs[tri.VertexIndices[2]]
output := uv1.Lerp(uv2, v).Lerp(uv3, u)
return output, nil
}
func sphereRayTest(center Vector, radius float64, from, to Vector) (RayHit, bool) {
line := to.Sub(from)
dir := line.Unit()
e := center.Sub(from)
esq := e.MagnitudeSquared()
a := e.Dot(dir)
b := math.Sqrt(esq - (a * a))
f := math.Sqrt((radius * radius) - (b * b))
vecLength := 0.0
if radius*radius-esq+a*a < 0 {
vecLength = -1
} else if esq < radius*radius {
vecLength = a + f
} else {
vecLength = a - f
}
if vecLength*vecLength > line.MagnitudeSquared() {
return RayHit{}, false
}
if vecLength >= 0 {
strikePos := from.Add(dir.Scale(vecLength))
return RayHit{
Position: strikePos,
from: from,
Normal: strikePos.Sub(center).Unit(),
}, true
}
return RayHit{}, false
}
var rayCylinder = NewBoundingTriangles("ray cylinder test", NewCylinderMesh(32, 1, false), 0)
// TODO: Add SphereCast?
// RayTest casts a ray from the "from" world position to the "to" world position, testing against the provided
// IBoundingObjects.
// RayTest returns a slice of RayHit objects sorted from closest to furthest. Note that
// each object can only be struck once by the raycast, with the exception of BoundingTriangles objects (since a
// single ray may strike multiple triangles).
func RayTest(from, to Vector, testAgainst ...IBoundingObject) []RayHit {
rays := []RayHit{}
for _, i := range testAgainst {
i.Transform() // Make sure the transform is updated before the test
switch test := i.(type) {
case *BoundingSphere:
if result, ok := sphereRayTest(test.WorldPosition(), test.WorldRadius(), from, to); ok {
result.Object = test
rays = append(rays, result)
}
case *BoundingCapsule:
radius := test.WorldRadius()
first := test.lineTop()
second := test.lineBottom()
// We want to check the pole that's closest to the casting from point first, but we have to check both, technically,
// since we don't know which pole the ray may pierce.
if from.Distance(first) > from.Distance(second) {
tmp := first
first = second
second = tmp
}
// test the cylinder in-between. This is done with a plain cylinder mesh because I'm too stupid to
// figure out how to do it otherwise lmbo
rayCylinder.SetWorldTransform(test.Transform())
rayCylinder.SetLocalScale(radius, (test.Height/2)-radius, radius)
if results := RayTest(from, to, rayCylinder); len(results) > 0 {
for i := range results {
results[i].Object = test
}
rays = append(rays, results...)
} else if result, ok := sphereRayTest(first, radius, from, to); ok {
result.Object = test
rays = append(rays, result)
} else if result, ok := sphereRayTest(second, radius, from, to); ok {
result.Object = test
rays = append(rays, result)
}
// segment := to.Sub(from)
// pos := test.lineTop()
// bottom := test.lineBottom()
// if from.Distance(pos) > from.Distance(bottom) {
// pos = bottom
// }
// if from.Distance(pos) > from.Distance(test.WorldPosition()) {
// pos = test.WorldPosition()
// }
// t := pos.Sub(from).Dot(segment) / segment.Dot(segment)
// e := from.Add(segment.Scale(t))
// fmt.Println(pos, bottom, t)
// if t < 0 {
// continue
// }
// if t > 1 {
// continue
// }
// segment := to.Sub(from)
// t := pos.Dot(segment) / segment.Dot(segment)
// if t > 1 {
// t = 1
// } else if t < 0 {
// t = 0
// }
// fmt.Println(t)
// point := from
// point.X += segment.X * t
// point.Y += segment.Y * t
// point.Z += segment.Z * t
// point = point.Sub(start)
// t := point.Dot(segment) / segment.Dot(segment)
// if t > 1 {
// t = 1
// } else if t < 0 {
// t = 0
// }
// start.X += segment.X * t
// start.Y += segment.Y * t
// start.Z += segment.Z * t
// if result := sphereRayTest(test.ClosestPoint(e), test.WorldRadius(), from, to); result != nil {
// result.Object = test
// rays = append(rays, result)
// }
case *BoundingAABB:
line := to.Sub(from)
dir := line.Unit()
pos := test.WorldPosition()
t1 := (test.Dimensions.Min.X + pos.X - from.X) / dir.X
t2 := (test.Dimensions.Max.X + pos.X - from.X) / dir.X
t3 := (test.Dimensions.Min.Y + pos.Y - from.Y) / dir.Y
t4 := (test.Dimensions.Max.Y + pos.Y - from.Y) / dir.Y
t5 := (test.Dimensions.Min.Z + pos.Z - from.Z) / dir.Z
t6 := (test.Dimensions.Max.Z + pos.Z - from.Z) / dir.Z
tmin := max(max(min(t1, t2), min(t3, t4)), min(t5, t6))
tmax := min(min(max(t1, t2), max(t3, t4)), max(t5, t6))
if math.IsNaN(tmin) || math.IsNaN(tmax) {
continue
}
if tmin < 0 {
continue
}
if tmin > tmax {
continue
}
vecLength := tmin
if tmin < 0 {
vecLength = tmax
}
if vecLength*vecLength > line.MagnitudeSquared() {
continue
}
contact := from.Add(dir.Scale(vecLength))
rays = append(rays, RayHit{
Object: test,
Position: contact,
Normal: test.normalFromContactPoint(contact),
from: from,
})
case *BoundingTriangles:
if test.BoundingAABB.PointInside(from) || test.BoundingAABB.PointInside(to) || len(RayTest(from, to, test.BoundingAABB)) > 0 {
_, _, r := test.Transform().Decompose()
invertedTransform := test.Transform().Inverted()
invFrom := invertedTransform.MultVec(from)
invTo := invertedTransform.MultVec(to)
plane := newCollisionPlane()
maxRayDist := to.DistanceSquared(from)
for _, tri := range test.Mesh.Triangles {
// If the distance from the start point to the triangle is longer than the ray,
// then we know it can't be struck and we can bail early
if invFrom.DistanceSquared(tri.Center) > maxRayDist+(tri.MaxSpan*tri.MaxSpan) {
continue
}
fs := tri.Normal.Dot(invFrom.Sub(tri.Center))
ts := tri.Normal.Dot(invTo.Sub(tri.Center))
// If the start and end points of the ray lie on the same side of the triangle,
// then we know the triangle can't be struck and we can bail early
if (fs > 0 && ts > 0) || (fs < 0 && ts < 0) {
continue
}
v0 := test.Mesh.VertexPositions[tri.VertexIndices[0]]
v1 := test.Mesh.VertexPositions[tri.VertexIndices[1]]
v2 := test.Mesh.VertexPositions[tri.VertexIndices[2]]
plane.Set(v0, v1, v2)
if vec, ok := plane.RayAgainstPlane(invFrom, invTo); ok {
if isPointInsideTriangle(vec, v0, v1, v2) {
rays = append(rays, RayHit{
Object: test,
Position: test.Transform().MultVec(vec),
untransformedPosition: vec,
from: from,
Triangle: tri,
Normal: r.MultVec(tri.Normal),
})
// break
}
}
}
}
}
}
sort.Slice(rays, func(i, j int) bool {
return rays[i].Position.Distance(from) < rays[j].Position.Distance(from)
})
return rays
}
// MouseRayTest casts a ray forward from the mouse's position onscreen, testing against the provided
// IBoundingObjects. depth indicates how far the cast ray should extend forwards in world units.
// The function returns a slice of RayHit objects indicating objects struck by the ray, sorted from closest
// to furthest.
// Note that each object can only be struck once by the raycast, with the exception of BoundingTriangles
// objects (since a single ray may strike multiple triangles).
func (camera *Camera) MouseRayTest(depth float64, testAgainst ...IBoundingObject) []RayHit {
from := camera.WorldPosition()
mx, my := ebiten.CursorPosition()
if ebiten.CursorMode() == ebiten.CursorModeCaptured {
mx = camera.ColorTexture().Bounds().Dx() / 2
my = camera.ColorTexture().Bounds().Dy() / 2
}
to := camera.ScreenToWorldPixels(mx, my, depth)
return RayTest(from, to, testAgainst...)
}