CHANGE: Collision detection no longer works with INodes, but rather directly with IBoundingObjects.

CHANGE: CollisionTestSettings.Others no longer works with INodes; just IBoundingObjects.
FIX: Capsule>Capsule and Capsule>Sphere collisions now point to the proper colliding shape (not the sphere, if it hits that particular point).
CHANGE: TexturePlayer now just takes a VertexSelection, rather than a mesh and vertex selection.
Adding NodeFilter.ForEach().
NodeFilter.Not() now takes a variadic number of INodes, rather than a slice specifically.
Adding NodeFilter.ByParentProps() and NodeFilter.IBoundingObjectsWithProps().
Refining built-in particle system - particles now render more reliably.
FIX: Dynamically merged models now render properly if they weren't part of the scene prior to being rendered (as Model.Transform() never gets called before frustum culling).
AutomaticTesselation
SolarLune 2023-03-15 01:09:34 -07:00
parent 211b5d756c
commit 4e96aa0b99
24 changed files with 4456 additions and 4412 deletions

1839
bounds.go

File diff suppressed because it is too large Load Diff

View File

@ -1,283 +1,283 @@
package tetra3d
import (
"math"
)
// BoundingAABB represents a 3D AABB (Axis-Aligned Bounding Box), a 3D cube of varying width, height, and depth that cannot rotate.
// The primary purpose of a BoundingAABB is, like the other Bounding* Nodes, to perform intersection testing between itself and other
// BoundingObject Nodes.
type BoundingAABB struct {
*Node
internalSize Vector
Dimensions Dimensions
}
// NewBoundingAABB returns a new BoundingAABB Node.
func NewBoundingAABB(name string, width, height, depth float64) *BoundingAABB {
min := 0.0001
if width <= 0 {
width = min
}
if height <= 0 {
height = min
}
if depth <= 0 {
depth = min
}
bounds := &BoundingAABB{
Node: NewNode(name),
internalSize: Vector{width, height, depth, 0},
}
bounds.Node.onTransformUpdate = bounds.updateSize
bounds.updateSize()
return bounds
}
// updateSize updates the BoundingAABB's external Dimensions property to reflect its size after reposition, rotation, or resizing.
// This is be called automatically internally as necessary after the node's transform is updated.
func (box *BoundingAABB) updateSize() {
_, s, r := box.Node.Transform().Decompose()
corners := [][]float64{
{1, 1, 1},
{1, -1, 1},
{-1, 1, 1},
{-1, -1, 1},
{1, 1, -1},
{1, -1, -1},
{-1, 1, -1},
{-1, -1, -1},
}
dimensions := NewEmptyDimensions()
for _, c := range corners {
position := r.MultVec(Vector{
box.internalSize.X * c[0] * s.X / 2,
box.internalSize.Y * c[1] * s.Y / 2,
box.internalSize.Z * c[2] * s.Z / 2,
0,
})
if dimensions.Min.X > position.X {
dimensions.Min.X = position.X
}
if dimensions.Min.Y > position.Y {
dimensions.Min.Y = position.Y
}
if dimensions.Min.Z > position.Z {
dimensions.Min.Z = position.Z
}
if dimensions.Max.X < position.X {
dimensions.Max.X = position.X
}
if dimensions.Max.Y < position.Y {
dimensions.Max.Y = position.Y
}
if dimensions.Max.Z < position.Z {
dimensions.Max.Z = position.Z
}
}
box.Dimensions = dimensions
}
// SetDimensions sets the BoundingAABB's internal dimensions (prior to resizing or rotating the Node).
func (box *BoundingAABB) SetDimensions(newWidth, newHeight, newDepth float64) {
min := 0.00001
if newWidth <= 0 {
newWidth = min
}
if newHeight <= 0 {
newHeight = min
}
if newDepth <= 0 {
newDepth = min
}
if box.internalSize.X != newWidth || box.internalSize.Y != newHeight || box.internalSize.Z != newDepth {
box.internalSize.X = newWidth
box.internalSize.Y = newHeight
box.internalSize.Z = newDepth
box.updateSize()
}
}
// Clone returns a new BoundingAABB.
func (box *BoundingAABB) Clone() INode {
clone := NewBoundingAABB(box.name, box.internalSize.X, box.internalSize.Y, box.internalSize.Z)
clone.Node = box.Node.Clone().(*Node)
clone.Node.onTransformUpdate = clone.updateSize
return clone
}
// ClosestPoint returns the closest point, to the point given, on the inside or surface of the BoundingAABB
// in world space.
func (box *BoundingAABB) ClosestPoint(point Vector) Vector {
out := point
pos := box.WorldPosition()
half := box.Dimensions.Size().Scale(0.5)
if out.X > pos.X+half.X {
out.X = pos.X + half.X
} else if out.X < pos.X-half.X {
out.X = pos.X - half.X
}
if out.Y > pos.Y+half.Y {
out.Y = pos.Y + half.Y
} else if out.Y < pos.Y-half.Y {
out.Y = pos.Y - half.Y
}
if out.Z > pos.Z+half.Z {
out.Z = pos.Z + half.Z
} else if out.Z < pos.Z-half.Z {
out.Z = pos.Z - half.Z
}
return out
}
// normalFromContactPoint guesses which normal to return for an AABB given an MTV vector. Basically, if you have an MTV vector indicating a sphere, for example,
// moves up by 0.1 when colliding with an AABB, it must be colliding with the top, and so the returned normal would be [0, 1, 0].
func (box *BoundingAABB) normalFromContactPoint(contactPoint Vector) Vector {
if contactPoint.Equals(box.WorldPosition()) {
return NewVectorZero()
}
p := contactPoint.Sub(box.WorldPosition())
d := Vector{
box.Dimensions.Width() / 2,
box.Dimensions.Height() / 2,
box.Dimensions.Depth() / 2,
0,
}
normal := Vector{
float64(math.Round(p.X / d.X)),
float64(math.Round(p.Y / d.Y)),
float64(math.Round(p.Z / d.Z)),
0,
}.Unit()
return normal
}
// Colliding returns true if the BoundingAABB collides with another IBoundingObject.
func (box *BoundingAABB) Colliding(other IBoundingObject) bool {
return box.Collision(other) != nil
}
// Collision returns the Collision between the BoundingAABB and the other IBoundingObject. If
// there is no intersection, the function returns nil. (Note that BoundingAABB > BoundingTriangles collision
// is buggy at the moment.)
func (box *BoundingAABB) Collision(other IBoundingObject) *Collision {
if other == box {
return nil
}
switch otherBounds := other.(type) {
case *BoundingAABB:
return btAABBAABB(box, otherBounds)
case *BoundingSphere:
intersection := btSphereAABB(otherBounds, box)
if intersection != nil {
for _, inter := range intersection.Intersections {
inter.MTV = inter.MTV.Invert()
inter.Normal = inter.Normal.Invert()
}
intersection.BoundingObject = otherBounds
}
return intersection
case *BoundingTriangles:
return btAABBTriangles(box, otherBounds)
case *BoundingCapsule:
intersection := btCapsuleAABB(otherBounds, box)
if intersection != nil {
for _, inter := range intersection.Intersections {
inter.MTV = inter.MTV.Invert()
inter.Normal = inter.Normal.Invert()
}
intersection.BoundingObject = otherBounds
}
return intersection
}
panic("Unimplemented bounds type")
}
// CollisionTest performs a collision test using the provided collision test settings structure.
// As a nicety, CollisionTest also returns a distance-sorted slice of all of the Collisions (but you should rather
// handle collisions with intent using the OnCollision function of the CollisionTestSettings struct).
func (box *BoundingAABB) CollisionTest(settings CollisionTestSettings) []*Collision {
return CommonCollisionTest(box, settings)
}
func (box *BoundingAABB) PointInside(point Vector) bool {
position := box.WorldPosition()
min := box.Dimensions.Min.Add(position)
max := box.Dimensions.Max.Add(position)
margin := 0.01
if point.X >= min.X-margin && point.X <= max.X+margin &&
point.Y >= min.Y-margin && point.Y <= max.Y+margin &&
point.Z >= min.Z-margin && point.Z <= max.Z+margin {
return true
}
return false
}
// AddChildren parents the provided children Nodes to the passed parent Node, inheriting its transformations and being under it in the scenegraph
// hierarchy. If the children are already parented to other Nodes, they are unparented before doing so.
func (box *BoundingAABB) AddChildren(children ...INode) {
box.addChildren(box, children...)
}
// Unparent unparents the Camera from its parent, removing it from the scenegraph.
func (box *BoundingAABB) Unparent() {
if box.parent != nil {
box.parent.RemoveChildren(box)
}
}
// Index returns the index of the Node in its parent's children list.
// If the node doesn't have a parent, its index will be -1.
func (box *BoundingAABB) Index() int {
if box.parent != nil {
for i, c := range box.parent.Children() {
if c == box {
return i
}
}
}
return -1
}
// Type returns the NodeType for this object.
func (box *BoundingAABB) Type() NodeType {
return NodeTypeBoundingAABB
}
package tetra3d
import (
"math"
)
// BoundingAABB represents a 3D AABB (Axis-Aligned Bounding Box), a 3D cube of varying width, height, and depth that cannot rotate.
// The primary purpose of a BoundingAABB is, like the other Bounding* Nodes, to perform intersection testing between itself and other
// BoundingObject Nodes.
type BoundingAABB struct {
*Node
internalSize Vector
Dimensions Dimensions
}
// NewBoundingAABB returns a new BoundingAABB Node.
func NewBoundingAABB(name string, width, height, depth float64) *BoundingAABB {
min := 0.0001
if width <= 0 {
width = min
}
if height <= 0 {
height = min
}
if depth <= 0 {
depth = min
}
bounds := &BoundingAABB{
Node: NewNode(name),
internalSize: Vector{width, height, depth, 0},
}
bounds.Node.onTransformUpdate = bounds.updateSize
bounds.updateSize()
return bounds
}
// updateSize updates the BoundingAABB's external Dimensions property to reflect its size after reposition, rotation, or resizing.
// This is be called automatically internally as necessary after the node's transform is updated.
func (box *BoundingAABB) updateSize() {
_, s, r := box.Node.Transform().Decompose()
corners := [][]float64{
{1, 1, 1},
{1, -1, 1},
{-1, 1, 1},
{-1, -1, 1},
{1, 1, -1},
{1, -1, -1},
{-1, 1, -1},
{-1, -1, -1},
}
dimensions := NewEmptyDimensions()
for _, c := range corners {
position := r.MultVec(Vector{
box.internalSize.X * c[0] * s.X / 2,
box.internalSize.Y * c[1] * s.Y / 2,
box.internalSize.Z * c[2] * s.Z / 2,
0,
})
if dimensions.Min.X > position.X {
dimensions.Min.X = position.X
}
if dimensions.Min.Y > position.Y {
dimensions.Min.Y = position.Y
}
if dimensions.Min.Z > position.Z {
dimensions.Min.Z = position.Z
}
if dimensions.Max.X < position.X {
dimensions.Max.X = position.X
}
if dimensions.Max.Y < position.Y {
dimensions.Max.Y = position.Y
}
if dimensions.Max.Z < position.Z {
dimensions.Max.Z = position.Z
}
}
box.Dimensions = dimensions
}
// SetDimensions sets the BoundingAABB's internal dimensions (prior to resizing or rotating the Node).
func (box *BoundingAABB) SetDimensions(newWidth, newHeight, newDepth float64) {
min := 0.00001
if newWidth <= 0 {
newWidth = min
}
if newHeight <= 0 {
newHeight = min
}
if newDepth <= 0 {
newDepth = min
}
if box.internalSize.X != newWidth || box.internalSize.Y != newHeight || box.internalSize.Z != newDepth {
box.internalSize.X = newWidth
box.internalSize.Y = newHeight
box.internalSize.Z = newDepth
box.updateSize()
}
}
// Clone returns a new BoundingAABB.
func (box *BoundingAABB) Clone() INode {
clone := NewBoundingAABB(box.name, box.internalSize.X, box.internalSize.Y, box.internalSize.Z)
clone.Node = box.Node.Clone().(*Node)
clone.Node.onTransformUpdate = clone.updateSize
return clone
}
// ClosestPoint returns the closest point, to the point given, on the inside or surface of the BoundingAABB
// in world space.
func (box *BoundingAABB) ClosestPoint(point Vector) Vector {
out := point
pos := box.WorldPosition()
half := box.Dimensions.Size().Scale(0.5)
if out.X > pos.X+half.X {
out.X = pos.X + half.X
} else if out.X < pos.X-half.X {
out.X = pos.X - half.X
}
if out.Y > pos.Y+half.Y {
out.Y = pos.Y + half.Y
} else if out.Y < pos.Y-half.Y {
out.Y = pos.Y - half.Y
}
if out.Z > pos.Z+half.Z {
out.Z = pos.Z + half.Z
} else if out.Z < pos.Z-half.Z {
out.Z = pos.Z - half.Z
}
return out
}
// normalFromContactPoint guesses which normal to return for an AABB given an MTV vector. Basically, if you have an MTV vector indicating a sphere, for example,
// moves up by 0.1 when colliding with an AABB, it must be colliding with the top, and so the returned normal would be [0, 1, 0].
func (box *BoundingAABB) normalFromContactPoint(contactPoint Vector) Vector {
if contactPoint.Equals(box.WorldPosition()) {
return NewVectorZero()
}
p := contactPoint.Sub(box.WorldPosition())
d := Vector{
box.Dimensions.Width() / 2,
box.Dimensions.Height() / 2,
box.Dimensions.Depth() / 2,
0,
}
normal := Vector{
float64(math.Round(p.X / d.X)),
float64(math.Round(p.Y / d.Y)),
float64(math.Round(p.Z / d.Z)),
0,
}.Unit()
return normal
}
// Colliding returns true if the BoundingAABB collides with another IBoundingObject.
func (box *BoundingAABB) Colliding(other IBoundingObject) bool {
return box.Collision(other) != nil
}
// Collision returns the Collision between the BoundingAABB and the other IBoundingObject. If
// there is no intersection, the function returns nil. (Note that BoundingAABB > BoundingTriangles collision
// is buggy at the moment.)
func (box *BoundingAABB) Collision(other IBoundingObject) *Collision {
if other == box || other == nil {
return nil
}
switch otherBounds := other.(type) {
case *BoundingAABB:
return btAABBAABB(box, otherBounds)
case *BoundingSphere:
intersection := btSphereAABB(otherBounds, box)
if intersection != nil {
for _, inter := range intersection.Intersections {
inter.MTV = inter.MTV.Invert()
inter.Normal = inter.Normal.Invert()
}
intersection.BoundingObject = otherBounds
}
return intersection
case *BoundingTriangles:
return btAABBTriangles(box, otherBounds)
case *BoundingCapsule:
intersection := btCapsuleAABB(otherBounds, box)
if intersection != nil {
for _, inter := range intersection.Intersections {
inter.MTV = inter.MTV.Invert()
inter.Normal = inter.Normal.Invert()
}
intersection.BoundingObject = otherBounds
}
return intersection
}
panic("Unimplemented bounds type")
}
// CollisionTest performs a collision test using the provided collision test settings structure.
// As a nicety, CollisionTest also returns a distance-sorted slice of all of the Collisions (but you should rather
// handle collisions with intent using the OnCollision function of the CollisionTestSettings struct).
func (box *BoundingAABB) CollisionTest(settings CollisionTestSettings) []*Collision {
return CommonCollisionTest(box, settings)
}
func (box *BoundingAABB) PointInside(point Vector) bool {
position := box.WorldPosition()
min := box.Dimensions.Min.Add(position)
max := box.Dimensions.Max.Add(position)
margin := 0.01
if point.X >= min.X-margin && point.X <= max.X+margin &&
point.Y >= min.Y-margin && point.Y <= max.Y+margin &&
point.Z >= min.Z-margin && point.Z <= max.Z+margin {
return true
}
return false
}
// AddChildren parents the provided children Nodes to the passed parent Node, inheriting its transformations and being under it in the scenegraph
// hierarchy. If the children are already parented to other Nodes, they are unparented before doing so.
func (box *BoundingAABB) AddChildren(children ...INode) {
box.addChildren(box, children...)
}
// Unparent unparents the Camera from its parent, removing it from the scenegraph.
func (box *BoundingAABB) Unparent() {
if box.parent != nil {
box.parent.RemoveChildren(box)
}
}
// Index returns the index of the Node in its parent's children list.
// If the node doesn't have a parent, its index will be -1.
func (box *BoundingAABB) Index() int {
if box.parent != nil {
for i, c := range box.parent.Children() {
if c == box {
return i
}
}
}
return -1
}
// Type returns the NodeType for this object.
func (box *BoundingAABB) Type() NodeType {
return NodeTypeBoundingAABB
}

View File

@ -1,188 +1,188 @@
package tetra3d
import (
"math"
)
// BoundingCapsule represents a 3D capsule, whose primary purpose is to perform intersection testing between itself and other Bounding Nodes.
type BoundingCapsule struct {
*Node
Height float64
Radius float64
internalSphere *BoundingSphere
}
// NewBoundingCapsule returns a new BoundingCapsule instance. Name is the name of the underlying Node for the Capsule, height is the total
// height of the Capsule, and radius is how big around the capsule is. Height has to be at least radius (otherwise, it would no longer be a capsule).
func NewBoundingCapsule(name string, height, radius float64) *BoundingCapsule {
return &BoundingCapsule{
Node: NewNode(name),
Height: math.Max(radius, height),
Radius: radius,
internalSphere: NewBoundingSphere("internal sphere", 0),
}
}
// Clone returns a new BoundingCapsule.
func (capsule *BoundingCapsule) Clone() INode {
clone := NewBoundingCapsule(capsule.name, capsule.Height, capsule.Radius)
clone.Node = capsule.Node.Clone().(*Node)
return clone
}
// WorldRadius is the radius of the Capsule in world units, after taking into account its scale.
func (capsule *BoundingCapsule) WorldRadius() float64 {
maxScale := 1.0
if capsule.Node != nil {
scale := capsule.Node.LocalScale()
maxScale = math.Max(math.Max(math.Abs(scale.X), math.Abs(scale.Y)), math.Abs(scale.Z))
}
return capsule.Radius * maxScale
}
// Colliding returns true if the BoundingCapsule is intersecting the other BoundingObject.
func (capsule *BoundingCapsule) Colliding(other IBoundingObject) bool {
return capsule.Collision(other) != nil
}
// Collision returns a Collision struct if the BoundingCapsule is intersecting another BoundingObject. If
// no intersection is reported, Collision returns nil.
func (capsule *BoundingCapsule) Collision(other IBoundingObject) *Collision {
if other == capsule {
return nil
}
switch otherBounds := other.(type) {
case *BoundingCapsule:
return btCapsuleCapsule(capsule, otherBounds)
case *BoundingSphere:
intersection := btSphereCapsule(otherBounds, capsule)
if intersection != nil {
for _, inter := range intersection.Intersections {
inter.MTV = inter.MTV.Invert()
inter.Normal = inter.Normal.Invert()
}
intersection.BoundingObject = otherBounds
}
return intersection
case *BoundingAABB:
return btCapsuleAABB(capsule, otherBounds)
case *BoundingTriangles:
return btCapsuleTriangles(capsule, otherBounds)
}
panic("Unimplemented bounds type")
}
// CollisionTest performs a collision test using the provided collision test settings structure.
// As a nicety, CollisionTest also returns a distance-sorted slice of all of the Collisions (but you should rather
// handle collisions with intent using the OnCollision function of the CollisionTestSettings struct).
func (capsule *BoundingCapsule) CollisionTest(settings CollisionTestSettings) []*Collision {
return CommonCollisionTest(capsule, settings)
}
// PointInside returns true if the point provided is within the capsule.
func (capsule *BoundingCapsule) PointInside(point Vector) bool {
return capsule.ClosestPoint(point).Sub(point).Magnitude() < capsule.WorldRadius()
}
// ClosestPoint returns the closest point on the capsule's "central line" to the point provided. Essentially, ClosestPoint returns a point
// along the capsule's line in world coordinates, capped between its bottom and top.
func (capsule *BoundingCapsule) ClosestPoint(point Vector) Vector {
up := capsule.Node.WorldRotation().Up()
pos := capsule.Node.WorldPosition()
start := pos
start.X += up.X * (-capsule.Height/2 + capsule.Radius)
start.Y += up.Y * (-capsule.Height/2 + capsule.Radius)
start.Z += up.Z * (-capsule.Height/2 + capsule.Radius)
end := pos
end.X += up.X * (capsule.Height/2 - capsule.Radius)
end.Y += up.Y * (capsule.Height/2 - capsule.Radius)
end.Z += up.Z * (capsule.Height/2 - capsule.Radius)
segment := end.Sub(start)
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
return start
}
// lineTop returns the world position of the internal top end of the BoundingCapsule's line (i.e. this subtracts the
// capsule's radius).
func (capsule *BoundingCapsule) lineTop() Vector {
up := capsule.Node.WorldRotation().Up()
return capsule.Node.WorldPosition().Add(up.Scale(capsule.Height/2 - capsule.Radius))
}
// Top returns the world position of the top of the BoundingCapsule.
func (capsule *BoundingCapsule) Top() Vector {
up := capsule.Node.WorldRotation().Up()
return capsule.Node.WorldPosition().Add(up.Scale(capsule.Height / 2))
}
// lineBottom returns the world position of the internal bottom end of the BoundingCapsule's line (i.e. this subtracts the
// capsule's radius).
func (capsule *BoundingCapsule) lineBottom() Vector {
up := capsule.Node.WorldRotation().Up()
return capsule.Node.WorldPosition().Add(up.Scale(-capsule.Height/2 + capsule.Radius))
}
// Bottom returns the world position of the bottom of the BoundingCapsule.
func (capsule *BoundingCapsule) Bottom() Vector {
up := capsule.Node.WorldRotation().Up()
return capsule.Node.WorldPosition().Add(up.Scale(-capsule.Height / 2))
}
/////
// AddChildren parents the provided children Nodes to the passed parent Node, inheriting its transformations and being under it in the scenegraph
// hierarchy. If the children are already parented to other Nodes, they are unparented before doing so.
func (capsule *BoundingCapsule) AddChildren(children ...INode) {
capsule.addChildren(capsule, children...)
}
// Unparent unparents the Camera from its parent, removing it from the scenegraph.
func (capsule *BoundingCapsule) Unparent() {
if capsule.parent != nil {
capsule.parent.RemoveChildren(capsule)
}
}
// Index returns the index of the Node in its parent's children list.
// If the node doesn't have a parent, its index will be -1.
func (capsule *BoundingCapsule) Index() int {
if capsule.parent != nil {
for i, c := range capsule.parent.Children() {
if c == capsule {
return i
}
}
}
return -1
}
// Type returns the NodeType for this object.
func (capsule *BoundingCapsule) Type() NodeType {
return NodeTypeBoundingCapsule
}
package tetra3d
import (
"math"
)
// BoundingCapsule represents a 3D capsule, whose primary purpose is to perform intersection testing between itself and other Bounding Nodes.
type BoundingCapsule struct {
*Node
Height float64
Radius float64
internalSphere *BoundingSphere
}
// NewBoundingCapsule returns a new BoundingCapsule instance. Name is the name of the underlying Node for the Capsule, height is the total
// height of the Capsule, and radius is how big around the capsule is. Height has to be at least radius (otherwise, it would no longer be a capsule).
func NewBoundingCapsule(name string, height, radius float64) *BoundingCapsule {
return &BoundingCapsule{
Node: NewNode(name),
Height: math.Max(radius, height),
Radius: radius,
internalSphere: NewBoundingSphere("internal capsule sphere", 0),
}
}
// Clone returns a new BoundingCapsule.
func (capsule *BoundingCapsule) Clone() INode {
clone := NewBoundingCapsule(capsule.name, capsule.Height, capsule.Radius)
clone.Node = capsule.Node.Clone().(*Node)
return clone
}
// WorldRadius is the radius of the Capsule in world units, after taking into account its scale.
func (capsule *BoundingCapsule) WorldRadius() float64 {
maxScale := 1.0
if capsule.Node != nil {
scale := capsule.Node.LocalScale()
maxScale = math.Max(math.Max(math.Abs(scale.X), math.Abs(scale.Y)), math.Abs(scale.Z))
}
return capsule.Radius * maxScale
}
// Colliding returns true if the BoundingCapsule is intersecting the other BoundingObject.
func (capsule *BoundingCapsule) Colliding(other IBoundingObject) bool {
return capsule.Collision(other) != nil
}
// Collision returns a Collision struct if the BoundingCapsule is intersecting another BoundingObject. If
// no intersection is reported, Collision returns nil.
func (capsule *BoundingCapsule) Collision(other IBoundingObject) *Collision {
if other == capsule || other == nil {
return nil
}
switch otherBounds := other.(type) {
case *BoundingCapsule:
return btCapsuleCapsule(capsule, otherBounds)
case *BoundingSphere:
intersection := btSphereCapsule(otherBounds, capsule)
if intersection != nil {
for _, inter := range intersection.Intersections {
inter.MTV = inter.MTV.Invert()
inter.Normal = inter.Normal.Invert()
}
intersection.BoundingObject = otherBounds
}
return intersection
case *BoundingAABB:
return btCapsuleAABB(capsule, otherBounds)
case *BoundingTriangles:
return btCapsuleTriangles(capsule, otherBounds)
}
panic("Unimplemented bounds type")
}
// CollisionTest performs a collision test using the provided collision test settings structure.
// As a nicety, CollisionTest also returns a distance-sorted slice of all of the Collisions (but you should rather
// handle collisions with intent using the OnCollision function of the CollisionTestSettings struct).
func (capsule *BoundingCapsule) CollisionTest(settings CollisionTestSettings) []*Collision {
return CommonCollisionTest(capsule, settings)
}
// PointInside returns true if the point provided is within the capsule.
func (capsule *BoundingCapsule) PointInside(point Vector) bool {
return capsule.ClosestPoint(point).Sub(point).Magnitude() < capsule.WorldRadius()
}
// ClosestPoint returns the closest point on the capsule's "central line" to the point provided. Essentially, ClosestPoint returns a point
// along the capsule's line in world coordinates, capped between its bottom and top.
func (capsule *BoundingCapsule) ClosestPoint(point Vector) Vector {
up := capsule.Node.WorldRotation().Up()
pos := capsule.Node.WorldPosition()
start := pos
start.X += up.X * (-capsule.Height/2 + capsule.Radius)
start.Y += up.Y * (-capsule.Height/2 + capsule.Radius)
start.Z += up.Z * (-capsule.Height/2 + capsule.Radius)
end := pos
end.X += up.X * (capsule.Height/2 - capsule.Radius)
end.Y += up.Y * (capsule.Height/2 - capsule.Radius)
end.Z += up.Z * (capsule.Height/2 - capsule.Radius)
segment := end.Sub(start)
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
return start
}
// lineTop returns the world position of the internal top end of the BoundingCapsule's line (i.e. this subtracts the
// capsule's radius).
func (capsule *BoundingCapsule) lineTop() Vector {
up := capsule.Node.WorldRotation().Up()
return capsule.Node.WorldPosition().Add(up.Scale(capsule.Height/2 - capsule.Radius))
}
// Top returns the world position of the top of the BoundingCapsule.
func (capsule *BoundingCapsule) Top() Vector {
up := capsule.Node.WorldRotation().Up()
return capsule.Node.WorldPosition().Add(up.Scale(capsule.Height / 2))
}
// lineBottom returns the world position of the internal bottom end of the BoundingCapsule's line (i.e. this subtracts the
// capsule's radius).
func (capsule *BoundingCapsule) lineBottom() Vector {
up := capsule.Node.WorldRotation().Up()
return capsule.Node.WorldPosition().Add(up.Scale(-capsule.Height/2 + capsule.Radius))
}
// Bottom returns the world position of the bottom of the BoundingCapsule.
func (capsule *BoundingCapsule) Bottom() Vector {
up := capsule.Node.WorldRotation().Up()
return capsule.Node.WorldPosition().Add(up.Scale(-capsule.Height / 2))
}
/////
// AddChildren parents the provided children Nodes to the passed parent Node, inheriting its transformations and being under it in the scenegraph
// hierarchy. If the children are already parented to other Nodes, they are unparented before doing so.
func (capsule *BoundingCapsule) AddChildren(children ...INode) {
capsule.addChildren(capsule, children...)
}
// Unparent unparents the Camera from its parent, removing it from the scenegraph.
func (capsule *BoundingCapsule) Unparent() {
if capsule.parent != nil {
capsule.parent.RemoveChildren(capsule)
}
}
// Index returns the index of the Node in its parent's children list.
// If the node doesn't have a parent, its index will be -1.
func (capsule *BoundingCapsule) Index() int {
if capsule.parent != nil {
for i, c := range capsule.parent.Children() {
if c == capsule {
return i
}
}
}
return -1
}
// Type returns the NodeType for this object.
func (capsule *BoundingCapsule) Type() NodeType {
return NodeTypeBoundingCapsule
}

View File

@ -1,117 +1,117 @@
package tetra3d
import (
"math"
)
// BoundingSphere represents a 3D sphere.
type BoundingSphere struct {
*Node
Radius float64
}
// NewBoundingSphere returns a new BoundingSphere instance.
func NewBoundingSphere(name string, radius float64) *BoundingSphere {
return &BoundingSphere{
Node: NewNode(name),
Radius: radius,
}
}
// Clone returns a new BoundingSphere instance.
func (sphere *BoundingSphere) Clone() INode {
clone := NewBoundingSphere(sphere.name, sphere.Radius)
clone.Node = sphere.Node.Clone().(*Node)
return clone
}
// WorldRadius returns the radius of the BoundingSphere in world units, after taking into account its scale.
func (sphere *BoundingSphere) WorldRadius() float64 {
var scale Vector
maxScale := 1.0
if sphere.Node.Parent() != nil {
scale = sphere.Node.WorldScale() // We don't want to have to decompose the transform if we can help it
} else {
scale = sphere.Node.scale // We don't want to have to make a memory duplicate if we don't have to
}
maxScale = math.Max(math.Max(math.Abs(scale.X), math.Abs(scale.Y)), math.Abs(scale.Z))
return sphere.Radius * maxScale
}
// Colliding returns true if the BoundingSphere is intersecting the other BoundingObject.
func (sphere *BoundingSphere) Colliding(other IBoundingObject) bool {
return sphere.Collision(other) != nil
}
// Collision returns a Collision if the BoundingSphere is intersecting another BoundingObject. If
// no intersection is reported, Collision returns nil.
func (sphere *BoundingSphere) Collision(other IBoundingObject) *Collision {
if other == sphere {
return nil
}
switch otherBounds := other.(type) {
case *BoundingSphere:
return btSphereSphere(sphere, otherBounds)
case *BoundingAABB:
return btSphereAABB(sphere, otherBounds)
case *BoundingTriangles:
return btSphereTriangles(sphere, otherBounds)
case *BoundingCapsule:
return btSphereCapsule(sphere, otherBounds)
}
panic("Unimplemented bounds type")
}
// CollisionTest performs a collision test using the provided collision test settings structure.
// As a nicety, CollisionTest also returns a distance-sorted slice of all of the Collisions (but you should rather
// handle collisions with intent using the OnCollision function of the CollisionTestSettings struct).
func (sphere *BoundingSphere) CollisionTest(settings CollisionTestSettings) []*Collision {
return CommonCollisionTest(sphere, settings)
}
// PointInside returns whether the given point is inside of the sphere or not.
func (sphere *BoundingSphere) PointInside(point Vector) bool {
return sphere.Node.WorldPosition().Sub(point).Magnitude() < sphere.WorldRadius()
}
/////
// AddChildren parents the provided children Nodes to the passed parent Node, inheriting its transformations and being under it in the scenegraph
// hierarchy. If the children are already parented to other Nodes, they are unparented before doing so.
func (sphere *BoundingSphere) AddChildren(children ...INode) {
sphere.addChildren(sphere, children...)
}
// Unparent unparents the Camera from its parent, removing it from the scenegraph.
func (sphere *BoundingSphere) Unparent() {
if sphere.parent != nil {
sphere.parent.RemoveChildren(sphere)
}
}
// Index returns the index of the Node in its parent's children list.
// If the node doesn't have a parent, its index will be -1.
func (sphere *BoundingSphere) Index() int {
if sphere.parent != nil {
for i, c := range sphere.parent.Children() {
if c == sphere {
return i
}
}
}
return -1
}
// Type returns the NodeType for this object.
func (sphere *BoundingSphere) Type() NodeType {
return NodeTypeBoundingSphere
}
package tetra3d
import (
"math"
)
// BoundingSphere represents a 3D sphere.
type BoundingSphere struct {
*Node
Radius float64
}
// NewBoundingSphere returns a new BoundingSphere instance.
func NewBoundingSphere(name string, radius float64) *BoundingSphere {
return &BoundingSphere{
Node: NewNode(name),
Radius: radius,
}
}
// Clone returns a new BoundingSphere instance.
func (sphere *BoundingSphere) Clone() INode {
clone := NewBoundingSphere(sphere.name, sphere.Radius)
clone.Node = sphere.Node.Clone().(*Node)
return clone
}
// WorldRadius returns the radius of the BoundingSphere in world units, after taking into account its scale.
func (sphere *BoundingSphere) WorldRadius() float64 {
var scale Vector
maxScale := 1.0
if sphere.Node.Parent() != nil {
scale = sphere.Node.WorldScale() // We don't want to have to decompose the transform if we can help it
} else {
scale = sphere.Node.scale // We don't want to have to make a memory duplicate if we don't have to
}
maxScale = math.Max(math.Max(math.Abs(scale.X), math.Abs(scale.Y)), math.Abs(scale.Z))
return sphere.Radius * maxScale
}
// Colliding returns true if the BoundingSphere is intersecting the other BoundingObject.
func (sphere *BoundingSphere) Colliding(other IBoundingObject) bool {
return sphere.Collision(other) != nil
}
// Collision returns a Collision if the BoundingSphere is intersecting another BoundingObject. If
// no intersection is reported, Collision returns nil.
func (sphere *BoundingSphere) Collision(other IBoundingObject) *Collision {
if other == sphere || other == nil {
return nil
}
switch otherBounds := other.(type) {
case *BoundingSphere:
return btSphereSphere(sphere, otherBounds)
case *BoundingAABB:
return btSphereAABB(sphere, otherBounds)
case *BoundingTriangles:
return btSphereTriangles(sphere, otherBounds)
case *BoundingCapsule:
return btSphereCapsule(sphere, otherBounds)
}
panic("Unimplemented bounds type")
}
// CollisionTest performs a collision test using the provided collision test settings structure.
// As a nicety, CollisionTest also returns a distance-sorted slice of all of the Collisions (but you should rather
// handle collisions with intent using the OnCollision function of the CollisionTestSettings struct).
func (sphere *BoundingSphere) CollisionTest(settings CollisionTestSettings) []*Collision {
return CommonCollisionTest(sphere, settings)
}
// PointInside returns whether the given point is inside of the sphere or not.
func (sphere *BoundingSphere) PointInside(point Vector) bool {
return sphere.Node.WorldPosition().Sub(point).Magnitude() < sphere.WorldRadius()
}
/////
// AddChildren parents the provided children Nodes to the passed parent Node, inheriting its transformations and being under it in the scenegraph
// hierarchy. If the children are already parented to other Nodes, they are unparented before doing so.
func (sphere *BoundingSphere) AddChildren(children ...INode) {
sphere.addChildren(sphere, children...)
}
// Unparent unparents the Camera from its parent, removing it from the scenegraph.
func (sphere *BoundingSphere) Unparent() {
if sphere.parent != nil {
sphere.parent.RemoveChildren(sphere)
}
}
// Index returns the index of the Node in its parent's children list.
// If the node doesn't have a parent, its index will be -1.
func (sphere *BoundingSphere) Index() int {
if sphere.parent != nil {
for i, c := range sphere.parent.Children() {
if c == sphere {
return i
}
}
}
return -1
}
// Type returns the NodeType for this object.
func (sphere *BoundingSphere) Type() NodeType {
return NodeTypeBoundingSphere
}

View File

@ -1,294 +1,294 @@
package tetra3d
import (
"math"
)
// BoundingTriangles is a Node specifically for detecting a collision between any of the triangles from a mesh instance and another BoundingObject.
type BoundingTriangles struct {
*Node
BoundingAABB *BoundingAABB
Broadphase *Broadphase
Mesh *Mesh
}
// NewBoundingTriangles returns a new BoundingTriangles object. name is the name of the BoundingTriangles node, while mesh is a reference
// to the Mesh that the BoundingTriangles object should use for collision. broadphaseCellSize is how large the BoundingTriangle's broadphase
// collision cells should be - larger cells means more triangles are checked at a time when doing collision checks, while larger cells also means
// fewer broadphase cells need to be checked. Striking a balance is a good idea if you're setting this value by hand (by default, the grid size is
// the maximum dimension size / 20, rounded up (a grid of at least one cell every 20 Blender Units). A size of 0 disables the usage of the broadphase
// for collision checks.
func NewBoundingTriangles(name string, mesh *Mesh, broadphaseGridSize float64) *BoundingTriangles {
margin := 0.25 // An additional margin to help ensure the broadphase is crossed before checking for collisions
bt := &BoundingTriangles{
Node: NewNode(name),
BoundingAABB: NewBoundingAABB("triangle broadphase aabb", mesh.Dimensions.Width()+margin, mesh.Dimensions.Height()+margin, mesh.Dimensions.Depth()+margin),
Mesh: mesh,
}
bt.Node.onTransformUpdate = bt.UpdateTransform
// This initializes the broadphase using a default grid size.
// If the object is too small (less than 5 units large), it may not be worth doing
maxDim := bt.Mesh.Dimensions.MaxDimension()
gridSize := 0
if broadphaseGridSize > 0 {
gridSize = int(math.Ceil(maxDim / broadphaseGridSize))
}
bt.Broadphase = NewBroadphase(gridSize, bt)
return bt
}
// DisableBroadphase turns off the broadphase system for collision detection by settings its grid and cell size to 0.
// To turn broadphase collision back on, simply call Broadphase.Resize(gridSize) with a gridSize value above 0.
func (bt *BoundingTriangles) DisableBroadphase() {
bt.Broadphase.Resize(0)
}
// Transform returns a Matrix4 indicating the global position, rotation, and scale of the object, transforming it by any parents'.
// If there's no change between the previous Transform() call and this one, Transform() will return a cached version of the
// transform for efficiency.
func (bt *BoundingTriangles) UpdateTransform() {
transform := bt.Node.Transform()
bt.BoundingAABB.SetWorldTransform(transform)
rot := bt.WorldRotation().MultVec(bt.Mesh.Dimensions.Center())
bt.BoundingAABB.MoveVec(rot)
bt.BoundingAABB.Transform()
if bt.Broadphase != nil {
bt.Broadphase.Center.SetWorldTransform(transform)
bt.Broadphase.Center.MoveVec(rot)
bt.Broadphase.Center.Transform() // Update the transform
}
}
// Clone returns a new BoundingTriangles Node with the same values set as the original.
func (bt *BoundingTriangles) Clone() INode {
clone := NewBoundingTriangles(bt.name, bt.Mesh, 0) // Broadphase size is set to 0 so cloning doesn't create the broadphase triangle sets
clone.Broadphase = bt.Broadphase.Clone()
clone.Node = bt.Node.Clone().(*Node)
clone.Node.onTransformUpdate = clone.UpdateTransform
return clone
}
// Colliding returns true if the BoundingTriangles object is intersecting the other specified BoundingObject.
func (bt *BoundingTriangles) Colliding(other IBoundingObject) bool {
return bt.Collision(other) != nil
}
// Collision returns a Collision if the BoundingTriangles object is intersecting another BoundingObject. If
// no intersection is reported, Collision returns nil. (Note that BoundingTriangles > AABB collision is buggy at the moment.)
func (bt *BoundingTriangles) Collision(other IBoundingObject) *Collision {
if other == bt {
return nil
}
switch otherBounds := other.(type) {
case *BoundingAABB:
intersection := otherBounds.Collision(bt)
if intersection != nil {
for _, inter := range intersection.Intersections {
inter.MTV = inter.MTV.Invert()
inter.Normal = inter.Normal.Invert()
}
intersection.BoundingObject = otherBounds
}
return intersection
case *BoundingSphere:
intersection := otherBounds.Collision(bt)
if intersection != nil {
for _, inter := range intersection.Intersections {
inter.MTV = inter.MTV.Invert()
inter.Normal = inter.Normal.Invert()
}
intersection.BoundingObject = otherBounds
}
return intersection
case *BoundingTriangles:
return btTrianglesTriangles(bt, otherBounds)
case *BoundingCapsule:
intersection := otherBounds.Collision(bt)
if intersection != nil {
for _, inter := range intersection.Intersections {
inter.MTV = inter.MTV.Invert()
inter.Normal = inter.Normal.Invert()
}
intersection.BoundingObject = otherBounds
}
return intersection
}
panic("Unimplemented bounds type")
}
// CollisionTest performs a collision test using the provided collision test settings structure.
// As a nicety, CollisionTest also returns a distance-sorted slice of all of the Collisions (but you should rather
// handle collisions with intent using the OnCollision function of the CollisionTestSettings struct).
func (bt *BoundingTriangles) CollisionTest(settings CollisionTestSettings) []*Collision {
return CommonCollisionTest(bt, settings)
}
type collisionPlane struct {
Normal Vector
Distance float64
}
func newCollisionPlane() collisionPlane {
return collisionPlane{}
}
func (plane *collisionPlane) Set(v0, v1, v2 Vector) {
first := v1.Sub(v0)
second := v2.Sub(v0)
normal := first.Cross(second).Unit()
distance := normal.Dot(v0)
plane.Normal = normal
plane.Distance = distance
}
func (plane *collisionPlane) ClosestPoint(point Vector) Vector {
dist := plane.Normal.Dot(point) - plane.Distance
return point.Sub(plane.Normal.Scale(dist))
}
func (plane *collisionPlane) RayAgainstPlane(from, to Vector) (Vector, bool) {
dir := to.Sub(from).Unit()
nd := dir.Dot(plane.Normal)
pn := from.Dot(plane.Normal)
if nd >= 0 {
return Vector{}, false
}
t := (plane.Distance - pn) / nd
if t >= 0 {
return from.Add(dir.Scale(t)), true
}
return Vector{}, false
}
var colPlane = newCollisionPlane()
func closestPointOnTri(point, v0, v1, v2 Vector) Vector {
colPlane.Set(v0, v1, v2)
if planePoint := colPlane.ClosestPoint(point); colPlane.pointInsideTriangle(planePoint, v0, v1, v2) {
return planePoint
}
ab := colPlane.closestPointOnLine(point, v0, v1)
bc := colPlane.closestPointOnLine(point, v1, v2)
ca := colPlane.closestPointOnLine(point, v2, v0)
closest := ab
closestDist := point.DistanceSquared(ab)
bcDist := point.DistanceSquared(bc)
caDist := point.DistanceSquared(ca)
if bcDist < closestDist {
closest = bc
closestDist = bcDist
}
if caDist < closestDist {
closest = ca
}
return closest
}
func (plane *collisionPlane) pointInsideTriangle(point, v0, v1, v2 Vector) bool {
ca := v2.Sub(v0)
ba := v1.Sub(v0)
pa := point.Sub(v0)
dot00 := ca.Dot(ca)
dot01 := ca.Dot(ba)
dot02 := ca.Dot(pa)
dot11 := ba.Dot(ba)
dot12 := ba.Dot(pa)
invDenom := 1.0 / ((dot00 * dot11) - (dot01 * dot01))
u := ((dot11 * dot02) - (dot01 * dot12)) * invDenom
v := ((dot00 * dot12) - (dot01 * dot02)) * invDenom
return (u >= 0) && (v >= 0) && (u+v < 1)
}
func (plane *collisionPlane) closestPointOnLine(point, start, end Vector) Vector {
diff := end.Sub(start)
dotA := point.Sub(start).Dot(diff)
dotB := diff.Dot(diff)
d := dotA / dotB
if d > 1 {
d = 1
} else if d < 0 {
d = 0
}
return start.Add(diff.Scale(d))
}
/////
// AddChildren parents the provided children Nodes to the passed parent Node, inheriting its transformations and being under it in the scenegraph
// hierarchy. If the children are already parented to other Nodes, they are unparented before doing so.
func (bt *BoundingTriangles) AddChildren(children ...INode) {
bt.addChildren(bt, children...)
}
// Unparent unparents the Camera from its parent, removing it from the scenegraph.
func (bt *BoundingTriangles) Unparent() {
if bt.parent != nil {
bt.parent.RemoveChildren(bt)
}
}
// Index returns the index of the Node in its parent's children list.
// If the node doesn't have a parent, its index will be -1.
func (bt *BoundingTriangles) Index() int {
if bt.parent != nil {
for i, c := range bt.parent.Children() {
if c == bt {
return i
}
}
}
return -1
}
// Type returns the NodeType for this object.
func (bt *BoundingTriangles) Type() NodeType {
return NodeTypeBoundingTriangles
}
package tetra3d
import (
"math"
)
// BoundingTriangles is a Node specifically for detecting a collision between any of the triangles from a mesh instance and another BoundingObject.
type BoundingTriangles struct {
*Node
BoundingAABB *BoundingAABB
Broadphase *Broadphase
Mesh *Mesh
}
// NewBoundingTriangles returns a new BoundingTriangles object. name is the name of the BoundingTriangles node, while mesh is a reference
// to the Mesh that the BoundingTriangles object should use for collision. broadphaseCellSize is how large the BoundingTriangle's broadphase
// collision cells should be - larger cells means more triangles are checked at a time when doing collision checks, while larger cells also means
// fewer broadphase cells need to be checked. Striking a balance is a good idea if you're setting this value by hand (by default, the grid size is
// the maximum dimension size / 20, rounded up (a grid of at least one cell every 20 Blender Units). A size of 0 disables the usage of the broadphase
// for collision checks.
func NewBoundingTriangles(name string, mesh *Mesh, broadphaseGridSize float64) *BoundingTriangles {
margin := 0.25 // An additional margin to help ensure the broadphase is crossed before checking for collisions
bt := &BoundingTriangles{
Node: NewNode(name),
BoundingAABB: NewBoundingAABB("triangle broadphase aabb", mesh.Dimensions.Width()+margin, mesh.Dimensions.Height()+margin, mesh.Dimensions.Depth()+margin),
Mesh: mesh,
}
bt.Node.onTransformUpdate = bt.UpdateTransform
// This initializes the broadphase using a default grid size.
// If the object is too small (less than 5 units large), it may not be worth doing
maxDim := bt.Mesh.Dimensions.MaxDimension()
gridSize := 0
if broadphaseGridSize > 0 {
gridSize = int(math.Ceil(maxDim / broadphaseGridSize))
}
bt.Broadphase = NewBroadphase(gridSize, bt)
return bt
}
// DisableBroadphase turns off the broadphase system for collision detection by settings its grid and cell size to 0.
// To turn broadphase collision back on, simply call Broadphase.Resize(gridSize) with a gridSize value above 0.
func (bt *BoundingTriangles) DisableBroadphase() {
bt.Broadphase.Resize(0)
}
// Transform returns a Matrix4 indicating the global position, rotation, and scale of the object, transforming it by any parents'.
// If there's no change between the previous Transform() call and this one, Transform() will return a cached version of the
// transform for efficiency.
func (bt *BoundingTriangles) UpdateTransform() {
transform := bt.Node.Transform()
bt.BoundingAABB.SetWorldTransform(transform)
rot := bt.WorldRotation().MultVec(bt.Mesh.Dimensions.Center())
bt.BoundingAABB.MoveVec(rot)
bt.BoundingAABB.Transform()
if bt.Broadphase != nil {
bt.Broadphase.Center.SetWorldTransform(transform)
bt.Broadphase.Center.MoveVec(rot)
bt.Broadphase.Center.Transform() // Update the transform
}
}
// Clone returns a new BoundingTriangles Node with the same values set as the original.
func (bt *BoundingTriangles) Clone() INode {
clone := NewBoundingTriangles(bt.name, bt.Mesh, 0) // Broadphase size is set to 0 so cloning doesn't create the broadphase triangle sets
clone.Broadphase = bt.Broadphase.Clone()
clone.Node = bt.Node.Clone().(*Node)
clone.Node.onTransformUpdate = clone.UpdateTransform
return clone
}
// Colliding returns true if the BoundingTriangles object is intersecting the other specified BoundingObject.
func (bt *BoundingTriangles) Colliding(other IBoundingObject) bool {
return bt.Collision(other) != nil
}
// Collision returns a Collision if the BoundingTriangles object is intersecting another BoundingObject. If
// no intersection is reported, Collision returns nil. (Note that BoundingTriangles > AABB collision is buggy at the moment.)
func (bt *BoundingTriangles) Collision(other IBoundingObject) *Collision {
if other == bt || other == nil {
return nil
}
switch otherBounds := other.(type) {
case *BoundingAABB:
intersection := otherBounds.Collision(bt)
if intersection != nil {
for _, inter := range intersection.Intersections {
inter.MTV = inter.MTV.Invert()
inter.Normal = inter.Normal.Invert()
}
intersection.BoundingObject = otherBounds
}
return intersection
case *BoundingSphere:
intersection := otherBounds.Collision(bt)
if intersection != nil {
for _, inter := range intersection.Intersections {
inter.MTV = inter.MTV.Invert()
inter.Normal = inter.Normal.Invert()
}
intersection.BoundingObject = otherBounds
}
return intersection
case *BoundingTriangles:
return btTrianglesTriangles(bt, otherBounds)
case *BoundingCapsule:
intersection := otherBounds.Collision(bt)
if intersection != nil {
for _, inter := range intersection.Intersections {
inter.MTV = inter.MTV.Invert()
inter.Normal = inter.Normal.Invert()
}
intersection.BoundingObject = otherBounds
}
return intersection
}
panic("Unimplemented bounds type")
}
// CollisionTest performs a collision test using the provided collision test settings structure.
// As a nicety, CollisionTest also returns a distance-sorted slice of all of the Collisions (but you should rather
// handle collisions with intent using the OnCollision function of the CollisionTestSettings struct).
func (bt *BoundingTriangles) CollisionTest(settings CollisionTestSettings) []*Collision {
return CommonCollisionTest(bt, settings)
}
type collisionPlane struct {
Normal Vector
Distance float64
}
func newCollisionPlane() collisionPlane {
return collisionPlane{}
}
func (plane *collisionPlane) Set(v0, v1, v2 Vector) {
first := v1.Sub(v0)
second := v2.Sub(v0)
normal := first.Cross(second).Unit()
distance := normal.Dot(v0)
plane.Normal = normal
plane.Distance = distance
}
func (plane *collisionPlane) ClosestPoint(point Vector) Vector {
dist := plane.Normal.Dot(point) - plane.Distance
return point.Sub(plane.Normal.Scale(dist))
}
func (plane *collisionPlane) RayAgainstPlane(from, to Vector) (Vector, bool) {
dir := to.Sub(from).Unit()
nd := dir.Dot(plane.Normal)
pn := from.Dot(plane.Normal)
if nd >= 0 {
return Vector{}, false
}
t := (plane.Distance - pn) / nd
if t >= 0 {
return from.Add(dir.Scale(t)), true
}
return Vector{}, false
}
var colPlane = newCollisionPlane()
func closestPointOnTri(point, v0, v1, v2 Vector) Vector {
colPlane.Set(v0, v1, v2)
if planePoint := colPlane.ClosestPoint(point); colPlane.pointInsideTriangle(planePoint, v0, v1, v2) {
return planePoint
}
ab := colPlane.closestPointOnLine(point, v0, v1)
bc := colPlane.closestPointOnLine(point, v1, v2)
ca := colPlane.closestPointOnLine(point, v2, v0)
closest := ab
closestDist := point.DistanceSquared(ab)
bcDist := point.DistanceSquared(bc)
caDist := point.DistanceSquared(ca)
if bcDist < closestDist {
closest = bc
closestDist = bcDist
}
if caDist < closestDist {
closest = ca
}
return closest
}
func (plane *collisionPlane) pointInsideTriangle(point, v0, v1, v2 Vector) bool {
ca := v2.Sub(v0)
ba := v1.Sub(v0)
pa := point.Sub(v0)
dot00 := ca.Dot(ca)
dot01 := ca.Dot(ba)
dot02 := ca.Dot(pa)
dot11 := ba.Dot(ba)
dot12 := ba.Dot(pa)
invDenom := 1.0 / ((dot00 * dot11) - (dot01 * dot01))
u := ((dot11 * dot02) - (dot01 * dot12)) * invDenom
v := ((dot00 * dot12) - (dot01 * dot02)) * invDenom
return (u >= 0) && (v >= 0) && (u+v < 1)
}
func (plane *collisionPlane) closestPointOnLine(point, start, end Vector) Vector {
diff := end.Sub(start)
dotA := point.Sub(start).Dot(diff)
dotB := diff.Dot(diff)
d := dotA / dotB
if d > 1 {
d = 1
} else if d < 0 {
d = 0
}
return start.Add(diff.Scale(d))
}
/////
// AddChildren parents the provided children Nodes to the passed parent Node, inheriting its transformations and being under it in the scenegraph
// hierarchy. If the children are already parented to other Nodes, they are unparented before doing so.
func (bt *BoundingTriangles) AddChildren(children ...INode) {
bt.addChildren(bt, children...)
}
// Unparent unparents the Camera from its parent, removing it from the scenegraph.
func (bt *BoundingTriangles) Unparent() {
if bt.parent != nil {
bt.parent.RemoveChildren(bt)
}
}
// Index returns the index of the Node in its parent's children list.
// If the node doesn't have a parent, its index will be -1.
func (bt *BoundingTriangles) Index() int {
if bt.parent != nil {
for i, c := range bt.parent.Children() {
if c == bt {
return i
}
}
}
return -1
}
// Type returns the NodeType for this object.
func (bt *BoundingTriangles) Type() NodeType {
return NodeTypeBoundingTriangles
}

View File

@ -1010,8 +1010,6 @@ func (camera *Camera) Render(scene *Scene, lights []ILight, models ...*Model) {
continue
}
camera.DebugInfo.TotalParts++
if model.FrustumCulling {
model.Transform()
@ -1068,6 +1066,8 @@ func (camera *Camera) Render(scene *Scene, lights []ILight, models ...*Model) {
}
}
camera.DebugInfo.TotalParts += len(modelSlice)
}
} else if model.Mesh != nil {
@ -1088,6 +1088,8 @@ func (camera *Camera) Render(scene *Scene, lights []ILight, models ...*Model) {
// depths[model] = camera.WorldToScreen(model.WorldPosition()).Z
}
camera.DebugInfo.TotalParts++
}
}
@ -1212,6 +1214,8 @@ func (camera *Camera) Render(scene *Scene, lights []ILight, models ...*Model) {
}
// TODO: Implement PS1-style automatic tesselation
meshPart.ForEachVertexIndex(
func(vertIndex int) {
@ -1570,6 +1574,13 @@ func (camera *Camera) Render(scene *Scene, lights []ILight, models ...*Model) {
continue
}
if merged.FrustumCulling {
merged.Transform()
if !camera.SphereInFrustum(merged.BoundingSphere) {
continue
}
}
for _, part := range merged.Mesh.MeshParts {
render(renderPair{Model: merged, MeshPart: part})
}
@ -1593,33 +1604,33 @@ func (camera *Camera) Render(scene *Scene, lights []ILight, models ...*Model) {
}
func encodeDepth(depth float64) *Color {
// func encodeDepth(depth float64) *Color {
r := math.Floor(depth*255) / 255
_, f := math.Modf(depth * 255)
g := math.Floor(f*255) / 255
_, f = math.Modf(depth * 255 * 255)
b := f
// r := math.Floor(depth*255) / 255
// _, f := math.Modf(depth * 255)
// g := math.Floor(f*255) / 255
// _, f = math.Modf(depth * 255 * 255)
// b := f
if r < 0 {
r = 0
} else if r > 1 {
r = 1
}
if g < 0 {
g = 0
} else if g > 1 {
g = 1
}
if b < 0 {
b = 0
} else if b > 1 {
b = 1
}
// if r < 0 {
// r = 0
// } else if r > 1 {
// r = 1
// }
// if g < 0 {
// g = 0
// } else if g > 1 {
// g = 1
// }
// if b < 0 {
// b = 0
// } else if b > 1 {
// b = 1
// }
return NewColor(float32(r), float32(g), float32(b), 1)
// return NewColor(float32(r), float32(g), float32(b), 1)
}
// }
type SpriteRender3d struct {
Image *ebiten.Image

View File

@ -1,139 +1,139 @@
package main
import (
_ "embed"
"github.com/solarlune/tetra3d"
"github.com/solarlune/tetra3d/colors"
"github.com/solarlune/tetra3d/examples"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/inpututil"
)
type Game struct {
Library *tetra3d.Library
Scene *tetra3d.Scene
Camera examples.BasicFreeCam
System examples.BasicSystemHandler
AnimatedTexture *tetra3d.TexturePlayer
Character *tetra3d.Model
}
//go:embed animatedTextures.gltf
var libraryData []byte
func NewGame() *Game {
game := &Game{}
game.Init()
return game
}
func (g *Game) Init() {
library, err := tetra3d.LoadGLTFData(libraryData, nil)
if err != nil {
panic(err)
}
g.Library = library
// We clone the scene so we have an original to work from
g.Scene = library.ExportedScene.Clone()
// Turn off lighting
g.Scene.World.LightingOn = false
g.Camera = examples.NewBasicFreeCam(g.Scene)
g.Camera.Move(0, 5, 0)
g.System = examples.NewBasicSystemHandler(g)
g.Character = g.Scene.Root.Get("Character").(*tetra3d.Model)
clone := g.Character.Mesh.Clone()
clone.Name = "Cloned Character"
g.Character.Mesh = clone
// Firstly, we create a TexturePlayer, which animates a collection of vertices' UV values to
// animate a texture on them.
mesh := library.Meshes["Plane"]
// We can select all vertices:
selection := mesh.SelectVertices().SelectAll()
// And then create a TexturePlayer, which steps through all vertices and assigns their UV values according
// to the TexturePlayer's playing animation.
g.AnimatedTexture = tetra3d.NewTexturePlayer(mesh, selection)
// Next we create the animation using NewTextureAnimationPixels():
bloopAnim := tetra3d.NewTextureAnimationPixels(15, mesh.MeshParts[0].Material.Texture,
0, 0, // UV offset for frame 0,
16, 0, // Frame 1,
0, 16, // Frame 2,
16, 16, // And frame 3.
)
// Note that we want to pass 2 values (x and y position) for each frame. Otherwise, NewTextureAnimationPixels will panic.
// And finally, begin playing the animation. That's it!
g.AnimatedTexture.Play(bloopAnim)
}
func (g *Game) Update() error {
// Update the TexturePlayer with the time that's passed since the previous frame.
g.AnimatedTexture.Update(1.0 / 60.0)
if inpututil.IsKeyJustPressed(ebiten.Key1) {
g.AnimatedTexture.Playing = !g.AnimatedTexture.Playing
}
g.Camera.Update()
return g.System.Update()
}
func (g *Game) Draw(screen *ebiten.Image) {
// Clear, but with a color
screen.Fill(g.Scene.World.ClearColor.ToRGBA64())
// Clear the Camera
g.Camera.Clear()
// Render the logo first
g.Camera.RenderScene(g.Scene)
// We rescale the depth or color textures here just in case we render at a different resolution than the window's; this isn't necessary,
// we could just draw the images straight.
screen.DrawImage(g.Camera.ColorTexture(), nil)
g.System.Draw(screen, g.Camera.Camera)
if g.System.DrawDebugText {
txt := "This demo shows how animated textures and billboarding work.\nThere are several lava planes, but they all share\nthe same mesh, which is animated by the\nTexturePlayer.\n\nThe character faces the camera because his\nmaterial has its BillboardMode set to X/Z (so\nit faces the camera, but doesn't tilt horizontally).\n1 key: Toggle playback"
g.Camera.DebugDrawText(screen, txt, 0, 220, 1, colors.LightGray())
}
}
func (g *Game) Layout(w, h int) (int, int) {
return g.Camera.Size()
}
func main() {
ebiten.SetWindowTitle("Tetra3d - Animated Textures Test")
ebiten.SetWindowResizingMode(ebiten.WindowResizingModeEnabled)
game := NewGame()
if err := ebiten.RunGame(game); err != nil {
panic(err)
}
}
package main
import (
_ "embed"
"github.com/solarlune/tetra3d"
"github.com/solarlune/tetra3d/colors"
"github.com/solarlune/tetra3d/examples"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/inpututil"
)
type Game struct {
Library *tetra3d.Library
Scene *tetra3d.Scene
Camera examples.BasicFreeCam
System examples.BasicSystemHandler
AnimatedTexture *tetra3d.TexturePlayer
Character *tetra3d.Model
}
//go:embed animatedTextures.gltf
var libraryData []byte
func NewGame() *Game {
game := &Game{}
game.Init()
return game
}
func (g *Game) Init() {
library, err := tetra3d.LoadGLTFData(libraryData, nil)
if err != nil {
panic(err)
}
g.Library = library
// We clone the scene so we have an original to work from
g.Scene = library.ExportedScene.Clone()
// Turn off lighting
g.Scene.World.LightingOn = false
g.Camera = examples.NewBasicFreeCam(g.Scene)
g.Camera.Move(0, 5, 0)
g.System = examples.NewBasicSystemHandler(g)
g.Character = g.Scene.Root.Get("Character").(*tetra3d.Model)
clone := g.Character.Mesh.Clone()
clone.Name = "Cloned Character"
g.Character.Mesh = clone
// Firstly, we create a TexturePlayer, which animates a collection of vertices' UV values to
// animate a texture on them.
mesh := library.Meshes["Plane"]
// We can select all vertices:
selection := mesh.SelectVertices().SelectAll()
// And then create a TexturePlayer, which steps through all vertices and assigns their UV values according
// to the TexturePlayer's playing animation.
g.AnimatedTexture = tetra3d.NewTexturePlayer(selection)
// Next we create the animation using NewTextureAnimationPixels():
bloopAnim := tetra3d.NewTextureAnimationPixels(15, mesh.MeshParts[0].Material.Texture,
0, 0, // UV offset for frame 0,
16, 0, // Frame 1,
0, 16, // Frame 2,
16, 16, // And frame 3.
)
// Note that we want to pass 2 values (x and y position) for each frame. Otherwise, NewTextureAnimationPixels will panic.
// And finally, begin playing the animation. That's it!
g.AnimatedTexture.Play(bloopAnim)
}
func (g *Game) Update() error {
// Update the TexturePlayer with the time that's passed since the previous frame.
g.AnimatedTexture.Update(1.0 / 60.0)
if inpututil.IsKeyJustPressed(ebiten.Key1) {
g.AnimatedTexture.Playing = !g.AnimatedTexture.Playing
}
g.Camera.Update()
return g.System.Update()
}
func (g *Game) Draw(screen *ebiten.Image) {
// Clear, but with a color
screen.Fill(g.Scene.World.ClearColor.ToRGBA64())
// Clear the Camera
g.Camera.Clear()
// Render the logo first
g.Camera.RenderScene(g.Scene)
// We rescale the depth or color textures here just in case we render at a different resolution than the window's; this isn't necessary,
// we could just draw the images straight.
screen.DrawImage(g.Camera.ColorTexture(), nil)
g.System.Draw(screen, g.Camera.Camera)
if g.System.DrawDebugText {
txt := "This demo shows how animated textures and billboarding work.\nThere are several lava planes, but they all share\nthe same mesh, which is animated by the\nTexturePlayer.\n\nThe character faces the camera because his\nmaterial has its BillboardMode set to X/Z (so\nit faces the camera, but doesn't tilt horizontally).\n1 key: Toggle playback"
g.Camera.DebugDrawText(screen, txt, 0, 220, 1, colors.LightGray())
}
}
func (g *Game) Layout(w, h int) (int, int) {
return g.Camera.Size()
}
func main() {
ebiten.SetWindowTitle("Tetra3d - Animated Textures Test")
ebiten.SetWindowResizingMode(ebiten.WindowResizingModeEnabled)
game := NewGame()
if err := ebiten.RunGame(game); err != nil {
panic(err)
}
}

View File

@ -1,179 +1,179 @@
package main
import (
_ "embed"
"github.com/solarlune/tetra3d"
"github.com/solarlune/tetra3d/colors"
"github.com/solarlune/tetra3d/examples"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/inpututil"
)
//go:embed baking.gltf
var gltfData []byte
type Game struct {
Library *tetra3d.Library
Scene *tetra3d.Scene
Camera examples.BasicFreeCam
System examples.BasicSystemHandler
}
func NewGame() *Game {
game := &Game{}
game.Init()
return game
}
const (
ChannelLight = iota // The light color channel
ChannelAO // The AO color channel
ChannelCombined // The combined color channel
ChannelNone = -1 // No color channel
)
func (g *Game) Init() {
library, err := tetra3d.LoadGLTFData(gltfData, nil)
if err != nil {
panic(err)
}
g.Library = library
g.Scene = library.Scenes[0]
g.Camera = examples.NewBasicFreeCam(g.Scene)
g.Camera.Move(0, 10, 0)
g.System = examples.NewBasicSystemHandler(g)
// OK, so in this example we're going to bake lighting and AO.
// We will combine them into different vertex color channels to
// easily preview the differences, as well.
// We start by collecting all the lights we'll be baking. NodeFilter.Lights() will automatically give us just the lights out of our selection and discard any other INodes.
lights := g.Scene.Root.SearchTree().Lights()
// // Don't forget the world's ambient light!
lights = append(lights, g.Scene.World.AmbientLight)
// Let's get all the solid, occluding models here.
models := g.Scene.Root.SearchTree().ByProperties(false, "ao").Models()
// The idea is that we'll bake the lighting and AO of each ao-applicable Model.
// We'll bake the lighting to vertex channel #0, AO to channel #1, and combine both
// in vertex channel #2. We will use named consts to make referencing these channels simple.
for _, model := range models {
// First, we'll bake the lighting into the lighting vertex color channel.
model.BakeLighting(ChannelLight, lights...)
// Next, we'll bake the AO into the AO vertex color channel.
// The AOBakeOptions struct controls how our AO should be baked. For readability, we create it
// here, though we should, of course, create it outside our model loop if the options don't differ
// for each Model.
bakeOptions := tetra3d.NewDefaultAOBakeOptions()
bakeOptions.OcclusionAngle = tetra3d.ToRadians(60)
bakeOptions.TargetChannel = ChannelAO // Set the target baking channel (as it's not 0)
// bakeOptions.OtherModels = models // Specify what other objects should influence the AO (note that this is optional)
model.Mesh.SetVertexColor(ChannelAO, colors.White())
model.BakeAO(bakeOptions) // And bake the AO.
// We'll combine the channels together multiplicatively here.
model.Mesh.CombineVertexColors(ChannelCombined, true, ChannelLight, ChannelAO)
for _, mat := range model.Mesh.Materials() {
mat.Shadeless = true // We don't need lighting anymore.
}
// Finally, we'll set the models' active color channel here. By default, it's -1, indicating no vertex colors are active (unless
// the mesh was exported from Blender with an active vertex color channel).
model.Mesh.SetActiveColorChannel(ChannelCombined)
}
// We can turn off the lights now, as we won't need them anymore.
for _, light := range lights {
light.SetOn(false)
}
}
func (g *Game) Update() error {
// Change the active vertex color channels
if inpututil.IsKeyJustPressed(ebiten.Key1) {
for _, model := range g.Scene.Root.SearchTree().Models() {
model.Mesh.SelectVertices().SelectAll().SetActiveColorChannel(ChannelNone) // Switch to unlit
}
}
if inpututil.IsKeyJustPressed(ebiten.Key2) {
for _, model := range g.Scene.Root.SearchTree().Models() {
model.Mesh.SelectVertices().SelectAll().SetActiveColorChannel(ChannelAO) // Switch to AO only
}
}
if inpututil.IsKeyJustPressed(ebiten.Key3) {
for _, model := range g.Scene.Root.SearchTree().Models() {
model.Mesh.SelectVertices().SelectAll().SetActiveColorChannel(ChannelLight) // Switch to Lighting
}
}
if inpututil.IsKeyJustPressed(ebiten.Key4) {
for _, model := range g.Scene.Root.SearchTree().Models() {
model.Mesh.SelectVertices().SelectAll().SetActiveColorChannel(ChannelCombined) // Switch to lighting+AO
}
}
g.Camera.Update()
return g.System.Update()
}
func (g *Game) Draw(screen *ebiten.Image) {
// Clear, but with a color - we can use the world lighting color for this.
screen.Fill(g.Scene.World.ClearColor.ToRGBA64())
// Clear the Camera
g.Camera.Clear()
// Render the scene
g.Camera.RenderScene(g.Scene)
// We rescale the depth or color textures here just in case we render at a different resolution than the window's; this isn't necessary,
// we could just draw the images straight.
screen.DrawImage(g.Camera.ColorTexture(), nil)
g.System.Draw(screen, g.Camera.Camera)
if g.System.DrawDebugText {
txt := "This example shows how lighting and primitive\nambient occlusion can be baked into vertex colors.\n1 Key: Switch to unlit channel\n2 Key: Switch to only AO\n3 key: Switch to only lighting\n4 Key: Switch to lighting+AO"
g.Camera.DebugDrawText(screen, txt, 0, 200, 1, colors.LightGray())
}
}
func (g *Game) Layout(w, h int) (int, int) {
return g.Camera.Size()
}
func main() {
ebiten.SetWindowTitle("Tetra3d - Baked Lighting Test")
ebiten.SetWindowResizingMode(ebiten.WindowResizingModeEnabled)
game := NewGame()
if err := ebiten.RunGame(game); err != nil {
panic(err)
}
}
package main
import (
_ "embed"
"github.com/solarlune/tetra3d"
"github.com/solarlune/tetra3d/colors"
"github.com/solarlune/tetra3d/examples"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/inpututil"
)
//go:embed baking.gltf
var gltfData []byte
type Game struct {
Library *tetra3d.Library
Scene *tetra3d.Scene
Camera examples.BasicFreeCam
System examples.BasicSystemHandler
}
func NewGame() *Game {
game := &Game{}
game.Init()
return game
}
const (
ChannelLight = iota // The light color channel
ChannelAO // The AO color channel
ChannelCombined // The combined color channel
ChannelNone = -1 // No color channel
)
func (g *Game) Init() {
library, err := tetra3d.LoadGLTFData(gltfData, nil)
if err != nil {
panic(err)
}
g.Library = library
g.Scene = library.Scenes[0]
g.Camera = examples.NewBasicFreeCam(g.Scene)
g.Camera.Move(0, 10, 0)
g.System = examples.NewBasicSystemHandler(g)
// OK, so in this example we're going to bake lighting and AO.
// We will combine them into different vertex color channels to
// easily preview the differences, as well.
// We start by collecting all the lights we'll be baking. NodeFilter.Lights() will automatically give us just the lights out of our selection and discard any other INodes.
lights := g.Scene.Root.SearchTree().Lights()
// // Don't forget the world's ambient light!
lights = append(lights, g.Scene.World.AmbientLight)
// Let's get all the solid, occluding models here.
models := g.Scene.Root.SearchTree().ByProps("ao").Models()
// The idea is that we'll bake the lighting and AO of each ao-applicable Model.
// We'll bake the lighting to vertex channel #0, AO to channel #1, and combine both
// in vertex channel #2. We will use named consts to make referencing these channels simple.
for _, model := range models {
// First, we'll bake the lighting into the lighting vertex color channel.
model.BakeLighting(ChannelLight, lights...)
// Next, we'll bake the AO into the AO vertex color channel.
// The AOBakeOptions struct controls how our AO should be baked. For readability, we create it
// here, though we should, of course, create it outside our model loop if the options don't differ
// for each Model.
bakeOptions := tetra3d.NewDefaultAOBakeOptions()
bakeOptions.OcclusionAngle = tetra3d.ToRadians(60)
bakeOptions.TargetChannel = ChannelAO // Set the target baking channel (as it's not 0)
// bakeOptions.OtherModels = models // Specify what other objects should influence the AO (note that this is optional)
model.Mesh.SetVertexColor(ChannelAO, colors.White())
model.BakeAO(bakeOptions) // And bake the AO.
// We'll combine the channels together multiplicatively here.
model.Mesh.CombineVertexColors(ChannelCombined, true, ChannelLight, ChannelAO)
for _, mat := range model.Mesh.Materials() {
mat.Shadeless = true // We don't need lighting anymore.
}
// Finally, we'll set the models' active color channel here. By default, it's -1, indicating no vertex colors are active (unless
// the mesh was exported from Blender with an active vertex color channel).
model.Mesh.SetActiveColorChannel(ChannelCombined)
}
// We can turn off the lights now, as we won't need them anymore.
for _, light := range lights {
light.SetOn(false)
}
}
func (g *Game) Update() error {
// Change the active vertex color channels
if inpututil.IsKeyJustPressed(ebiten.Key1) {
for _, model := range g.Scene.Root.SearchTree().Models() {
model.Mesh.SelectVertices().SelectAll().SetActiveColorChannel(ChannelNone) // Switch to unlit
}
}
if inpututil.IsKeyJustPressed(ebiten.Key2) {
for _, model := range g.Scene.Root.SearchTree().Models() {
model.Mesh.SelectVertices().SelectAll().SetActiveColorChannel(ChannelAO) // Switch to AO only
}
}
if inpututil.IsKeyJustPressed(ebiten.Key3) {
for _, model := range g.Scene.Root.SearchTree().Models() {
model.Mesh.SelectVertices().SelectAll().SetActiveColorChannel(ChannelLight) // Switch to Lighting
}
}
if inpututil.IsKeyJustPressed(ebiten.Key4) {
for _, model := range g.Scene.Root.SearchTree().Models() {
model.Mesh.SelectVertices().SelectAll().SetActiveColorChannel(ChannelCombined) // Switch to lighting+AO
}
}
g.Camera.Update()
return g.System.Update()
}
func (g *Game) Draw(screen *ebiten.Image) {
// Clear, but with a color - we can use the world lighting color for this.
screen.Fill(g.Scene.World.ClearColor.ToRGBA64())
// Clear the Camera
g.Camera.Clear()
// Render the scene
g.Camera.RenderScene(g.Scene)
// We rescale the depth or color textures here just in case we render at a different resolution than the window's; this isn't necessary,
// we could just draw the images straight.
screen.DrawImage(g.Camera.ColorTexture(), nil)
g.System.Draw(screen, g.Camera.Camera)
if g.System.DrawDebugText {
txt := "This example shows how lighting and primitive\nambient occlusion can be baked into vertex colors.\n1 Key: Switch to unlit channel\n2 Key: Switch to only AO\n3 key: Switch to only lighting\n4 Key: Switch to lighting+AO"
g.Camera.DebugDrawText(screen, txt, 0, 200, 1, colors.LightGray())
}
}
func (g *Game) Layout(w, h int) (int, int) {
return g.Camera.Size()
}
func main() {
ebiten.SetWindowTitle("Tetra3d - Baked Lighting Test")
ebiten.SetWindowResizingMode(ebiten.WindowResizingModeEnabled)
game := NewGame()
if err := ebiten.RunGame(game); err != nil {
panic(err)
}
}

View File

@ -1,186 +1,186 @@
package main
import (
"fmt"
_ "embed"
"github.com/solarlune/tetra3d"
"github.com/solarlune/tetra3d/colors"
"github.com/solarlune/tetra3d/examples"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/inpututil"
)
//go:embed bounds.gltf
var gltfData []byte
type Game struct {
Scene *tetra3d.Scene
Controlling *tetra3d.Model
Movement tetra3d.Vector
VerticalSpeed float64
Camera examples.BasicFreeCam
System examples.BasicSystemHandler
}
func NewGame() *Game {
game := &Game{}
game.Init()
return game
}
func (g *Game) Init() {
library, err := tetra3d.LoadGLTFData(gltfData, nil)
if err != nil {
panic(err)
}
g.Scene = library.FindScene("Scene")
g.Controlling = g.Scene.Root.Get("YellowCapsule").(*tetra3d.Model)
g.Camera = examples.NewBasicFreeCam(g.Scene)
g.Camera.SetLocalPosition(0, 6, 15)
g.Camera.SetFar(40)
g.System = examples.NewBasicSystemHandler(g)
}
func (g *Game) Update() error {
friction := 0.05
maxSpd := 0.25
accel := 0.05 + friction
gravity := 0.05
bounds := g.Controlling.Children()[0].(tetra3d.IBoundingObject)
solids := g.Scene.Root.SearchTree().ByType(tetra3d.NodeTypeBoundingObject).INodes()
movement := g.Movement.Modify() // Modification Vector
if ebiten.IsKeyPressed(ebiten.KeyRight) {
movement.X += accel
}
if ebiten.IsKeyPressed(ebiten.KeyLeft) {
movement.X -= accel
}
if ebiten.IsKeyPressed(ebiten.KeyUp) {
movement.Z -= accel
}
if ebiten.IsKeyPressed(ebiten.KeyDown) {
movement.Z += accel
}
if movement.Magnitude() > friction {
fr := movement.Clone().Unit().Scale(friction).ToVector()
movement.Sub(fr)
} else {
movement.SetZero()
}
movement.ClampMagnitude(maxSpd)
if g.VerticalSpeed < -0.3 {
g.VerticalSpeed = -0.3
}
g.VerticalSpeed -= gravity
// Now, check for collisions.
// Horizontal check (walls):
g.Controlling.MoveVec(g.Movement)
margin := 0.01
bounds.CollisionTest(tetra3d.CollisionTestSettings{
HandleCollision: func(col *tetra3d.Collision) bool {
mtv := col.AverageMTV()
mtv.Y = 0 // We don't want to move up to avoid collision
g.Controlling.MoveVec(mtv.Expand(margin, 0.01)) // Move out of the collision, but add a little margin
return true
},
Others: solids,
})
// Vertical check (floors):
g.Controlling.Move(0, g.VerticalSpeed, 0)
bounds.CollisionTest(tetra3d.CollisionTestSettings{
HandleCollision: func(col *tetra3d.Collision) bool {
g.Controlling.Move(0, col.AverageMTV().Y+margin, 0) // Move the object up, so that it's on the ground, plus a little margin
g.VerticalSpeed = 0
return true
},
Others: solids,
})
if inpututil.IsKeyJustPressed(ebiten.KeyF) {
sphere := g.Scene.Root.Get("Sphere").(*tetra3d.Model)
capsule := g.Scene.Root.Get("YellowCapsule").(*tetra3d.Model)
if g.Controlling == sphere {
g.Controlling = capsule
} else {
g.Controlling = sphere
}
}
g.Camera.Update()
return g.System.Update()
}
func (g *Game) Draw(screen *ebiten.Image) {
// Clear, but with a color
screen.Fill(g.Scene.World.ClearColor.ToRGBA64())
g.Camera.Clear()
g.Camera.RenderScene(g.Scene)
screen.DrawImage(g.Camera.ColorTexture(), nil)
g.System.Draw(screen, g.Camera.Camera)
if g.System.DrawDebugText {
txt := fmt.Sprintf(`This demo shows some basic movement
and collision detection. The red and white cubes
have BoundingAABB nodes, while the capsule and sphere are,
naturally, capsule and sphere BoundingObjects.
Arrow keys: Move %s
F: switch between capsule and sphere`, g.Controlling.Name())
g.Camera.DebugDrawText(screen, txt, 0, 200, 1, colors.LightGray())
}
}
func (g *Game) Layout(w, h int) (int, int) {
return g.Camera.Size()
}
func main() {
ebiten.SetWindowTitle("Tetra3d - Shapes Test")
ebiten.SetWindowResizingMode(ebiten.WindowResizingModeEnabled)
game := NewGame()
if err := ebiten.RunGame(game); err != nil {
panic(err)
}
}
package main
import (
"fmt"
_ "embed"
"github.com/solarlune/tetra3d"
"github.com/solarlune/tetra3d/colors"
"github.com/solarlune/tetra3d/examples"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/inpututil"
)
//go:embed bounds.gltf
var gltfData []byte
type Game struct {
Scene *tetra3d.Scene
Controlling *tetra3d.Model
Movement tetra3d.Vector
VerticalSpeed float64
Camera examples.BasicFreeCam
System examples.BasicSystemHandler
}
func NewGame() *Game {
game := &Game{}
game.Init()
return game
}
func (g *Game) Init() {
library, err := tetra3d.LoadGLTFData(gltfData, nil)
if err != nil {
panic(err)
}
g.Scene = library.FindScene("Scene")
g.Controlling = g.Scene.Root.Get("YellowCapsule").(*tetra3d.Model)
g.Camera = examples.NewBasicFreeCam(g.Scene)
g.Camera.SetLocalPosition(0, 6, 15)
g.Camera.SetFar(40)
g.System = examples.NewBasicSystemHandler(g)
}
func (g *Game) Update() error {
friction := 0.05
maxSpd := 0.25
accel := 0.05 + friction
gravity := 0.05
bounds := g.Controlling.Children()[0].(tetra3d.IBoundingObject)
solids := g.Scene.Root.SearchTree().ByType(tetra3d.NodeTypeBoundingObject).IBoundingObjects()
movement := g.Movement.Modify() // Modification Vector
if ebiten.IsKeyPressed(ebiten.KeyRight) {
movement.X += accel
}
if ebiten.IsKeyPressed(ebiten.KeyLeft) {
movement.X -= accel
}
if ebiten.IsKeyPressed(ebiten.KeyUp) {
movement.Z -= accel
}
if ebiten.IsKeyPressed(ebiten.KeyDown) {
movement.Z += accel
}
if movement.Magnitude() > friction {
fr := movement.Clone().Unit().Scale(friction).ToVector()
movement.Sub(fr)
} else {
movement.SetZero()
}
movement.ClampMagnitude(maxSpd)
if g.VerticalSpeed < -0.3 {
g.VerticalSpeed = -0.3
}
g.VerticalSpeed -= gravity
// Now, check for collisions.
// Horizontal check (walls):
g.Controlling.MoveVec(g.Movement)
margin := 0.01
bounds.CollisionTest(tetra3d.CollisionTestSettings{
HandleCollision: func(col *tetra3d.Collision) bool {
mtv := col.AverageMTV()
mtv.Y = 0 // We don't want to move up to avoid collision
g.Controlling.MoveVec(mtv.Expand(margin, 0.01)) // Move out of the collision, but add a little margin
return true
},
Others: solids,
})
// Vertical check (floors):
g.Controlling.Move(0, g.VerticalSpeed, 0)
bounds.CollisionTest(tetra3d.CollisionTestSettings{
HandleCollision: func(col *tetra3d.Collision) bool {
g.Controlling.Move(0, col.AverageMTV().Y+margin, 0) // Move the object up, so that it's on the ground, plus a little margin
g.VerticalSpeed = 0
return true
},
Others: solids,
})
if inpututil.IsKeyJustPressed(ebiten.KeyF) {
sphere := g.Scene.Root.Get("Sphere").(*tetra3d.Model)
capsule := g.Scene.Root.Get("YellowCapsule").(*tetra3d.Model)
if g.Controlling == sphere {
g.Controlling = capsule
} else {
g.Controlling = sphere
}
}
g.Camera.Update()
return g.System.Update()
}
func (g *Game) Draw(screen *ebiten.Image) {
// Clear, but with a color
screen.Fill(g.Scene.World.ClearColor.ToRGBA64())
g.Camera.Clear()
g.Camera.RenderScene(g.Scene)
screen.DrawImage(g.Camera.ColorTexture(), nil)
g.System.Draw(screen, g.Camera.Camera)
if g.System.DrawDebugText {
txt := fmt.Sprintf(`This demo shows some basic movement
and collision detection. The red and white cubes
have BoundingAABB nodes, while the capsule and sphere are,
naturally, capsule and sphere BoundingObjects.
Arrow keys: Move %s
F: switch between capsule and sphere`, g.Controlling.Name())
g.Camera.DebugDrawText(screen, txt, 0, 200, 1, colors.LightGray())
}
}
func (g *Game) Layout(w, h int) (int, int) {
return g.Camera.Size()
}
func main() {
ebiten.SetWindowTitle("Tetra3d - Shapes Test")
ebiten.SetWindowResizingMode(ebiten.WindowResizingModeEnabled)
game := NewGame()
if err := ebiten.RunGame(game); err != nil {
panic(err)
}
}

View File

@ -1,151 +1,151 @@
package main
import (
_ "embed"
"github.com/solarlune/tetra3d"
"github.com/solarlune/tetra3d/colors"
"github.com/solarlune/tetra3d/examples"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/inpututil"
)
//go:embed cubeLighting.gltf
var gltfData []byte
type Game struct {
Library *tetra3d.Library
Scene *tetra3d.Scene
Camera examples.BasicFreeCam
System examples.BasicSystemHandler
}
func NewGame() *Game {
game := &Game{}
game.Init()
return game
}
func (g *Game) Init() {
library, err := tetra3d.LoadGLTFData(gltfData, nil)
if err != nil {
panic(err)
}
g.Library = library
g.Scene = library.Scenes[0]
g.Camera = examples.NewBasicFreeCam(g.Scene)
g.Camera.Move(0, 10, 10)
g.System = examples.NewBasicSystemHandler(g)
for _, cubeLightModel := range g.Scene.Root.SearchTree().ByProperties(false, "cubelight").Models() {
cubeLight := tetra3d.NewCubeLightFromModel("cube light", cubeLightModel)
cubeLight.Energy = 3
g.Scene.Root.AddChildren(cubeLight)
}
g.Scene.Root.Get("SunLight").Unparent()
}
func (g *Game) Update() error {
cubeLight := g.Scene.Root.Get("cube light").(*tetra3d.CubeLight)
angle := cubeLight.LightingAngle.Modify()
if ebiten.IsKeyPressed(ebiten.KeyRight) {
angle.RotateVec(tetra3d.WorldRight, 0.1)
}
if ebiten.IsKeyPressed(ebiten.KeyLeft) {
angle.RotateVec(tetra3d.WorldRight, -0.1)
}
if ebiten.IsKeyPressed(ebiten.KeyUp) {
cubeLight.Bleed += 0.05
if cubeLight.Bleed > 1 {
cubeLight.Bleed = 1
}
}
if ebiten.IsKeyPressed(ebiten.KeyDown) {
cubeLight.Bleed -= 0.05
if cubeLight.Bleed < 0 {
cubeLight.Bleed = 0
}
}
if inpututil.IsKeyJustPressed(ebiten.KeyE) {
if cubeLight.Distance == 0 {
cubeLight.Distance = 25
} else {
cubeLight.Distance = 0
}
}
if inpututil.IsKeyJustPressed(ebiten.Key2) {
g.Scene.World.LightingOn = !g.Scene.World.LightingOn
}
g.Camera.Update()
return g.System.Update()
}
func (g *Game) Draw(screen *ebiten.Image) {
// Clear, but with a color - we can use the world lighting color for this.
screen.Fill(g.Scene.World.ClearColor.ToRGBA64())
// Clear the Camera
g.Camera.Clear()
// Render the scene
g.Camera.RenderScene(g.Scene)
// We rescale the depth or color textures here just in case we render at a different resolution than the window's; this isn't necessary,
// we could just draw the images straight.
screen.DrawImage(g.Camera.ColorTexture(), nil)
g.System.Draw(screen, g.Camera.Camera)
if g.System.DrawDebugText {
txt := `This example shows a Cube Light.
Cube Lights are volumes that shine from the top down.
If the light's distance is greater than 0, then the
light will be brighter towards the top.
Triangles that lie outside the (AABB)
volume remain unlit.
E Key: Toggle light distance
Left / Right Arrow Key: Rotate Light
Up / Down Arrow Key: Increase / Decrease Bleed
2 Key: Toggle all lighting
`
g.Camera.DebugDrawText(screen, txt, 0, 200, 1, colors.LightGray())
}
}
func (g *Game) Layout(w, h int) (int, int) {
return g.Camera.Size()
}
func main() {
ebiten.SetWindowTitle("Tetra3d - LightGroup Test")
ebiten.SetWindowResizingMode(ebiten.WindowResizingModeEnabled)
game := NewGame()
if err := ebiten.RunGame(game); err != nil {
panic(err)
}
}
package main
import (
_ "embed"
"github.com/solarlune/tetra3d"
"github.com/solarlune/tetra3d/colors"
"github.com/solarlune/tetra3d/examples"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/inpututil"
)
//go:embed cubeLighting.gltf
var gltfData []byte
type Game struct {
Library *tetra3d.Library
Scene *tetra3d.Scene
Camera examples.BasicFreeCam
System examples.BasicSystemHandler
}
func NewGame() *Game {
game := &Game{}
game.Init()
return game
}
func (g *Game) Init() {
library, err := tetra3d.LoadGLTFData(gltfData, nil)
if err != nil {
panic(err)
}
g.Library = library
g.Scene = library.Scenes[0]
g.Camera = examples.NewBasicFreeCam(g.Scene)
g.Camera.Move(0, 10, 10)
g.System = examples.NewBasicSystemHandler(g)
for _, cubeLightModel := range g.Scene.Root.SearchTree().ByProps("cubelight").Models() {
cubeLight := tetra3d.NewCubeLightFromModel("cube light", cubeLightModel)
cubeLight.Energy = 3
g.Scene.Root.AddChildren(cubeLight)
}
g.Scene.Root.Get("SunLight").Unparent()
}
func (g *Game) Update() error {
cubeLight := g.Scene.Root.Get("cube light").(*tetra3d.CubeLight)
angle := cubeLight.LightingAngle.Modify()
if ebiten.IsKeyPressed(ebiten.KeyRight) {
angle.RotateVec(tetra3d.WorldRight, 0.1)
}
if ebiten.IsKeyPressed(ebiten.KeyLeft) {
angle.RotateVec(tetra3d.WorldRight, -0.1)
}
if ebiten.IsKeyPressed(ebiten.KeyUp) {
cubeLight.Bleed += 0.05
if cubeLight.Bleed > 1 {
cubeLight.Bleed = 1
}
}
if ebiten.IsKeyPressed(ebiten.KeyDown) {
cubeLight.Bleed -= 0.05
if cubeLight.Bleed < 0 {
cubeLight.Bleed = 0
}
}
if inpututil.IsKeyJustPressed(ebiten.KeyE) {
if cubeLight.Distance == 0 {
cubeLight.Distance = 25
} else {
cubeLight.Distance = 0
}
}
if inpututil.IsKeyJustPressed(ebiten.Key2) {
g.Scene.World.LightingOn = !g.Scene.World.LightingOn
}
g.Camera.Update()
return g.System.Update()
}
func (g *Game) Draw(screen *ebiten.Image) {
// Clear, but with a color - we can use the world lighting color for this.
screen.Fill(g.Scene.World.ClearColor.ToRGBA64())
// Clear the Camera
g.Camera.Clear()
// Render the scene
g.Camera.RenderScene(g.Scene)
// We rescale the depth or color textures here just in case we render at a different resolution than the window's; this isn't necessary,
// we could just draw the images straight.
screen.DrawImage(g.Camera.ColorTexture(), nil)
g.System.Draw(screen, g.Camera.Camera)
if g.System.DrawDebugText {
txt := `This example shows a Cube Light.
Cube Lights are volumes that shine from the top down.
If the light's distance is greater than 0, then the
light will be brighter towards the top.
Triangles that lie outside the (AABB)
volume remain unlit.
E Key: Toggle light distance
Left / Right Arrow Key: Rotate Light
Up / Down Arrow Key: Increase / Decrease Bleed
2 Key: Toggle all lighting
`
g.Camera.DebugDrawText(screen, txt, 0, 200, 1, colors.LightGray())
}
}
func (g *Game) Layout(w, h int) (int, int) {
return g.Camera.Size()
}
func main() {
ebiten.SetWindowTitle("Tetra3d - LightGroup Test")
ebiten.SetWindowResizingMode(ebiten.WindowResizingModeEnabled)
game := NewGame()
if err := ebiten.RunGame(game); err != nil {
panic(err)
}
}

View File

@ -1,74 +1,74 @@
package main
import (
"github.com/hajimehoshi/ebiten/v2"
"github.com/solarlune/tetra3d"
)
// The concept here is that one can tie Go structs to Tetra3D nodes by designing an interface that directly
// interacts with that node. In this example, we use the Node.SetData() function to store a Go object (in this case,
// a Player) in the Node. A GameObject is anything that fulfills the below interface, which means we can now
// loop through Nodes in our scene and call Update() on whatever's in their Data() space.
type GameObject interface {
Node() tetra3d.INode
Update()
}
type Player struct {
node tetra3d.INode
Bounds *tetra3d.BoundingAABB
}
func NewPlayer(node tetra3d.INode) *Player {
return &Player{
node: node,
Bounds: node.SearchTree().ByType(tetra3d.NodeTypeBoundingAABB).First().(*tetra3d.BoundingAABB),
}
}
func (player *Player) Update() {
move := tetra3d.NewVectorZero()
moveSpd := 0.1
if ebiten.IsKeyPressed(ebiten.KeyLeft) {
move.X -= moveSpd
}
if ebiten.IsKeyPressed(ebiten.KeyRight) {
move.X += moveSpd
}
if ebiten.IsKeyPressed(ebiten.KeyUp) {
move.Z -= moveSpd
}
if ebiten.IsKeyPressed(ebiten.KeyDown) {
move.Z += moveSpd
}
player.node.MoveVec(move)
player.Bounds.CollisionTest(
tetra3d.CollisionTestSettings{
Others: player.node.Root().SearchTree().ByProperties(false, "solid").INodes(),
HandleCollision: func(col *tetra3d.Collision) bool {
if col.BoundingObject.Parent().Properties().Has("death") {
player.node.Unparent() // Unparenting is the equivalent of destroying the node
}
player.node.MoveVec(col.AverageMTV())
return true
},
},
)
}
func (player *Player) Node() tetra3d.INode {
return player.node
}
package main
import (
"github.com/hajimehoshi/ebiten/v2"
"github.com/solarlune/tetra3d"
)
// The concept here is that one can tie Go structs to Tetra3D nodes by designing an interface that directly
// interacts with that node. In this example, we use the Node.SetData() function to store a Go object (in this case,
// a Player) in the Node. A GameObject is anything that fulfills the below interface, which means we can now
// loop through Nodes in our scene and call Update() on whatever's in their Data() space.
type GameObject interface {
Node() tetra3d.INode
Update()
}
type Player struct {
node tetra3d.INode
Bounds *tetra3d.BoundingAABB
}
func NewPlayer(node tetra3d.INode) *Player {
return &Player{
node: node,
Bounds: node.Get("BoundingAABB").(*tetra3d.BoundingAABB),
}
}
func (player *Player) Update() {
move := tetra3d.NewVectorZero()
moveSpd := 0.1
if ebiten.IsKeyPressed(ebiten.KeyLeft) {
move.X -= moveSpd
}
if ebiten.IsKeyPressed(ebiten.KeyRight) {
move.X += moveSpd
}
if ebiten.IsKeyPressed(ebiten.KeyUp) {
move.Z -= moveSpd
}
if ebiten.IsKeyPressed(ebiten.KeyDown) {
move.Z += moveSpd
}
player.node.MoveVec(move)
player.Bounds.CollisionTest(
tetra3d.CollisionTestSettings{
Others: player.node.Root().SearchTree().IBoundingObjectsWithProps("solid"),
HandleCollision: func(col *tetra3d.Collision) bool {
if col.BoundingObject.Parent().Properties().Has("death") {
player.node.Unparent() // Unparenting is the equivalent of destroying the node
}
player.node.MoveVec(col.AverageMTV())
return true
},
},
)
}
func (player *Player) Node() tetra3d.INode {
return player.node
}

View File

@ -66,7 +66,7 @@ func (g *Game) Update() error {
g.Camera.Update()
// Spin the tetrahedrons in the logos around their local orientation:
for _, g := range g.Scene.Root.SearchTree().ByProperties(false, "spin").INodes() {
for _, g := range g.Scene.Root.SearchTree().ByProps("spin").INodes() {
g.Rotate(0, 1, 0, 0.05)
}

View File

@ -57,20 +57,24 @@ func (g *Game) Init() {
// You then change the ParticleSystemSettings struct to make the particles spawn and move as you wish.
partSystem := g.Scene.Root.Get("Fire").(*tetra3d.Model)
g.FireParticleSystem = tetra3d.NewParticleSystem(partSystem, g.Scene.Root.Get("Particle").(*tetra3d.Model))
// We don't parent the fire particle system to the fire particle system node itself because we want the particles to move
// independent of the node
g.FireParticleSystem = tetra3d.NewParticleSystem(g.Scene.Root, g.Scene.Root.Get("Particle").(*tetra3d.Model))
settings := g.FireParticleSystem.Settings
settings.Lifetime.Set(1, 1) // Lifetime can vary randomly; we're setting both the minimum and maximum bounds here to 1.
settings.SpawnRate.Set(0.025, 0.025) // How often particles are spawned
settings.SpawnCount.Set(2, 2) // How many particles are spawned each time
settings.Scale.SetRanges(0.2, 0.4) // The scale of a particle can vary randomly...
settings.Scale.Uniform = true // But we also want the particles to scale uniformly, so they don't appear squashed or stretched
settings.Growth.SetAll(0.02) // Growth is how quickly particles grow in size. If a particle reaches a scale of 0, it will die (unless you disable ParticleSystemSettings.AllowNegativeScale).
settings.Lifetime.Set(1, 1) // Lifetime can vary randomly; we're setting both the minimum and maximum bounds here to 1.
settings.Rate.Set(0.025, 0.025) // How often particles are spawned
settings.Count.Set(2, 2) // How many particles are spawned each time
settings.Scale.SetRanges(0.2, 0.4) // The scale of a particle can vary randomly...
settings.Scale.Uniform = true // But we also want the particles to scale uniformly, so they don't appear squashed or stretched
settings.Growth.SetAll(0.02) // Growth is how quickly particles grow in size. If a particle reaches a scale of 0, it will die (unless you disable ParticleSystemSettings.AllowNegativeScale).
settings.Growth.Uniform = true
settings.SpawnOffset.SetRangeX(-0.1, 0.1) // SpawnOffset is how far the particle can spawn away from the center of the particle system
settings.SpawnOffset.SetRangeZ(-0.1, 0.1)
settings.SpawnOffsetFunction = func(particle *tetra3d.Particle) {
particle.Model.SetWorldPositionVec(g.Scene.Root.Get("Fire").WorldPosition())
}
settings.Velocity.SetRangeY(0.04, 0.04) // Velocity controls a Particle's linear movement, while settings.Acceleration controls a Particle's acceleration (it gathers over time)
@ -83,12 +87,12 @@ func (g *Game) Init() {
// Now, for the Field particle system.
partSystem = g.Scene.Root.Get("Field").(*tetra3d.Model)
partSystem := g.Scene.Root.Get("Field").(*tetra3d.Model)
g.FieldParticleSystem = tetra3d.NewParticleSystem(partSystem, g.Scene.Root.Get("Particle").(*tetra3d.Model), g.Scene.Root.Get("Particle2").(*tetra3d.Model))
settings = g.FieldParticleSystem.Settings
settings.SpawnRate.Set(0.1, 0.1)
settings.SpawnCount.Set(4, 4)
settings.Rate.Set(0.1, 0.1)
settings.Count.Set(4, 4)
settings.Lifetime.Set(2, 3)
settings.Scale.SetRanges(0.05, 0.2)
settings.Scale.Uniform = true
@ -132,8 +136,8 @@ func (g *Game) Init() {
}
settings.SpawnOffset.SetRanges(-0.1, 0.1)
settings.SpawnRate.Set(0.025, 0.025)
settings.SpawnCount.Set(8, 8)
settings.Rate.Set(0.025, 0.025)
settings.Count.Set(8, 8)
settings.Scale.SetRanges(0.25, 0.5)
settings.Scale.Uniform = true
settings.Growth.SetRanges(0.002, 0.004) // Steadily grow just a little bit, with some randomness
@ -154,6 +158,13 @@ func (g *Game) Init() {
g.Camera.Move(0, 5, 5)
g.System = examples.NewBasicSystemHandler(g)
g.Scene.Root.SearchTree().
ByType(tetra3d.NodeTypeModel).
Not(g.Scene.Root.Get("Fire")).
ForEach(func(node tetra3d.INode) {
node.Move(0, 10, 0)
})
}
func (g *Game) Update() error {
@ -165,7 +176,7 @@ func (g *Game) Update() error {
g.FieldParticleSystem.Update(1.0 / 60.0)
g.RingParticleSystem.Update(1.0 / 60.0)
g.FireParticleSystem.Root.Move(math.Sin(g.Time)*0.05, 0, 0)
g.Scene.Root.Get("Fire").Move(math.Sin(g.Time)*0.05, 0, 0)
g.Camera.Update()

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@ -1,108 +1,108 @@
package main
import (
_ "embed"
"github.com/solarlune/tetra3d"
"github.com/solarlune/tetra3d/colors"
"github.com/solarlune/tetra3d/examples"
"github.com/hajimehoshi/ebiten/v2"
)
//go:embed rays.gltf
var blendFile []byte
type Game struct {
Scene *tetra3d.Scene
Camera examples.BasicFreeCam
System examples.BasicSystemHandler
}
func NewGame() *Game {
game := &Game{}
game.Init()
return game
}
func (g *Game) Init() {
// Load the GLTF file and turn it into a Library, which is a collection of scenes and data shared between them (like meshes or animations).
library, err := tetra3d.LoadGLTFData(blendFile, nil)
if err != nil {
panic(err)
}
g.Scene = library.FindScene("Scene")
g.Camera = examples.NewBasicFreeCam(g.Scene)
g.System = examples.NewBasicSystemHandler(g)
// g.Camera.Resize(320, 180)
g.Camera.SetFieldOfView(100)
}
func (g *Game) Update() error {
results := g.Camera.MouseRayTest(g.Camera.Far(), g.Scene.Root.SearchTree().BoundingObjects()...)
if len(results) > 0 {
marker := g.Scene.Root.Get("Marker")
marker.SetWorldPositionVec(results[0].Position.Add(results[0].Normal.Scale(0.5)))
mat := tetra3d.NewLookAtMatrix(marker.WorldPosition(), results[0].Position, tetra3d.WorldUp)
marker.SetWorldRotation(mat)
}
g.Camera.Update()
return g.System.Update()
}
func (g *Game) Draw(screen *ebiten.Image) {
// Clear, but with a color
screen.Fill(g.Scene.World.FogColor.ToRGBA64())
g.Camera.Clear()
g.Camera.RenderScene(g.Scene)
screen.DrawImage(g.Camera.ColorTexture(), nil)
g.System.Draw(screen, g.Camera.Camera)
if g.System.DrawDebugText {
txt := `In this example, a ray is cast
from the mouse, forward. Whatever is hit will
be marked with a blue arrow. If the mouse is locked
to the game window, then the ray shoots directly forward
from the center of the screen.
`
g.Camera.DebugDrawText(screen, txt, 0, 200, 1, colors.LightGray())
}
}
func (g *Game) Layout(w, h int) (int, int) {
return g.Camera.Size()
}
func main() {
ebiten.SetWindowTitle("Tetra3d - Ray Test")
ebiten.SetWindowResizingMode(ebiten.WindowResizingModeEnabled)
game := NewGame()
if err := ebiten.RunGame(game); err != nil {
panic(err)
}
}
package main
import (
_ "embed"
"github.com/solarlune/tetra3d"
"github.com/solarlune/tetra3d/colors"
"github.com/solarlune/tetra3d/examples"
"github.com/hajimehoshi/ebiten/v2"
)
//go:embed rays.gltf
var blendFile []byte
type Game struct {
Scene *tetra3d.Scene
Camera examples.BasicFreeCam
System examples.BasicSystemHandler
}
func NewGame() *Game {
game := &Game{}
game.Init()
return game
}
func (g *Game) Init() {
// Load the GLTF file and turn it into a Library, which is a collection of scenes and data shared between them (like meshes or animations).
library, err := tetra3d.LoadGLTFData(blendFile, nil)
if err != nil {
panic(err)
}
g.Scene = library.FindScene("Scene")
g.Camera = examples.NewBasicFreeCam(g.Scene)
g.System = examples.NewBasicSystemHandler(g)
// g.Camera.Resize(320, 180)
g.Camera.SetFieldOfView(100)
}
func (g *Game) Update() error {
results := g.Camera.MouseRayTest(g.Camera.Far(), g.Scene.Root.SearchTree().IBoundingObjects()...)
if len(results) > 0 {
marker := g.Scene.Root.Get("Marker")
marker.SetWorldPositionVec(results[0].Position.Add(results[0].Normal.Scale(0.5)))
mat := tetra3d.NewLookAtMatrix(marker.WorldPosition(), results[0].Position, tetra3d.WorldUp)
marker.SetWorldRotation(mat)
}
g.Camera.Update()
return g.System.Update()
}
func (g *Game) Draw(screen *ebiten.Image) {
// Clear, but with a color
screen.Fill(g.Scene.World.FogColor.ToRGBA64())
g.Camera.Clear()
g.Camera.RenderScene(g.Scene)
screen.DrawImage(g.Camera.ColorTexture(), nil)
g.System.Draw(screen, g.Camera.Camera)
if g.System.DrawDebugText {
txt := `In this example, a ray is cast
from the mouse, forward. Whatever is hit will
be marked with a blue arrow. If the mouse is locked
to the game window, then the ray shoots directly forward
from the center of the screen.
`
g.Camera.DebugDrawText(screen, txt, 0, 200, 1, colors.LightGray())
}
}
func (g *Game) Layout(w, h int) (int, int) {
return g.Camera.Size()
}
func main() {
ebiten.SetWindowTitle("Tetra3d - Ray Test")
ebiten.SetWindowResizingMode(ebiten.WindowResizingModeEnabled)
game := NewGame()
if err := ebiten.RunGame(game); err != nil {
panic(err)
}
}

2
go.mod
View File

@ -3,7 +3,7 @@ module github.com/solarlune/tetra3d
go 1.18
require (
github.com/hajimehoshi/ebiten/v2 v2.4.16
github.com/hajimehoshi/ebiten/v2 v2.4.18
github.com/qmuntal/gltf v0.23.1
golang.org/x/image v0.1.0
)

4
go.sum
View File

@ -7,8 +7,8 @@ github.com/go-test/deep v1.0.1 h1:UQhStjbkDClarlmv0am7OXXO4/GaPdCGiUiMTvi28sg=
github.com/go-test/deep v1.0.1/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
github.com/hajimehoshi/bitmapfont/v2 v2.2.2 h1:4z08Fk1m3pjtlO7BdoP48u5bp/Y8xmKshf44aCXgYpE=
github.com/hajimehoshi/bitmapfont/v2 v2.2.2/go.mod h1:Ua/x9Dkz7M9CU4zr1VHWOqGwjKdXbOTRsH7lWfb1Co0=
github.com/hajimehoshi/ebiten/v2 v2.4.16 h1:vhuMtaB78N2HlNMfImV/SZkDPNJhOxgFrEIm1uh838o=
github.com/hajimehoshi/ebiten/v2 v2.4.16/go.mod h1:BZcqCU4XHmScUi+lsKexocWcf4offMFwfp8dVGIB/G4=
github.com/hajimehoshi/ebiten/v2 v2.4.18 h1:S6d1iNCxGZhdYh2GOcEnfwaoK37o1CIHRXkrLVQL5dE=
github.com/hajimehoshi/ebiten/v2 v2.4.18/go.mod h1:BZcqCU4XHmScUi+lsKexocWcf4offMFwfp8dVGIB/G4=
github.com/hajimehoshi/file2byteslice v0.0.0-20210813153925-5340248a8f41 h1:s01qIIRG7vN/5ndLwkDktjx44ulFk6apvAjVBYR50Yo=
github.com/hajimehoshi/file2byteslice v0.0.0-20210813153925-5340248a8f41/go.mod h1:CqqAHp7Dk/AqQiwuhV1yT2334qbA/tFWQW0MD2dGqUE=
github.com/hajimehoshi/go-mp3 v0.3.3/go.mod h1:qMJj/CSDxx6CGHiZeCgbiq2DSUkbK0UbtXShQcnfyMM=

View File

@ -210,6 +210,10 @@ type INode interface {
// Sector returns the Sector this Node is in.
Sector() *Sector
sectorHierarchy() *Sector
// DistanceTo returns the distance between the given Nodes' centers.
// Quick syntactic sugar for Node.WorldPosition().Distance(otherNode.WorldPosition()).
DistanceTo(otherNode INode) float64
}
var nodeID uint64 = 0
@ -1073,3 +1077,7 @@ func (node *Node) Sector() *Sector {
return nil
}
func (node *Node) DistanceTo(other INode) float64 {
return node.WorldPosition().Distance(other.WorldPosition())
}

View File

@ -1,240 +1,276 @@
package tetra3d
import (
"regexp"
)
// NodeFilter represents a chain of node filters, executed in sequence to collect the desired nodes
// out of an entire hierarchy. The filters are executed lazily (so only one slice is allocated
// in the process, and possibly one more for the end result, if you get the result as not just a
// slice of INodes).
type NodeFilter struct {
Filters []func(INode) bool // The slice of filters that are currently active on the NodeFilter.
Start INode // The start (root) of the filter.
StopOnFiltered bool // If the filter should continue through to a node's children if the node itself doesn't pass the filter
MaxDepth int // How deep the node filter should search; a value that is less than zero means the entire tree will be traversed.
depth int
}
func newNodeFilter(startingNode INode) *NodeFilter {
return &NodeFilter{
Start: startingNode,
depth: -1,
MaxDepth: -1,
}
}
func (nf *NodeFilter) execute(node INode) []INode {
nf.depth++
out := []INode{}
added := true
if node != nf.Start {
add := true
for _, filter := range nf.Filters {
if !filter(node) {
add = false
added = false
}
}
if add {
out = append(out, node)
}
}
if nf.MaxDepth < 0 || nf.depth <= nf.MaxDepth {
if !nf.StopOnFiltered || added {
for _, child := range node.Children() {
out = append(out, nf.execute(child)...)
}
}
}
nf.depth--
return out
}
// First returns the first Node in the NodeFilter; if the NodeFilter is empty, this function returns nil.
func (nf NodeFilter) First() INode {
out := nf.execute(nf.Start)
if len(out) == 0 {
return nil
}
return out[0]
}
// First returns the last Node in the NodeFilter; if the NodeFilter is empty, this function returns nil.
func (nf NodeFilter) Last() INode {
out := nf.execute(nf.Start)
if len(out) == 0 {
return nil
}
return out[len(out)-1]
}
// Get returns the Node at the given index in the NodeFilter; if index is invalid (<0 or >= len(nodes)), this function returns nil.
func (nf NodeFilter) Get(index int) INode {
out := nf.execute(nf.Start)
if index < 0 || index >= len(out) {
return nil
}
return out[index]
}
// ByFunc allows you to filter a given selection of nodes by the provided filter function (which takes a Node
// and returns a boolean, indicating whether or not to add that Node to the resulting NodeFilter).
// If no matching Nodes are found, an empty NodeFilter is returned.
func (nf NodeFilter) ByFunc(filterFunc func(node INode) bool) NodeFilter {
nf.Filters = append(nf.Filters, filterFunc)
return nf
}
// ByProperties allows you to filter a given selection of nodes by the provided set of property names.
// If parentHas is true, then a Node will be accepted if its parent has the property.
// If no matching Nodes are found, an empty NodeFilter is returned.
func (nf NodeFilter) ByProperties(parentHas bool, propNames ...string) NodeFilter {
nf.Filters = append(nf.Filters, func(node INode) bool {
if parentHas && node.Parent() != nil {
return node.Parent().Properties().Has(propNames...) || node.Properties().Has(propNames...)
}
return node.Properties().Has(propNames...)
})
return nf
}
// ByName allows you to filter a given selection of nodes if their names are wholly equal
// to the provided name string.
// If a Node's name doesn't match, it isn't added to the filter results.
func (nf NodeFilter) ByName(name string) NodeFilter {
nf.Filters = append(nf.Filters, func(node INode) bool { return node.Name() == name })
return nf
}
// ByRegex allows you to filter a given selection of nodes by the given regex string.
// If the regexp string is invalid or no matching Nodes are found, the node isn't
// added to the filter results. See https://regexr.com/ for regex help / reference.
func (nf NodeFilter) ByRegex(regexString string) NodeFilter {
nf.Filters = append(nf.Filters, func(node INode) bool {
match, _ := regexp.MatchString(regexString, node.Name())
return match
})
return nf
}
// ByType allows you to filter a given selection of nodes by the provided NodeType.
// If no matching Nodes are found, an empty NodeFilter is returned.
func (nf NodeFilter) ByType(nodeType NodeType) NodeFilter {
nf.Filters = append(nf.Filters, func(node INode) bool {
return node.Type().Is(nodeType)
})
return nf
}
// Not allows you to filter OUT a given NodeFilter of nodes.
// If a node is in the provided slice of INodes, then it will not be added to the
// final NodeFilter.
func (nf NodeFilter) Not(others []INode) NodeFilter {
nf.Filters = append(nf.Filters, func(node INode) bool {
for _, other := range others {
if node == other {
return false
}
}
return true
})
return nf
}
func (nf NodeFilter) bySectors() NodeFilter {
nf.Filters = append(nf.Filters, func(node INode) bool {
return node.Type() == NodeTypeModel && node.(*Model).sector != nil
})
return nf
}
// ForEach executes the provided function on each Node filtered out. If the function
// returns true, the loop continues; if it returns false, the NodeFilter stops executing
// the function on the results.
func (nf NodeFilter) ForEach(f func(node INode) bool) {
for _, o := range nf.execute(nf.Start) {
if !f(o) {
break
}
}
}
// Contains returns if the provided Node is contained in the NodeFilter.
func (nf NodeFilter) Contains(node INode) bool {
return nf.Index(node) > 0
}
// Index returns the index of the given INode in the NodeFilter; if it doesn't exist in the filter,
// then this function returns -1.
func (nf NodeFilter) Index(node INode) int {
out := nf.execute(nf.Start)
for index, child := range out {
if child == node {
return index
}
}
return -1
}
// Empty returns true if the NodeFilter contains no Nodes.
func (nf NodeFilter) Empty() bool {
return len(nf.execute(nf.Start)) == 0
}
// INodes returns the NodeFilter's results as a slice of INodes.
func (nf NodeFilter) INodes() []INode {
return nf.execute(nf.Start)
}
// BoundingObjects returns a slice of the IBoundingObjects contained within the NodeFilter.
func (nf NodeFilter) BoundingObjects() []IBoundingObject {
out := nf.execute(nf.Start)
boundings := make([]IBoundingObject, 0, len(out))
for _, n := range out {
if b, ok := n.(IBoundingObject); ok {
boundings = append(boundings, b)
}
}
return boundings
}
// Models returns a slice of the Models contained within the NodeFilter.
func (nf NodeFilter) Models() []*Model {
out := nf.execute(nf.Start)
models := make([]*Model, 0, len(out))
for _, n := range out {
if m, ok := n.(*Model); ok {
models = append(models, m)
}
}
return models
}
// Lights returns a slice of the ILights contained within the NodeFilter.
func (nf NodeFilter) Lights() []ILight {
out := nf.execute(nf.Start)
lights := make([]ILight, 0, len(out))
for _, n := range out {
if m, ok := n.(ILight); ok {
lights = append(lights, m)
}
}
return lights
}
// Grids returns a slice of the Grid nodes contained within the NodeFilter.
func (nf NodeFilter) Grids() []*Grid {
out := nf.execute(nf.Start)
grids := make([]*Grid, 0, len(out))
for _, n := range out {
if m, ok := n.(*Grid); ok {
grids = append(grids, m)
}
}
return grids
}
package tetra3d
import (
"regexp"
)
// NodeFilter represents a chain of node filters, executed in sequence to collect the desired nodes
// out of an entire hierarchy. The filters are executed lazily (so only one slice is allocated
// in the process, and possibly one more for the end result, if you get the result as not just a
// slice of INodes).
type NodeFilter struct {
Filters []func(INode) bool // The slice of filters that are currently active on the NodeFilter.
Start INode // The start (root) of the filter.
StopOnFiltered bool // If the filter should continue through to a node's children if the node itself doesn't pass the filter
MaxDepth int // How deep the node filter should search in the starting node's hierarchy; a value that is less than zero means the entire tree will be traversed.
depth int
}
func newNodeFilter(startingNode INode) *NodeFilter {
return &NodeFilter{
Start: startingNode,
depth: -1,
MaxDepth: -1,
}
}
func (nf *NodeFilter) execute(node INode) []INode {
nf.depth++
out := []INode{}
added := true
if node != nf.Start {
add := true
for _, filter := range nf.Filters {
if !filter(node) {
add = false
added = false
}
}
if add {
out = append(out, node)
}
}
if nf.MaxDepth < 0 || nf.depth <= nf.MaxDepth {
if !nf.StopOnFiltered || added {
for _, child := range node.Children() {
out = append(out, nf.execute(child)...)
}
}
}
nf.depth--
return out
}
func (nf *NodeFilter) executeFilters(node INode, execute func(INode)) {
nf.depth++
successfulFilter := true
if node != nf.Start {
ok := true
for _, filter := range nf.Filters {
if !filter(node) {
ok = false
successfulFilter = false
}
}
if ok {
execute(node)
}
}
if nf.MaxDepth < 0 || nf.depth <= nf.MaxDepth {
if !nf.StopOnFiltered || successfulFilter {
for _, child := range node.Children() {
nf.executeFilters(child, execute)
}
}
}
nf.depth--
}
// First returns the first Node in the NodeFilter; if the NodeFilter is empty, this function returns nil.
func (nf NodeFilter) First() INode {
out := nf.execute(nf.Start)
if len(out) == 0 {
return nil
}
return out[0]
}
// First returns the last Node in the NodeFilter; if the NodeFilter is empty, this function returns nil.
func (nf NodeFilter) Last() INode {
out := nf.execute(nf.Start)
if len(out) == 0 {
return nil
}
return out[len(out)-1]
}
// Get returns the Node at the given index in the NodeFilter; if index is invalid (<0 or >= len(nodes)), this function returns nil.
func (nf NodeFilter) Get(index int) INode {
out := nf.execute(nf.Start)
if index < 0 || index >= len(out) {
return nil
}
return out[index]
}
// ByFunc allows you to filter a given selection of nodes by the provided filter function (which takes a Node
// and returns a boolean, indicating whether or not to add that Node to the resulting NodeFilter).
// If no matching Nodes are found, an empty NodeFilter is returned.
func (nf NodeFilter) ByFunc(filterFunc func(node INode) bool) NodeFilter {
nf.Filters = append(nf.Filters, filterFunc)
return nf
}
// ByProps allows you to filter a given selection of nodes by the provided set of property names.
// If no matching Nodes are found, an empty NodeFilter is returned.
func (nf NodeFilter) ByProps(propNames ...string) NodeFilter {
nf.Filters = append(nf.Filters, func(node INode) bool {
return node.Properties().Has(propNames...)
})
return nf
}
// ByParentProps allows you to filter a given selection of nodes if the node has a parent with the provided
// set of property names.
// If no matching Nodes are found, an empty NodeFilter is returned.
func (nf NodeFilter) ByParentProps(propNames ...string) NodeFilter {
nf.Filters = append(nf.Filters, func(node INode) bool {
return node.Parent() != nil && node.Parent().Properties().Has(propNames...)
})
return nf
}
// ByName allows you to filter a given selection of nodes if their names are wholly equal
// to the provided name string.
// If a Node's name doesn't match, it isn't added to the filter results.
func (nf NodeFilter) ByName(name string) NodeFilter {
nf.Filters = append(nf.Filters, func(node INode) bool { return node.Name() == name })
return nf
}
// ByRegex allows you to filter a given selection of nodes by the given regex string.
// If the regexp string is invalid or no matching Nodes are found, the node isn't
// added to the filter results. See https://regexr.com/ for regex help / reference.
func (nf NodeFilter) ByRegex(regexString string) NodeFilter {
nf.Filters = append(nf.Filters, func(node INode) bool {
match, _ := regexp.MatchString(regexString, node.Name())
return match
})
return nf
}
// ByType allows you to filter a given selection of nodes by the provided NodeType.
// If no matching Nodes are found, an empty NodeFilter is returned.
func (nf NodeFilter) ByType(nodeType NodeType) NodeFilter {
nf.Filters = append(nf.Filters, func(node INode) bool {
return node.Type().Is(nodeType)
})
return nf
}
// Not allows you to filter OUT a given NodeFilter of nodes.
// If a node is in the provided slice of INodes, then it will not be added to the
// final NodeFilter.
func (nf NodeFilter) Not(others ...INode) NodeFilter {
nf.Filters = append(nf.Filters, func(node INode) bool {
for _, other := range others {
if node == other {
return false
}
}
return true
})
return nf
}
func (nf NodeFilter) bySectors() NodeFilter {
nf.Filters = append(nf.Filters, func(node INode) bool {
return node.Type() == NodeTypeModel && node.(*Model).sector != nil
})
return nf
}
// ForEach executes the provided function on each Node filtered out, without creating a slice of the nodes.
func (nf NodeFilter) ForEach(f func(node INode)) {
nf.executeFilters(nf.Start, f)
}
// Contains returns if the provided Node is contained in the NodeFilter.
func (nf NodeFilter) Contains(node INode) bool {
return nf.Index(node) > 0
}
// Index returns the index of the given INode in the NodeFilter; if it doesn't exist in the filter,
// then this function returns -1.
func (nf NodeFilter) Index(node INode) int {
out := nf.execute(nf.Start)
for index, child := range out {
if child == node {
return index
}
}
return -1
}
// Empty returns true if the NodeFilter contains no Nodes.
func (nf NodeFilter) Empty() bool {
return len(nf.execute(nf.Start)) == 0
}
// INodes returns the NodeFilter's results as a slice of INodes.
func (nf NodeFilter) INodes() []INode {
return nf.execute(nf.Start)
}
// IBoundingObjects returns a slice of the IBoundingObjects contained within the NodeFilter.
func (nf NodeFilter) IBoundingObjects() []IBoundingObject {
out := nf.execute(nf.Start)
boundings := make([]IBoundingObject, 0, len(out))
for _, n := range out {
if b, ok := n.(IBoundingObject); ok {
boundings = append(boundings, b)
}
}
return boundings
}
// IBoundingObjectsWithProps is a helper function that returns a slice of IBoundingObjects who have parents
// with the specified properties.
func (nf NodeFilter) IBoundingObjectsWithProps(props ...string) []IBoundingObject {
return nf.ByParentProps(props...).IBoundingObjects()
}
// Models returns a slice of the Models contained within the NodeFilter.
func (nf NodeFilter) Models() []*Model {
out := nf.execute(nf.Start)
models := make([]*Model, 0, len(out))
for _, n := range out {
if m, ok := n.(*Model); ok {
models = append(models, m)
}
}
return models
}
// Lights returns a slice of the ILights contained within the NodeFilter.
func (nf NodeFilter) Lights() []ILight {
out := nf.execute(nf.Start)
lights := make([]ILight, 0, len(out))
for _, n := range out {
if m, ok := n.(ILight); ok {
lights = append(lights, m)
}
}
return lights
}
// Grids returns a slice of the Grid nodes contained within the NodeFilter.
func (nf NodeFilter) Grids() []*Grid {
out := nf.execute(nf.Start)
grids := make([]*Grid, 0, len(out))
for _, n := range out {
if m, ok := n.(*Grid); ok {
grids = append(grids, m)
}
}
return grids
}

View File

@ -33,6 +33,7 @@ func NewParticle(partSystem *ParticleSystem, partModels []*Model) *Particle {
clone := p.Clone().(*Model)
clone.visible = false
// clone.FrustumCulling = false
clone.AutoBatchMode = AutoBatchDynamic
bank = append(bank, clone)
}
@ -56,7 +57,9 @@ func (part *Particle) Reinit() {
// Update updates the particle's color and movement.
func (part *Particle) Update(dt float64) {
part.Model.visible = part.ParticleSystem.Root.visible
part.Model.visible = true
// part.Model.visible = part.ParticleSystem.Root.Visible()
part.Life += dt
@ -130,10 +133,9 @@ func (part *Particle) Update(dt float64) {
}
type ParticleSystemSettings struct {
SpawnRate *FloatRange // SpawnRate is how often a particle is spawned in seconds
SpawnCount *IntRange // SpawnCount is how many particles are spawned at a time when a particle is spawned
Lifetime *FloatRange // Lifetime is how long a particle lives in seconds
ParentToSystem bool // If particles should be parented to the owning particle system
Rate *FloatRange // SpawnRate is how often a particle is spawned in seconds
Count *IntRange // SpawnCount is how many particles are spawned at a time when a particle is spawned
Lifetime *FloatRange // Lifetime is how long a particle lives in seconds
Velocity *VectorRange
Acceleration *VectorRange
@ -144,7 +146,8 @@ type ParticleSystemSettings struct {
Friction float64 // Friction to apply to velocity
AllowNegativeScale bool // If negative scale should be allowed for particles. By default, this is false.
VertexSpawnMode int // VertexSpawnMode influences where a particle spawns. By default, this is ParticleVertexSpawnModeOff.
VertexSpawnMode int // VertexSpawnMode influences where a particle spawns. By default, this is ParticleVertexSpawnModeOff.
VertexSpawnModel *Model
// SpawnOffsetFunction is a function the user can use to customize spawning position of the particles within the system. This function
// is called additively to the SpawnOffset setting.
@ -175,9 +178,9 @@ func NewParticleSystemSettings() *ParticleSystemSettings {
spawnCount.Max = 1
return &ParticleSystemSettings{
SpawnRate: spawnRate,
SpawnCount: spawnCount,
Lifetime: lifetime,
Rate: spawnRate,
Count: spawnCount,
Lifetime: lifetime,
Velocity: NewVectorRange(),
SpawnOffset: NewVectorRange(),
@ -194,9 +197,9 @@ func NewParticleSystemSettings() *ParticleSystemSettings {
func (pss *ParticleSystemSettings) Clone() *ParticleSystemSettings {
newPS := &ParticleSystemSettings{
SpawnRate: pss.SpawnRate,
SpawnCount: pss.SpawnCount,
Lifetime: pss.Lifetime.Clone(),
Rate: pss.Rate,
Count: pss.Count,
Lifetime: pss.Lifetime.Clone(),
Velocity: pss.Velocity.Clone(),
Acceleration: pss.Acceleration.Clone(),
@ -225,7 +228,7 @@ type ParticleSystem struct {
On bool
ParticleFactories []*Model
Root *Model
Root INode
spawnTimer float64
Settings *ParticleSystemSettings
@ -233,14 +236,14 @@ type ParticleSystem struct {
}
// NewParticleSystem creates a new ParticleSystem, operating on the systemNode and randomly creating particles from the provided collection of particle Models.
func NewParticleSystem(systemNode *Model, particles ...*Model) *ParticleSystem {
func NewParticleSystem(systemNode INode, particles ...*Model) *ParticleSystem {
for _, part := range particles {
mat := part.Mesh.MeshParts[0].Material
if systemNode.Mesh.FindMeshPart(mat.Name) == nil {
systemNode.Mesh.AddMeshPart(part.Mesh.MeshParts[0].Material)
}
}
// for _, part := range particles {
// mat := part.Mesh.MeshParts[0].Material
// if systemNode.Mesh.FindMeshPart(mat.Name) == nil {
// systemNode.Mesh.AddMeshPart(part.Mesh.MeshParts[0].Material)
// }
// }
// systemNode.FrustumCulling = false // if we leave frustum culling on, the particles will turn invisible if the batch goes offscreen
@ -261,7 +264,7 @@ func NewParticleSystem(systemNode *Model, particles ...*Model) *ParticleSystem {
On: true,
}
partSys.Root.SetVisible(false, false)
// partSys.Root.SetVisible(false, false)
return partSys
@ -302,18 +305,18 @@ func (ps *ParticleSystem) Update(dt float64) {
}
if ps.spawnTimer <= 0 {
spawnCount := int(ps.Settings.SpawnCount.Value())
spawnCount := int(ps.Settings.Count.Value())
for i := 0; i < spawnCount; i++ {
ps.Spawn()
}
ps.spawnTimer = ps.Settings.SpawnRate.Value()
ps.spawnTimer = ps.Settings.Rate.Value()
}
ps.spawnTimer -= dt
if len(ps.Root.DynamicBatchModels) > 0 {
ps.Root.SetVisible(true, true)
}
// if len(ps.Root.DynamicBatchModels) > 0 {
// ps.Root.SetVisible(true, true)
// }
}
@ -327,9 +330,9 @@ func (ps *ParticleSystem) Spawn() {
ps.DeadParticles = ps.DeadParticles[1:]
} else {
part = NewParticle(ps, ps.ParticleFactories)
for _, newModel := range part.ModelBank {
ps.Root.DynamicBatchAdd(ps.Root.Mesh.FindMeshPart(part.Model.Mesh.MeshParts[0].Material.Name), newModel)
}
// for _, newModel := range part.ModelBank {
// ps.Root.DynamicBatchAdd(ps.Root.Mesh.FindMeshPart(part.Model.Mesh.MeshParts[0].Material.Name), newModel)
// }
}
ps.LivingParticles = append(ps.LivingParticles, part)
@ -338,9 +341,7 @@ func (ps *ParticleSystem) Spawn() {
part.Reinit()
if ps.Settings.ParentToSystem {
ps.Root.AddChildren(part.Model)
}
ps.Root.AddChildren(part.Model)
part.Model.SetWorldScaleVec(ps.Settings.Scale.Value())
@ -351,14 +352,16 @@ func (ps *ParticleSystem) Spawn() {
var pos Vector
if ps.Settings.VertexSpawnMode != ParticleVertexSpawnModeOff {
if ps.Settings.VertexSpawnMode != ParticleVertexSpawnModeOff && ps.Settings.VertexSpawnModel != nil {
vertCount := len(ps.Root.Mesh.VertexPositions)
model := ps.Settings.VertexSpawnModel
if ps.Root.skinned {
pos = ps.Root.Mesh.vertexSkinnedPositions[ps.vertexSpawnIndex]
vertCount := len(model.Mesh.VertexPositions)
if model.skinned {
pos = model.Mesh.vertexSkinnedPositions[ps.vertexSpawnIndex]
} else {
pos = ps.Root.Transform().MultVec(ps.Root.Mesh.VertexPositions[ps.vertexSpawnIndex])
pos = model.Transform().MultVec(model.Mesh.VertexPositions[ps.vertexSpawnIndex])
}
switch ps.Settings.VertexSpawnMode {

810
readme.md
View File

@ -1,401 +1,409 @@
# Tetra3D
[Ebitengine Discord](https://discord.gg/fXM7VYASTu)
[SolarLune's Discord](https://discord.gg/cepcpfV)
[TetraTerm, an easy-to-use tool to visualize your game scene](https://github.com/SolarLune/tetraterm)
![Tetra3D Logo](https://user-images.githubusercontent.com/4733521/207243838-b3ece6c4-965a-4cb5-aa81-b6b61f34d4d4.gif)
![It Breeds Fear - Construction Worker](https://thumbs.gfycat.com/ThoughtfulChubbyBunny-size_restricted.gif)
![Dark exploration](https://thumbs.gfycat.com/ScalySlimCrayfish-size_restricted.gif)
[Tetra3D Docs](https://pkg.go.dev/github.com/solarlune/tetra3d) / [Tetra3D Wiki](https://github.com/SolarLune/Tetra3d/wiki)
[Quickstart Project Repo](https://github.com/SolarLune/tetra3d-quickstart)
## Support
If you want to support development, feel free to check out my [itch.io](https://solarlune.itch.io/masterplan) / [Steam](https://store.steampowered.com/app/1269310/MasterPlan/) / [Patreon](https://www.patreon.com/SolarLune). I also have a [Discord server here](https://discord.gg/cepcpfV). Thanks~!
## What is Tetra3D?
Tetra3D is a 3D hybrid software / hardware renderer written in Go by means of [Ebitengine](https://ebiten.org/), primarily for video games. Compared to a professional 3D rendering system like OpenGL or Vulkan, it's slow and buggy, but _it's also janky_, and I love it for that. Tetra3D is largely implemented in software, but uses the GPU a bit for rendering triangles and for depth testing (by use of shaders to compare and write depth and composite the result onto the finished texture). Depth testing can be turned off for a slight performance increase in exchange for no visual inter-object intersection.
Tetra3D's rendering evokes a similar feeling to primitive 3D game consoles like the PS1, N64, or DS. Being that a largely-software renderer is not _nearly_ fast enough for big, modern 3D titles, the best you're going to get out of Tetra is drawing some 3D elements for your primarily 2D Ebitengine game, or a relatively simple fully 3D game (i.e. something on the level of a PS1, or N64 game). That said, limitation breeds creativity, and I am intrigued at the thought of what people could make with Tetra.
Tetra3D also gives you a Blender add-on to make the Blender > Tetra3D development process flow a bit smoother. See the Releases section for the add-on, and [this wiki page](https://github.com/SolarLune/Tetra3d/wiki/Blender-Addon) for more information.
## Why did I make it?
Because there's not really too much of an ability to do 3D for gamedev in Go apart from [g3n](http://g3n.rocks), [go-gl](https://github.com/go-gl/gl) and [Raylib-go](https://github.com/gen2brain/raylib-go). I like Go, I like janky 3D, and so, here we are.
It's also interesting to have the ability to spontaneously do things in 3D sometimes. For example, if you were making a 2D game with Ebitengine but wanted to display just a few GUI elements or objects in 3D, Tetra3D should work well for you.
Finally, while this hybrid renderer is not by any means fast, it is relatively simple and easy to use. Any platforms that Ebiten supports _should_ also work for Tetra3D automatically. Basing a 3D framework off of an existing 2D framework also means any improvements or refinements to Ebitengine may be of help to Tetra3D, and it keeps the codebase small and unified between platforms.
## Why Tetra3D? Why is it named that?
Because it's like a [tetrahedron](https://en.wikipedia.org/wiki/Tetrahedron), a relatively primitive (but visually interesting) 3D shape made of 4 triangles. Otherwise, I had other names, but I didn't really like them very much. "Jank3D" was the second-best one, haha.
## How do you get it?
`go get github.com/solarlune/tetra3d`
Tetra depends on [Ebitengine](https://ebiten.org/) itself for rendering. Tetra3D requires Go v1.16 or above. This minimum required version is somewhat arbitrary, as it could run on an older Go version if a couple of functions (primarily the ones that loads data from a file directly) were changed.
There is an optional Blender add-on as well (`tetra3d.py`) that can be downloaded from the releases page or from the repo directly (i.e. click on the file and download it). The add-on provides some useful helper functionality that makes using Tetra3D simpler - for more information, check the [Wiki](https://github.com/SolarLune/Tetra3d/wiki/Blender-Addon).
## How do you use it?
Load a scene, render it. A simple 3D framework means a simple 3D API.
Here's an example:
```go
package main
import (
"errors"
"fmt"
"image/color"
"github.com/solarlune/tetra3d"
"github.com/hajimehoshi/ebiten/v2"
)
type Game struct {
GameScene *tetra3d.Scene
Camera *tetra3d.Camera
}
func NewGame() *Game {
g := &Game{}
// First, we load a scene from a .gltf or .glb file. LoadGLTFFile takes a filepath and
// any loading options (nil can be taken as a valid default set of loading options), and
// returns a tetra3d.*Library and an error if it was unsuccessful. We can also use
// tetra3d.LoadGLTFData() if we don't have access to the host OS's filesystem (if the
// assets are embedded, for example).
library, err := tetra3d.LoadGLTFFile("example.gltf", nil)
if err != nil {
panic(err)
}
// A Library is essentially everything that got exported from your 3D modeler -
// all of the scenes, meshes, materials, and animations. The ExportedScene of a Library
// is the scene that was active when the file was exported.
// We'll clone the ExportedScene so we don't change it irreversibly; making a clone
// of a Tetra3D resource (Scene, Node, Material, Mesh, Camera, whatever) makes a deep
// copy of it.
g.GameScene = library.ExportedScene.Clone()
// Tetra3D uses OpenGL's coordinate system (+X = Right, +Y = Up, +Z = Forward),
// in comparison to Blender's coordinate system (+X = Right, +Y = Forward,
// +Z = Up). Note that when loading models in via GLTF or DAE, models are
// converted automatically (so up is +Z in Blender and +Y in Tetra3D automatically).
// We could create a new Camera as below - we would pass the size of the screen to the
// Camera so it can create its own buffer textures (which are *ebiten.Images).
// g.Camera = tetra3d.NewCamera(ScreenWidth, ScreenHeight)
// However, we can also just grab an existing camera from the scene if it
// were exported from the GLTF file - if exported through Blender's Tetra3D add-on,
// then the camera size can be set from within Blender.
g.Camera = g.GameScene.Root.Get("Camera").(*tetra3d.Camera)
// Camera implements the tetra3d.INode interface, which means it can be placed
// in 3D space and can be parented to another Node somewhere in the scene tree.
// Models, Lights, and Nodes (which are essentially "empties" one can
// use for positioning and parenting) can, as well.
// We can place Models, Cameras, and other Nodes with node.SetWorldPosition() or
// node.SetLocalPosition(). There are also variants that take a 3D Vector.
// The *World variants of positioning functions takes into account absolute space;
// the Local variants position Nodes relative to their parents' positioning and
// transforms (and is more performant.)
// You can also move Nodes using Node.Move(x, y, z) / Node.MoveVec(vector).
// Each Scene has a tree that starts with the Root Node. To add Nodes to the Scene,
// parent them to the Scene's base, like so:
// scene.Root.AddChildren(object)
// To remove them, either use Node.RemoveChildren() (so something like, scene.Root.RemoveChildren(object))
// or Node.Unparent() (in this case, object.Unparent() ).
// For Cameras, we don't actually need to place them in the scene to view it, since
// the presence of the Camera in the Scene node tree doesn't impact what it would see.
// We can see the tree "visually" by printing out the hierarchy:
fmt.Println(g.GameScene.Root.HierarchyAsString())
// You can also visualize the scene hierarchy using TetraTerm:
// https://github.com/SolarLune/tetraterm
return g
}
func (g *Game) Update() error { return nil }
func (g *Game) Draw(screen *ebiten.Image) {
// Here, we'll call Camera.Clear() to clear its internal backing texture. This
// should be called once per frame before drawing your Scene.
g.Camera.Clear()
// Now we'll render the Scene from the camera. The Camera's ColorTexture will then
// hold the result.
// Camera.RenderScene() renders all Nodes in a scene, starting with the
// scene's root. You can also use Camera.Render() to simply render a selection of
// individual Models, or Camera.RenderNodes() to render a subset of a scene tree.
g.Camera.RenderScene(g.GameScene)
// To see the result, we draw the Camera's ColorTexture to the screen. Before doing so, we'll clear the screen first;
// in this case, with a color, though we can also go with screen.Clear().
screen.Fill(color.RGBA{20, 30, 40, 255})
// Draw the resulting texture to the screen, and you're done! You can
// also visualize the depth texture with g.Camera.DepthTexture.
screen.DrawImage(g.Camera.ColorTexture, nil)
// Note that the resulting texture is indeed just an ordinary *ebiten.Image, so
// you can also use this as a texture for a mesh, as an example.
}
func (g *Game) Layout(w, h int) (int, int) {
// Here, by simply returning the camera's size, we are essentially
// scaling the camera's output to the window size and letterboxing as necessary.
// If you wanted to extend the camera according to window size, you would
// have to resize the camera using the new width and height.
return g.Camera.Size()
}
func main() {
game := NewGame()
if err := ebiten.RunGame(game); err != nil {
panic(err)
}
}
```
You can also do collision testing between BoundingObjects, a category of nodes designed for this purpose. As a simplified example:
```go
type Game struct {
Cube *tetra3d.BoundingAABB
Capsule *tetra3d.BoundingCapsule
}
func NewGame() *Game {
g := &Game{}
// Create a new BoundingCapsule named "player", 1 unit tall with a 0.25 unit radius for the caps at the ends.
g.Capsule = tetra3d.NewBoundingCapsule("player", 1, 0.25)
// Create a new BoundingAABB named "block", of 0.5 width, height, and depth (in that order).
g.Cube = tetra3d.NewBoundingAABB("block", 0.5, 0.5, 0.5)
// Move it over on the X axis by 4 units.
g.Cube.Move(4, 0, 0)
return g
}
func (g *Game) Update() {
// Move the capsule 0.2 units to the right every frame.
g.Capsule.Move(0.2, 0, 0)
// Will print the result of the Collision, or nil, if there was no intersection.
fmt.Println(g.Capsule.Collision(g.Cube))
}
```
If you wanted a deeper collision test with multiple objects, you can do so using `IBoundingObject.CollisionTest()`. Take a look at the [Wiki](https://github.com/SolarLune/tetra3d/wiki/Collision-Testing) and the `bounds` example for more info.
That's basically it. Note that Tetra3D is, indeed, a work-in-progress and so will require time to get to a good state. But I feel like it works pretty well as is. Feel free to examine all of the examples in the `examples` folder. Calling `go run .` from within their directories will run them - the mouse usually controls the view, and clicking locks and unlocks the view.
There's a quick start project repo available [here](https://github.com/SolarLune/tetra3d-quickstart), as well to help with getting started.
For more information, check out the [Wiki](https://github.com/SolarLune/Tetra3d/wiki) for tips and tricks.
## What's missing?
The following is a rough to-do list (tasks with checks have been implemented):
- [X] **3D rendering**
- [X] -- Perspective projection
- [X] -- Orthographic projection (it's kinda jank, but it works)
- [x] -- Automatic billboarding
- [x] -- Sprites (a way to draw 2D images with no perspective changes (if desired), but within 3D space) (not sure?)
- [X] -- Basic depth sorting (sorting vertices in a model according to distance, sorting models according to distance)
- [X] -- A depth buffer and [depth testing](https://learnopengl.com/Advanced-OpenGL/Depth-testing) - This is now implemented by means of a depth texture and [Kage shader](https://ebiten.org/documents/shader.html#Shading_language_Kage), though the downside is that it requires rendering and compositing the scene into textures _twice_. Also, it doesn't work on triangles from the same object (as we can't render to the depth texture while reading it for existing depth).
- [X] -- A more advanced / accurate depth buffer
- [x] -- ~~Writing depth through some other means than vertex colors for precision~~ _This is fine for now, I think._
- [ ] -- Depth testing within the same object - I'm unsure if I will be able to implement this.
- [X] -- Offscreen Rendering
- [X] -- Mesh merging - Meshes can be merged together to lessen individual object draw calls.
- [x] -- Render batching - We can avoid calling Image.DrawTriangles between objects if they share properties (blend mode, material, etc) and it's not too many triangles to push before flushing to the GPU. Perhaps these Materials can have a flag that you can toggle to enable this behavior? (EDIT: This has been partially added by dynamic batching of Models.)
- [ ] -- Texture wrapping (will require rendering with shaders) - This is kind of implemented, but I don't believe it's been implemented for alpha clip materials.
- [ ] -- Draw triangle in 3D space through a function (could be useful for 3D lines, for example)
- [ ] -- Easy dynamic 3D Text (to make this simple, it might be best to allow the user to render the text as he wishes, and then make a function to map it (or any other *Image) to a plane of variable size).
- [ ] -- Lighting Probes - general idea is to be able to specify a space that has basic (optionally continuously updated) AO and lighting information, so standing a character in this spot makes him greener, that spot redder, that spot darker because he's in the shadows, etc.
- [X] **Culling**
- [X] -- Backface culling
- [X] -- Frustum culling
- [X] -- Far triangle culling
- [ ] -- Triangle clipping to view (this isn't implemented, but not having it doesn't seem to be too much of a problem for now)
- [ ] -- Sectors - The general idea is that the camera can be set up to only render sectors that it's in / neighboring (up to a customizeable depth)
- [X] **Debug**
- [X] -- Debug text: overall render time, FPS, render call count, vertex count, triangle count, skipped triangle count
- [X] -- Wireframe debug rendering
- [X] -- Normal debug rendering
- [X] **Materials**
- [X] -- Basic Texturing
- [X] -- Multitexturing / Per-triangle Materials
- [ ] -- Perspective-corrected texturing (currently it's affine, see [Wikipedia](https://en.wikipedia.org/wiki/Texture_mapping#Affine_texture_mapping))
- [ ] -- Screen-coordinate instead of UV texturing (useful for repeating patterns)
- [X] **Animations**
- [X] -- Armature-based animations
- [X] -- Object transform-based animations
- [ ] -- Blending between animations (this works, but can be a bit buggy and needs to be looked at)
- [X] -- Linear keyframe interpolation
- [X] -- Constant keyframe interpolation
- [ ] -- Bezier keyframe interpolation
- [ ] -- Morph (mesh-based) animations
- [X] **Scenes**
- [X] -- Fog
- [X] -- A node or scenegraph for parenting and simple visibility culling
- [X] -- Ambient vertex coloring
- [ ] -- Multiple vertex color channels
- [X] **GLTF / GLB model loading**
- [X] -- Vertex colors loading
- [X] -- UV map loading
- [X] -- Normal loading
- [X] -- Transform / full scene loading
- [X] -- Animation loading
- [X] -- Camera loading
- [X] -- Loading world color in as ambient lighting
- [ ] -- Separate .bin loading
- [x] -- Support for multiple scenes in a single Blend file (was broken due to GLTF exporter changes; working again in Blender 3.3)
- [X] **Blender Add-on**
- [ ] -- Custom mesh attribute to assign values to vertices, allowing you to, say, "mark" vertices
- [X] -- Export GLTF on save / on command via button
- [X] -- Bounds node creation
- [X] -- Game property export (less clunky version of Blender's vanilla custom properties)
- [X] -- Collection / group substitution
- [X] -- -- Overwriting properties through collection instance objects (it would be nice to do this cleanly with a nice UI, but just hamfisting it is fine for now)
- [X] -- -- Collection instances instantiate their objects in the same location in the tree
- [x] -- Optional camera size export
- [X] -- Linking collections from external files
- [X] -- Material data export
- [X] -- Option to pack textures or leave them as a path
- [X] -- Path / 3D Curve support
- [X] -- Grid support (for pathfinding / linking 3D points together)
- [ ] -- Toggleable option for drawing game property status to screen for each object using the gpu and blf modules
- [ ] -- Game properties should be an ordered slice, rather than a map of property name to property values.
- [X] **DAE model loading**
- [X] -- Vertex colors loading
- [X] -- UV map loading
- [X] -- Normal loading
- [X] -- Transform / full scene loading
- [X] **Lighting**
- [X] -- Smooth shading
- [X] -- Ambient lights
- [X] -- Point lights
- [X] -- Directional lights
- [X] -- Cube (AABB volume) lights
- [X] -- Lighting Groups
- [X] -- Ability to bake lighting to vertex colors
- [X] -- Ability to bake ambient occlusion to vertex colors
- [ ] -- Specular lighting (shininess)
- [ ] -- Take into account view normal (seems most useful for seeing a dark side if looking at a non-backface-culled triangle that is lit) - This is now done for point lights, but not sun lights
- [ ] -- Per-fragment lighting (by pushing it to the GPU, it would be more efficient and look better, of course)
- [X] **Shaders**
- [X] -- Custom fragment shaders
- [ ] -- Normal rendering (useful for, say, screen-space shaders)
- [X] **Collision Testing**
- [X] -- Normal reporting
- [X] -- Slope reporting
- [X] -- Contact point reporting
- [X] -- Varying collision shapes
- [X] -- Checking multiple collisions at the same time
- [X] -- Composing collision shapes out of multiple sub-shapes (this can be done by simply creating them, parenting them to some node, and then testing against that node)
- [X] -- Bounding / Broadphase collision checking
| Collision Type | Sphere | AABB | Triangle | Capsule |
| ---------------- | -------- | ------------ | ------------ | --------- |
| Sphere | ✅ | ✅ | ✅ | ✅ |
| AABB | ✅ | ✅ | ⛔ (buggy) | ✅ |
| Triangle | ✅ | ⛔ (buggy) | ⛔ (buggy) | ✅ |
| Capsule | ✅ | ✅ | ✅ | ⛔ (buggy) |
| Ray | ✅ | ✅ | ✅ | ✅ |
- [ ] -- An actual collision system?
- [ ] **3D Sound** (adjusting panning of sound sources based on 3D location)
- [ ] **Optimization**
- [ ] -- It might be possible to not have to write depth manually
- [ ] -- Make NodeFilters work lazily, rather than gathering all nodes in the filter at once
- [x] -- Reusing vertex indices for adjacent triangles
- [ ] -- Multithreading (particularly for vertex transformations)
- [X] -- Armature animation improvements?
- [X] -- Custom Vectors
- [ ] -- -- Vector pools again?
- [ ] -- Matrix pools?
- [ ] -- Raytest optimization
- [ ] -- -- Sphere?
- [ ] -- -- AABB?
- [ ] -- -- Capsule?
- [ ] -- -- Triangles
- [ ] -- -- -- Maybe we can combine contiguous triangles into faces and just check faces?
- [ ] -- -- -- We could use the broadphase system to find triangles that are in the raycast area, specifically
- [ ] -- Instead of doing cosllision testing using triangles directly, we can test against planes / faces if possible to reduce checks?
- [ ] -- Lighting speed improvements
- [ ] -- Occlusion culling - this should be possible using octrees to determine if an object is visible before rendering it; see: https://www.gamedeveloper.com/programming/occlusion-culling-algorithms
- [ ] -- [Prefer Discrete GPU](https://github.com/silbinarywolf/preferdiscretegpu) for computers with both discrete and integrated graphics cards
Again, it's incomplete and jank. However, it's also pretty cool!
## Shout-out time~
Huge shout-out to the open-source community:
- StackOverflow, in general, _FOR REAL_ - this would've been impossible otherwise
- [quartercastle's vector package](https://github.com/quartercastle/vector)
- [fauxgl](https://github.com/fogleman/fauxgl)
- [tinyrenderer](https://github.com/ssloy/tinyrenderer)
- [learnopengl.com](https://learnopengl.com/Getting-started/Coordinate-Systems)
- [3DCollisions](https://gdbooks.gitbooks.io/3dcollisions/content/)
- [Simon Rodriguez's "Writing a Small Software Renderer" article](http://blog.simonrodriguez.fr/articles/2017/02/writing_a_small_software_renderer.html)
- And many other articles that I've forgotten to note down
... For sharing the information and code to make this possible; I would definitely have never been able to create this otherwise.
# Tetra3D
[Ebitengine Discord](https://discord.gg/fXM7VYASTu)
[SolarLune's Discord](https://discord.gg/cepcpfV)
[TetraTerm, an easy-to-use tool to visualize your game scene](https://github.com/SolarLune/tetraterm)
![Tetra3D Logo](https://user-images.githubusercontent.com/4733521/207243838-b3ece6c4-965a-4cb5-aa81-b6b61f34d4d4.gif)
![It Breeds Fear - Construction Worker](https://thumbs.gfycat.com/ThoughtfulChubbyBunny-size_restricted.gif)
![Dark exploration](https://thumbs.gfycat.com/ScalySlimCrayfish-size_restricted.gif)
[Tetra3D Docs](https://pkg.go.dev/github.com/solarlune/tetra3d) / [Tetra3D Wiki](https://github.com/SolarLune/Tetra3d/wiki)
[Quickstart Project Repo](https://github.com/SolarLune/tetra3d-quickstart)
## Support
If you want to support development, feel free to check out my [itch.io](https://solarlune.itch.io/masterplan) / [Steam](https://store.steampowered.com/app/1269310/MasterPlan/) / [Patreon](https://www.patreon.com/SolarLune). I also have a [Discord server here](https://discord.gg/cepcpfV). Thanks~!
## What is Tetra3D?
Tetra3D is a 3D hybrid software / hardware renderer written in Go by means of [Ebitengine](https://ebiten.org/), primarily for video games. Compared to a professional 3D rendering system like OpenGL or Vulkan, it's slow and buggy, but _it's also janky_, and I love it for that. Tetra3D is largely implemented in software, but uses the GPU a bit for rendering triangles and for depth testing (by use of shaders to compare and write depth and composite the result onto the finished texture). Depth testing can be turned off for a slight performance increase in exchange for no visual inter-object intersection.
Tetra3D's rendering evokes a similar feeling to primitive 3D game consoles like the PS1, N64, or DS. Being that a largely-software renderer is not _nearly_ fast enough for big, modern 3D titles, the best you're going to get out of Tetra is drawing some 3D elements for your primarily 2D Ebitengine game, or a relatively simple fully 3D game (i.e. something on the level of a PS1, or N64 game). That said, limitation breeds creativity, and I am intrigued at the thought of what people could make with Tetra.
Tetra3D also gives you a Blender add-on to make the Blender > Tetra3D development process flow a bit smoother. See the Releases section for the add-on, and [this wiki page](https://github.com/SolarLune/Tetra3d/wiki/Blender-Addon) for more information.
## Why did I make it?
Because there's not really too much of an ability to do 3D for gamedev in Go apart from [g3n](http://g3n.rocks), [go-gl](https://github.com/go-gl/gl) and [Raylib-go](https://github.com/gen2brain/raylib-go). I like Go, I like janky 3D, and so, here we are.
It's also interesting to have the ability to spontaneously do things in 3D sometimes. For example, if you were making a 2D game with Ebitengine but wanted to display just a few GUI elements or objects in 3D, Tetra3D should work well for you.
Finally, while this hybrid renderer is not by any means fast, it is relatively simple and easy to use. Any platforms that Ebiten supports _should_ also work for Tetra3D automatically. Basing a 3D framework off of an existing 2D framework also means any improvements or refinements to Ebitengine may be of help to Tetra3D, and it keeps the codebase small and unified between platforms.
## Why Tetra3D? Why is it named that?
Because it's like a [tetrahedron](https://en.wikipedia.org/wiki/Tetrahedron), a relatively primitive (but visually interesting) 3D shape made of 4 triangles. Otherwise, I had other names, but I didn't really like them very much. "Jank3D" was the second-best one, haha.
## How do you get it?
`go get github.com/solarlune/tetra3d`
Tetra depends on [Ebitengine](https://ebiten.org/) itself for rendering. Tetra3D requires Go v1.16 or above. This minimum required version is somewhat arbitrary, as it could run on an older Go version if a couple of functions (primarily the ones that loads data from a file directly) were changed.
There is an optional Blender add-on as well (`tetra3d.py`) that can be downloaded from the releases page or from the repo directly (i.e. click on the file and download it). The add-on provides some useful helper functionality that makes using Tetra3D simpler - for more information, check the [Wiki](https://github.com/SolarLune/Tetra3d/wiki/Blender-Addon).
## How do you use it?
Load a scene, render it. A simple 3D framework means a simple 3D API.
Here's an example:
```go
package main
import (
"errors"
"fmt"
"image/color"
"github.com/solarlune/tetra3d"
"github.com/hajimehoshi/ebiten/v2"
)
type Game struct {
GameScene *tetra3d.Scene
Camera *tetra3d.Camera
}
func NewGame() *Game {
g := &Game{}
// First, we load a scene from a .gltf or .glb file. LoadGLTFFile takes a filepath and
// any loading options (nil can be taken as a valid default set of loading options), and
// returns a *tetra3d.Library and an error if it was unsuccessful. We can also use
// tetra3d.LoadGLTFData() if we don't have access to the host OS's filesystem (if the
// assets are embedded, for example).
library, err := tetra3d.LoadGLTFFile("example.gltf", nil)
if err != nil {
panic(err)
}
// A Library is essentially everything that got exported from your 3D modeler -
// all of the scenes, meshes, materials, and animations. The ExportedScene of a Library
// is the scene that was active when the file was exported.
// We'll clone the ExportedScene so we don't change it irreversibly; making a clone
// of a Tetra3D resource (Scene, Node, Material, Mesh, Camera, whatever) makes a deep
// copy of it.
g.GameScene = library.ExportedScene.Clone()
// Tetra3D uses OpenGL's coordinate system (+X = Right, +Y = Up, +Z = Backward [towards the camera]),
// in comparison to Blender's coordinate system (+X = Right, +Y = Forward,
// +Z = Up). Note that when loading models in via GLTF or DAE, models are
// converted automatically (so up is +Z in Blender and +Y in Tetra3D automatically).
// We could create a new Camera as below - we would pass the size of the screen to the
// Camera so it can create its own buffer textures (which are *ebiten.Images).
// g.Camera = tetra3d.NewCamera(ScreenWidth, ScreenHeight)
// However, we can also just grab an existing camera from the scene if it
// were exported from the GLTF file - if exported through Blender's Tetra3D add-on,
// then the camera size can be easily set from within Blender.
g.Camera = g.GameScene.Root.Get("Camera").(*tetra3d.Camera)
// Camera implements the tetra3d.INode interface, which means it can be placed
// in 3D space and can be parented to another Node somewhere in the scene tree.
// Models, Lights, and Nodes (which are essentially "empties" one can
// use for positioning and parenting) can, as well.
// We can place Models, Cameras, and other Nodes with node.SetWorldPosition() or
// node.SetLocalPosition(). There are also variants that take a 3D Vector.
// The *World variants of positioning functions takes into account absolute space;
// the Local variants position Nodes relative to their parents' positioning and
// transforms (and is more performant.)
// You can also move Nodes using Node.Move(x, y, z) / Node.MoveVec(vector).
// Each Scene has a tree that starts with the Root Node. To add Nodes to the Scene,
// parent them to the Scene's base, like so:
// scene.Root.AddChildren(object)
// To remove them, you can unparent them from either the parent (Node.RemoveChildren())
// or the child (Node.Unparent()). When a Node is unparented, it is removed from the scene
// tree; if you want to destroy the Node, then dropping any references to this Node
// at this point would be sufficient.
// For Cameras, we don't actually need to place them in a scene to view the Scene, since
// the presence of the Camera in the Scene node tree doesn't impact what it would see.
// We can see the tree "visually" by printing out the hierarchy:
fmt.Println(g.GameScene.Root.HierarchyAsString())
// You can also visualize the scene hierarchy using TetraTerm:
// https://github.com/SolarLune/tetraterm
return g
}
func (g *Game) Update() error { return nil }
func (g *Game) Draw(screen *ebiten.Image) {
// Here, we'll call Camera.Clear() to clear its internal backing texture. This
// should be called once per frame before drawing your Scene.
g.Camera.Clear()
// Now we'll render the Scene from the camera. The Camera's ColorTexture will then
// hold the result.
// Camera.RenderScene() renders all Nodes in a scene, starting with the
// scene's root. You can also use Camera.Render() to simply render a selection of
// individual Models, or Camera.RenderNodes() to render a subset of a scene tree.
g.Camera.RenderScene(g.GameScene)
// To see the result, we draw the Camera's ColorTexture to the screen.
// Before doing so, we'll clear the screen first. In this case, we'll do this
// with a color, though we can also go with screen.Clear().
screen.Fill(color.RGBA{20, 30, 40, 255})
// Draw the resulting texture to the screen, and you're done! You can
// also visualize the depth texture with g.Camera.DepthTexture().
screen.DrawImage(g.Camera.ColorTexture(), nil)
// Note that the resulting texture is indeed just an ordinary *ebiten.Image, so
// you can also use this as a texture for a Model's Material, as an example.
}
func (g *Game) Layout(w, h int) (int, int) {
// Here, by simply returning the camera's size, we are essentially
// scaling the camera's output to the window size and letterboxing as necessary.
// If you wanted to extend the camera according to window size, you would
// have to resize the camera using the window's new width and height.
return g.Camera.Size()
}
func main() {
game := NewGame()
if err := ebiten.RunGame(game); err != nil {
panic(err)
}
}
```
You can also do collision testing between BoundingObjects, a category of nodes designed for this purpose. As a simplified example:
```go
type Game struct {
Cube *tetra3d.BoundingAABB
Capsule *tetra3d.BoundingCapsule
}
func NewGame() *Game {
g := &Game{}
// Create a new BoundingCapsule named "player", 1 unit tall with a
// 0.25 unit radius for the caps at the ends.
g.Capsule = tetra3d.NewBoundingCapsule("player", 1, 0.25)
// Create a new BoundingAABB named "block", of 0.5 width, height,
// and depth (in that order).
g.Cube = tetra3d.NewBoundingAABB("block", 0.5, 0.5, 0.5)
// Move the cube over on the X axis by 4 units.
g.Cube.Move(-4, 0, 0)
return g
}
func (g *Game) Update() {
// Move the capsule 0.2 units to the right every frame.
g.Capsule.Move(0.2, 0, 0)
// Will print the result of the Collision, (or nil), if there was no intersection.
fmt.Println(g.Capsule.Collision(g.Cube))
}
```
If you wanted a deeper collision test with multiple objects, you can do so using `IBoundingObject.CollisionTest()`. Take a look at the [Wiki](https://github.com/SolarLune/tetra3d/wiki/Collision-Testing) and the `bounds` example for more info.
That's basically it. Note that Tetra3D is, indeed, a work-in-progress and so will require time to get to a good state. But I feel like it works pretty well as is. Feel free to examine all of the examples in the `examples` folder. Calling `go run .` from within their directories will run them - the mouse usually controls the view, and clicking locks and unlocks the view.
There's a quick start project repo available [here](https://github.com/SolarLune/tetra3d-quickstart), as well to help with getting started.
For more information, check out the [Wiki](https://github.com/SolarLune/Tetra3d/wiki) for tips and tricks.
## What's missing?
The following is a rough to-do list (tasks with checks have been implemented):
- [X] **3D rendering**
- [X] -- Perspective projection
- [X] -- Orthographic projection (it's kinda jank, but it works)
- [x] -- Automatic billboarding
- [x] -- Sprites (a way to draw 2D images with no perspective changes (if desired), but within 3D space) (not sure?)
- [X] -- Basic depth sorting (sorting vertices in a model according to distance, sorting models according to distance)
- [X] -- A depth buffer and [depth testing](https://learnopengl.com/Advanced-OpenGL/Depth-testing) - This is now implemented by means of a depth texture and [Kage shader](https://ebiten.org/documents/shader.html#Shading_language_Kage), though the downside is that it requires rendering and compositing the scene into textures _twice_. Also, it doesn't work on triangles from the same object (as we can't render to the depth texture while reading it for existing depth).
- [X] -- A more advanced / accurate depth buffer
- [x] -- ~~Writing depth through some other means than vertex colors for precision~~ _This is fine for now, I think._
- [ ] -- Depth testing within the same object - I'm unsure if I will be able to implement this.
- [X] -- Offscreen Rendering
- [X] -- Mesh merging - Meshes can be merged together to lessen individual object draw calls.
- [x] -- Render batching - We can avoid calling Image.DrawTriangles between objects if they share properties (blend mode, material, etc) and it's not too many triangles to push before flushing to the GPU. Perhaps these Materials can have a flag that you can toggle to enable this behavior? (EDIT: This has been partially added by dynamic batching of Models.)
- [ ] -- Texture wrapping (will require rendering with shaders) - This is kind of implemented, but I don't believe it's been implemented for alpha clip materials.
- [ ] -- Draw triangle in 3D space through a function (could be useful for 3D lines, for example)
- [ ] -- Easy dynamic 3D Text (to make this simple, it might be best to allow the user to render the text as he wishes, and then make a function to map it (or any other *Image) to a plane of variable size).
- [ ] -- Lighting Probes - general idea is to be able to specify a space that has basic (optionally continuously updated) AO and lighting information, so standing a character in this spot makes him greener, that spot redder, that spot darker because he's in the shadows, etc.
- [X] **Culling**
- [X] -- Backface culling
- [X] -- Frustum culling
- [X] -- Far triangle culling
- [ ] -- Triangle clipping to view (this isn't implemented, but not having it doesn't seem to be too much of a problem for now)
- [ ] -- Sectors - The general idea is that the camera can be set up to only render sectors that it's in / neighboring (up to a customizeable depth)
- [X] **Debug**
- [X] -- Debug text: overall render time, FPS, render call count, vertex count, triangle count, skipped triangle count
- [X] -- Wireframe debug rendering
- [X] -- Normal debug rendering
- [X] **Materials**
- [X] -- Basic Texturing
- [X] -- Multitexturing / Per-triangle Materials
- [ ] -- Perspective-corrected texturing (currently it's affine, see [Wikipedia](https://en.wikipedia.org/wiki/Texture_mapping#Affine_texture_mapping))
- [ ] -- Screen-coordinate instead of UV texturing (useful for repeating patterns)
- [X] **Animations**
- [X] -- Armature-based animations
- [X] -- Object transform-based animations
- [ ] -- Blending between animations (this works, but can be a bit buggy and needs to be looked at)
- [X] -- Linear keyframe interpolation
- [X] -- Constant keyframe interpolation
- [ ] -- Bezier keyframe interpolation
- [ ] -- Morph (mesh-based) animations
- [X] **Scenes**
- [X] -- Fog
- [X] -- A node or scenegraph for parenting and simple visibility culling
- [X] -- Ambient vertex coloring
- [ ] -- Multiple vertex color channels
- [X] **GLTF / GLB model loading**
- [X] -- Vertex colors loading
- [X] -- UV map loading
- [X] -- Normal loading
- [X] -- Transform / full scene loading
- [X] -- Animation loading
- [X] -- Camera loading
- [X] -- Loading world color in as ambient lighting
- [ ] -- Separate .bin loading
- [x] -- Support for multiple scenes in a single Blend file (was broken due to GLTF exporter changes; working again in Blender 3.3)
- [X] **Blender Add-on**
- [ ] -- Custom mesh attribute to assign values to vertices, allowing you to, say, "mark" vertices
- [X] -- Export GLTF on save / on command via button
- [X] -- Bounds node creation
- [X] -- Game property export (less clunky version of Blender's vanilla custom properties)
- [X] -- Collection / group substitution
- [X] -- -- Overwriting properties through collection instance objects (it would be nice to do this cleanly with a nice UI, but just hamfisting it is fine for now)
- [X] -- -- Collection instances instantiate their objects in the same location in the tree
- [x] -- Optional camera size export
- [X] -- Linking collections from external files
- [X] -- Material data export
- [X] -- Option to pack textures or leave them as a path
- [X] -- Path / 3D Curve support
- [X] -- Grid support (for pathfinding / linking 3D points together)
- [ ] -- Toggleable option for drawing game property status to screen for each object using the gpu and blf modules
- [ ] -- Game properties should be an ordered slice, rather than a map of property name to property values.
- [X] **DAE model loading**
- [X] -- Vertex colors loading
- [X] -- UV map loading
- [X] -- Normal loading
- [X] -- Transform / full scene loading
- [X] **Lighting**
- [X] -- Smooth shading
- [X] -- Ambient lights
- [X] -- Point lights
- [X] -- Directional lights
- [X] -- Cube (AABB volume) lights
- [X] -- Lighting Groups
- [X] -- Ability to bake lighting to vertex colors
- [X] -- Ability to bake ambient occlusion to vertex colors
- [ ] -- Specular lighting (shininess)
- [ ] -- Take into account view normal (seems most useful for seeing a dark side if looking at a non-backface-culled triangle that is lit) - This is now done for point lights, but not sun lights
- [ ] -- Per-fragment lighting (by pushing it to the GPU, it would be more efficient and look better, of course)
- [X] **Particles**
- [X] -- Basic particle system support
- [ ] -- Fix layering issue when rendering a particle system underneath another one (visible in the Particles example)
- [X] **Shaders**
- [X] -- Custom fragment shaders
- [X] -- Normal rendering (useful for, say, screen-space shaders)
- [X] **Collision Testing**
- [X] -- Normal reporting
- [X] -- Slope reporting
- [X] -- Contact point reporting
- [X] -- Varying collision shapes
- [X] -- Checking multiple collisions at the same time
- [X] -- Composing collision shapes out of multiple sub-shapes (this can be done by simply creating them, parenting them to some node, and then testing against that node)
- [X] -- Bounding / Broadphase collision checking
| Collision Type | Sphere | AABB | Triangle | Capsule |
| -------------- | ------ | ---------- | ---------- | ------- |
| Sphere | ✅ | ✅ | ✅ | ✅ |
| AABB | ✅ | ✅ | ⛔ (buggy) | ✅ |
| Triangle | ✅ | ⛔ (buggy) | ⛔ (buggy) | ✅ |
| Capsule | ✅ | ✅ | ✅ | ⛔ (buggy) |
| Ray | ✅ | ✅ | ✅ | ✅ |
- [ ] -- An actual collision system?
- [ ] **3D Sound** (adjusting panning of sound sources based on 3D location?)
- [ ] **Optimization**
- [ ] -- It might be possible to not have to write depth manually
- [X] -- Make NodeFilters work lazily, rather than gathering all nodes in the filter at once
- [X] -- Reusing vertex indices for adjacent triangles
- [ ] -- Multithreading (particularly for vertex transformations)
- [X] -- Armature animation improvements?
- [X] -- Custom Vectors
- [ ] -- -- Vector pools again?
- [ ] -- Matrix pools?
- [ ] -- Raytest optimization
- [ ] -- -- Sphere?
- [ ] -- -- AABB?
- [ ] -- -- Capsule?
- [ ] -- -- Triangles
- [ ] -- -- -- Maybe we can combine contiguous triangles into faces and just check faces?
- [ ] -- -- -- We could use the broadphase system to find triangles that are in the raycast's general area, specifically
- [ ] -- Instead of doing collision testing using triangles directly, we can test against planes / faces if possible to reduce checks?
- [ ] -- Lighting speed improvements
- [ ] -- Occlusion culling - this should be possible using octrees to determine if an object is visible before rendering it; see: https://www.gamedeveloper.com/programming/occlusion-culling-algorithms
- [ ] -- [Prefer Discrete GPU](https://github.com/silbinarywolf/preferdiscretegpu) for computers with both discrete and integrated graphics cards
Again, it's incomplete and jank. However, it's also pretty cool!
## Shout-out time~
Huge shout-out to the open-source community:
- StackOverflow, in general, _FOR REAL_ - this would've been impossible otherwise
- [quartercastle's vector package](https://github.com/quartercastle/vector)
- [fauxgl](https://github.com/fogleman/fauxgl)
- [tinyrenderer](https://github.com/ssloy/tinyrenderer)
- [learnopengl.com](https://learnopengl.com/Getting-started/Coordinate-Systems)
- [3DCollisions](https://gdbooks.gitbooks.io/3dcollisions/content/)
- [Simon Rodriguez's "Writing a Small Software Renderer" article](http://blog.simonrodriguez.fr/articles/2017/02/writing_a_small_software_renderer.html)
- And many other articles that I've forgotten to note down
... For sharing the information and code to make this possible; I would definitely have never been able to create this otherwise.

View File

@ -817,6 +817,8 @@ def export():
if obj.type == "MESH":
# BUG: This causes a problem when subdividing; this is only really a problem if automatic tesselation when rendering in Tetra3D isn't on, though
if obj.t3dAutoSubdivide__:
origMesh = obj.data.copy()

View File

@ -1,116 +1,117 @@
package tetra3d
import (
"github.com/hajimehoshi/ebiten/v2"
)
// TextureAnimation is an animation struct. The TextureAnimation.Frames value is a []Vector, with each Vector representing
// a frame of the animation (and the offset from the original, base position for all animated vertices).
type TextureAnimation struct {
FPS float64 // The playback frame per second (or FPS) of the animation
Frames []Vector // A slice of vectors, with each indicating the offset of the frame from the original position for the mesh.
}
// NewTextureAnimationPixels creates a new TextureAnimation using pixel positions instead of UV values. fps is the
// frames per second for the animation. image is the source texture used, and framePositions are the positions in pixels
// for each frame (i.e. 32, 32 instead of 0.25, 0.25 on a 128x128 spritesheet). NewTextureAnimationInPixels will panic
// if given less than 2 values for framePositions, or if it's an odd number of values (i.e. an X value for a frame,
// but no matching Y Value).
func NewTextureAnimationPixels(fps float64, image *ebiten.Image, framePositions ...float64) *TextureAnimation {
if len(framePositions) < 2 || len(framePositions)%2 > 0 {
panic("Error: NewTextureAnimation must take at least 2 frame values and has to be in pairs of X and Y values.")
}
textureWidth := float64(image.Bounds().Dx())
textureHeight := float64(image.Bounds().Dy())
frames := []Vector{}
for i := 0; i < len(framePositions); i += 2 {
frames = append(frames, Vector{
framePositions[i] / textureWidth,
framePositions[i+1] / textureHeight,
0, 0,
})
}
return &TextureAnimation{
FPS: fps,
Frames: frames,
}
}
// TexturePlayer is a struct that allows you to animate a collection of vertices' UV values using a TextureAnimation.
type TexturePlayer struct {
OriginalOffsets map[int]Vector // OriginalOffsets is a map of vertex indices to their base UV offsets. All animating happens relative to these values.
Animation *TextureAnimation // Animation is a pointer to the currently playing Animation.
// Playhead increases as the TexturePlayer plays. The integer portion of Playhead is the frame that the TexturePlayer
// resides in (so a Playhead of 1.2 indicates that it is in frame 1, the second frame).
Playhead float64
Speed float64 // Speed indicates the playback speed and direction of the TexturePlayer, with a value of 1.0 being 100%.
Playing bool // Playing indicates whether the TexturePlayer is currently playing or not.
Mesh *Mesh
}
// NewTexturePlayer returns a new TexturePlayer instance.
func NewTexturePlayer(mesh *Mesh, vertexSelection *VertexSelection) *TexturePlayer {
player := &TexturePlayer{
Mesh: mesh,
Speed: 1,
}
player.Reset(vertexSelection)
return player
}
// Reset resets a TexturePlayer to be ready to run on a new selection of vertices. Note that this also resets the base UV offsets
// to use the current values of the passed vertices in the slice.
func (player *TexturePlayer) Reset(vertexSelection *VertexSelection) {
player.OriginalOffsets = map[int]Vector{}
for vert := range vertexSelection.Indices {
player.OriginalOffsets[vert] = player.Mesh.VertexUVs[vert]
}
}
// Play plays the passed TextureAnimation, resetting the playhead if the TexturePlayer is not playing an animation. If the player is not playing, it will begin playing.
func (player *TexturePlayer) Play(animation *TextureAnimation) {
if !player.Playing || player.Animation != animation {
player.Animation = animation
player.Playhead = 0
}
player.Playing = true
}
// Update updates the TexturePlayer, using the passed delta time variable to animate the TexturePlayer's vertices.
func (player *TexturePlayer) Update(dt float64) {
if player.Animation != nil && player.Playing && len(player.Animation.Frames) > 0 {
player.Playhead += dt * player.Animation.FPS * player.Speed
playhead := int(player.Playhead)
for playhead >= len(player.Animation.Frames) {
playhead -= len(player.Animation.Frames)
}
for playhead < 0 {
playhead += len(player.Animation.Frames)
}
frameOffset := player.Animation.Frames[playhead]
player.ApplyUVOffset(frameOffset.X, frameOffset.Y)
}
}
// ApplyUVOffset applies a specified UV offset to all vertices a player is assigned to. This offset is not additive, but rather is
// set once, regardless of how many times ApplyUVOffset is called.
func (player *TexturePlayer) ApplyUVOffset(offsetX, offsetY float64) {
for vertIndex, ogOffset := range player.OriginalOffsets {
player.Mesh.VertexUVs[vertIndex].X = ogOffset.X + offsetX
player.Mesh.VertexUVs[vertIndex].Y = ogOffset.Y + offsetY
}
}
package tetra3d
import (
"github.com/hajimehoshi/ebiten/v2"
)
// TextureAnimation is an animation struct. The TextureAnimation.Frames value is a []Vector, with each Vector representing
// a frame of the animation (and the offset from the original, base position for all animated vertices).
type TextureAnimation struct {
FPS float64 // The playback frame per second (or FPS) of the animation
Frames []Vector // A slice of vectors, with each indicating the offset of the frame from the original position for the mesh.
}
// NewTextureAnimationPixels creates a new TextureAnimation using pixel positions instead of UV values. fps is the
// frames per second for the animation. image is the source texture used, and framePositions are the positions in pixels
// for each frame (i.e. 32, 32 instead of 0.25, 0.25 on a 128x128 spritesheet). NewTextureAnimationInPixels will panic
// if given less than 2 values for framePositions, or if it's an odd number of values (i.e. an X value for a frame,
// but no matching Y Value).
func NewTextureAnimationPixels(fps float64, image *ebiten.Image, framePositions ...float64) *TextureAnimation {
if len(framePositions) < 2 || len(framePositions)%2 > 0 {
panic("Error: NewTextureAnimation must take at least 2 frame values and has to be in pairs of X and Y values.")
}
textureWidth := float64(image.Bounds().Dx())
textureHeight := float64(image.Bounds().Dy())
frames := []Vector{}
for i := 0; i < len(framePositions); i += 2 {
frames = append(frames, Vector{
framePositions[i] / textureWidth,
framePositions[i+1] / textureHeight,
0, 0,
})
}
return &TextureAnimation{
FPS: fps,
Frames: frames,
}
}
// TexturePlayer is a struct that allows you to animate a collection of vertices' UV values using a TextureAnimation.
type TexturePlayer struct {
OriginalOffsets map[int]Vector // OriginalOffsets is a map of vertex indices to their base UV offsets. All animating happens relative to these values.
Animation *TextureAnimation // Animation is a pointer to the currently playing Animation.
// Playhead increases as the TexturePlayer plays. The integer portion of Playhead is the frame that the TexturePlayer
// resides in (so a Playhead of 1.2 indicates that it is in frame 1, the second frame).
Playhead float64
Speed float64 // Speed indicates the playback speed and direction of the TexturePlayer, with a value of 1.0 being 100%.
Playing bool // Playing indicates whether the TexturePlayer is currently playing or not.
vertexSelection *VertexSelection
}
// NewTexturePlayer returns a new TexturePlayer instance.
func NewTexturePlayer(vertexSelection *VertexSelection) *TexturePlayer {
player := &TexturePlayer{
Speed: 1,
vertexSelection: vertexSelection,
}
player.Reset(vertexSelection)
return player
}
// Reset resets a TexturePlayer to be ready to run on a new selection of vertices. Note that this also resets the base UV offsets
// to use the current values of the passed vertices in the slice.
func (player *TexturePlayer) Reset(vertexSelection *VertexSelection) {
player.OriginalOffsets = map[int]Vector{}
mesh := vertexSelection.Mesh
for vert := range vertexSelection.Indices {
player.OriginalOffsets[vert] = mesh.VertexUVs[vert]
}
}
// Play plays the passed TextureAnimation, resetting the playhead if the TexturePlayer is not playing an animation. If the player is not playing, it will begin playing.
func (player *TexturePlayer) Play(animation *TextureAnimation) {
if !player.Playing || player.Animation != animation {
player.Animation = animation
player.Playhead = 0
}
player.Playing = true
}
// Update updates the TexturePlayer, using the passed delta time variable to animate the TexturePlayer's vertices.
func (player *TexturePlayer) Update(dt float64) {
if player.Animation != nil && player.Playing && len(player.Animation.Frames) > 0 {
player.Playhead += dt * player.Animation.FPS * player.Speed
playhead := int(player.Playhead)
for playhead >= len(player.Animation.Frames) {
playhead -= len(player.Animation.Frames)
}
for playhead < 0 {
playhead += len(player.Animation.Frames)
}
frameOffset := player.Animation.Frames[playhead]
player.ApplyUVOffset(frameOffset.X, frameOffset.Y)
}
}
// ApplyUVOffset applies a specified UV offset to all vertices a player is assigned to. This offset is not additive, but rather is
// set once, regardless of how many times ApplyUVOffset is called.
func (player *TexturePlayer) ApplyUVOffset(offsetX, offsetY float64) {
mesh := player.vertexSelection.Mesh
for vertIndex, ogOffset := range player.OriginalOffsets {
mesh.VertexUVs[vertIndex].X = ogOffset.X + offsetX
mesh.VertexUVs[vertIndex].Y = ogOffset.Y + offsetY
}
}