Files
zzg 4dddb62958
ci / test (push) Canceled after 0s
initial commit
2026-09-25 21:31:54 -07:00

386 lines
12 KiB
Go

package scene
import (
"math"
"math/rand"
"testing"
"github.com/zg/tracer/vec"
)
const eps = 1e-6
var mat = Lambertian{Albedo: vec.New(0.5, 0.5, 0.5)}
func TestRayAt(t *testing.T) {
r := Ray{Origin: vec.New(1, 2, 3), Dir: vec.New(1, 0, 0)}
if got, want := r.At(2), vec.New(3, 2, 3); got != want {
t.Errorf("At(2) = %v, want %v", got, want)
}
}
func TestSphereHit(t *testing.T) {
s := Sphere{Center: vec.New(0, 0, -5), Radius: 1, Material: mat}
tests := []struct {
name string
ray Ray
wantOk bool
wantT float64
}{
{"head on", Ray{vec.Vec3{}, vec.New(0, 0, -1)}, true, 4},
{"misses to the side", Ray{vec.Vec3{}, vec.New(0, 1, 0)}, false, 0},
{"tangent misses", Ray{vec.New(0, 1.5, 0), vec.New(0, 0, -1)}, false, 0},
{"pointing away", Ray{vec.Vec3{}, vec.New(0, 0, 1)}, false, 0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
h, ok := s.Hit(tt.ray, 1e-3, math.Inf(1))
if ok != tt.wantOk {
t.Fatalf("ok = %v, want %v", ok, tt.wantOk)
}
if ok && math.Abs(h.T-tt.wantT) > eps {
t.Errorf("t = %v, want %v", h.T, tt.wantT)
}
})
}
}
// The recorded normal must always oppose the incoming ray, and FrontFace
// must say which side was struck. Dielectrics depend on this to know
// whether they are entering or leaving.
func TestSphereNormalOrientation(t *testing.T) {
s := Sphere{Center: vec.New(0, 0, -5), Radius: 1, Material: mat}
outside := Ray{vec.Vec3{}, vec.New(0, 0, -1)}
h, ok := s.Hit(outside, 1e-3, math.Inf(1))
if !ok {
t.Fatal("expected a hit from outside")
}
if !h.FrontFace {
t.Error("ray from outside should record FrontFace")
}
if h.Normal.Dot(outside.Dir) >= 0 {
t.Error("normal must oppose the incoming ray")
}
inside := Ray{vec.New(0, 0, -5), vec.New(0, 0, -1)}
h, ok = s.Hit(inside, 1e-3, math.Inf(1))
if !ok {
t.Fatal("expected a hit from inside")
}
if h.FrontFace {
t.Error("ray from inside should not record FrontFace")
}
if h.Normal.Dot(inside.Dir) >= 0 {
t.Error("normal must oppose the incoming ray even from inside")
}
}
// A hit must lie on the surface: its distance from the centre is the radius.
func TestSphereHitPointOnSurface(t *testing.T) {
rng := rand.New(rand.NewSource(1))
s := Sphere{Center: vec.New(1, 2, 3), Radius: 2.5, Material: mat}
hits := 0
for range 2000 {
r := Ray{
Origin: vec.New(rng.NormFloat64()*5, rng.NormFloat64()*5, rng.NormFloat64()*5),
Dir: vec.New(rng.NormFloat64(), rng.NormFloat64(), rng.NormFloat64()),
}
h, ok := s.Hit(r, 1e-3, math.Inf(1))
if !ok {
continue
}
hits++
if d := h.P.Sub(s.Center).Len(); math.Abs(d-s.Radius) > 1e-6 {
t.Fatalf("hit point is %v from centre, want %v", d, s.Radius)
}
if math.Abs(h.Normal.Len()-1) > 1e-9 {
t.Fatalf("normal is not unit length: %v", h.Normal.Len())
}
}
if hits == 0 {
t.Fatal("no hits generated; test proved nothing")
}
}
func TestSphereRespectsTRange(t *testing.T) {
s := Sphere{Center: vec.New(0, 0, -5), Radius: 1, Material: mat}
r := Ray{vec.Vec3{}, vec.New(0, 0, -1)}
if _, ok := s.Hit(r, 1e-3, 3); ok {
t.Error("hit at t=4 should be rejected when tMax is 3")
}
// With tMin past the near root the far root is still a legitimate hit:
// the ray leaves through the back of the sphere at t=6.
h, ok := s.Hit(r, 5, math.Inf(1))
if !ok {
t.Fatal("far root at t=6 should still be found when tMin is 5")
}
if math.Abs(h.T-6) > eps {
t.Errorf("t = %v, want 6 (the far root)", h.T)
}
if h.FrontFace {
t.Error("hitting the far wall from inside should not be a front face")
}
if _, ok := s.Hit(r, 7, math.Inf(1)); ok {
t.Error("both roots are before tMin=7, so there should be no hit")
}
}
func TestPlaneHit(t *testing.T) {
p := Plane{Point: vec.New(0, -1, 0), Normal: vec.New(0, 1, 0), Material: mat}
h, ok := p.Hit(Ray{vec.Vec3{}, vec.New(0, -1, 0)}, 1e-3, math.Inf(1))
if !ok {
t.Fatal("expected a hit")
}
if math.Abs(h.T-1) > eps {
t.Errorf("t = %v, want 1", h.T)
}
// Parallel rays never meet the plane.
if _, ok := p.Hit(Ray{vec.Vec3{}, vec.New(1, 0, 0)}, 1e-3, math.Inf(1)); ok {
t.Error("parallel ray should not hit")
}
}
func TestTriangleHit(t *testing.T) {
tri := Triangle{
A: vec.New(0, 0, -2), B: vec.New(1, 0, -2), C: vec.New(0, 1, -2),
Material: mat,
}
// Through the interior.
if h, ok := tri.Hit(Ray{vec.New(0.25, 0.25, 0), vec.New(0, 0, -1)}, 1e-3, math.Inf(1)); !ok {
t.Error("expected a hit inside the triangle")
} else if math.Abs(h.T-2) > eps {
t.Errorf("t = %v, want 2", h.T)
}
// Outside the triangle but on its plane.
if _, ok := tri.Hit(Ray{vec.New(0.9, 0.9, 0), vec.New(0, 0, -1)}, 1e-3, math.Inf(1)); ok {
t.Error("point outside the triangle should not hit")
}
// Parallel to the plane.
if _, ok := tri.Hit(Ray{vec.New(0.25, 0.25, 0), vec.New(1, 0, 0)}, 1e-3, math.Inf(1)); ok {
t.Error("parallel ray should not hit")
}
}
func TestListReturnsNearest(t *testing.T) {
near := Sphere{Center: vec.New(0, 0, -2), Radius: 0.5, Material: mat}
far := Sphere{Center: vec.New(0, 0, -10), Radius: 0.5, Material: mat}
l := List{far, near} // deliberately out of order
h, ok := l.Hit(Ray{vec.Vec3{}, vec.New(0, 0, -1)}, 1e-3, math.Inf(1))
if !ok {
t.Fatal("expected a hit")
}
if math.Abs(h.T-1.5) > eps {
t.Errorf("t = %v, want 1.5 (the nearer sphere)", h.T)
}
}
func TestEmptyListMisses(t *testing.T) {
if _, ok := (List{}).Hit(Ray{vec.Vec3{}, vec.New(0, 0, -1)}, 1e-3, math.Inf(1)); ok {
t.Error("empty list cannot be hit")
}
}
func TestAABBHit(t *testing.T) {
box := AABB{Min: vec.New(-1, -1, -1), Max: vec.New(1, 1, 1)}
if !box.Hit(Ray{vec.New(0, 0, -5), vec.New(0, 0, 1)}, 1e-3, math.Inf(1)) {
t.Error("ray through the box should hit")
}
if box.Hit(Ray{vec.New(5, 5, -5), vec.New(0, 0, 1)}, 1e-3, math.Inf(1)) {
t.Error("ray missing the box should not hit")
}
}
// A ray exactly parallel to a slab, lying on its boundary, produces NaN in
// the naive slab test. It must not crash or silently report a miss.
func TestAABBParallelRayOnBoundary(t *testing.T) {
box := AABB{Min: vec.New(-1, -1, -1), Max: vec.New(1, 1, 1)}
// Origin y is exactly on the box's max plane, direction has zero y.
r := Ray{Origin: vec.New(0, 1, -5), Dir: vec.New(0, 0, 1)}
_ = box.Hit(r, 1e-3, math.Inf(1)) // must simply not panic or hang
}
// The BVH is an acceleration structure: it must return exactly what the
// brute-force list returns, for every ray.
func TestBVHMatchesBruteForce(t *testing.T) {
rng := rand.New(rand.NewSource(7))
var surfaces []Surface
for range 200 {
surfaces = append(surfaces, Sphere{
Center: vec.New(rng.Float64()*20-10, rng.Float64()*20-10, rng.Float64()*20-10),
Radius: rng.Float64()*0.8 + 0.2,
Material: mat,
})
}
brute := make(List, len(surfaces))
copy(brute, surfaces)
bvh := NewBVH(surfaces) // reorders its argument, hence the copy above
tested := 0
for range 5000 {
r := Ray{
Origin: vec.New(rng.NormFloat64()*8, rng.NormFloat64()*8, rng.NormFloat64()*8),
Dir: vec.New(rng.NormFloat64(), rng.NormFloat64(), rng.NormFloat64()),
}
wantHit, wantOk := brute.Hit(r, 1e-3, math.Inf(1))
gotHit, gotOk := bvh.Hit(r, 1e-3, math.Inf(1))
if wantOk != gotOk {
t.Fatalf("BVH ok = %v, brute force ok = %v", gotOk, wantOk)
}
if wantOk {
tested++
if math.Abs(gotHit.T-wantHit.T) > 1e-9 {
t.Fatalf("BVH t = %v, brute force t = %v", gotHit.T, wantHit.T)
}
}
}
if tested < 100 {
t.Fatalf("only %d hits exercised; test proved little", tested)
}
}
func TestBVHDegenerateSizes(t *testing.T) {
for _, n := range []int{0, 1, 2, 3} {
var surfaces []Surface
for i := range n {
surfaces = append(surfaces, Sphere{Center: vec.New(float64(i), 0, -5), Radius: 0.4, Material: mat})
}
s := NewBVH(surfaces)
if s == nil {
t.Fatalf("n=%d produced a nil surface", n)
}
_, _ = s.Hit(Ray{vec.Vec3{}, vec.New(0, 0, -1)}, 1e-3, math.Inf(1))
}
}
func TestLambertianScatterStaysAboveSurface(t *testing.T) {
rng := rand.New(rand.NewSource(3))
m := Lambertian{Albedo: vec.New(0.5, 0.5, 0.5)}
h := Hit{P: vec.Vec3{}, Normal: vec.New(0, 1, 0), FrontFace: true}
for range 5000 {
out, att, ok := m.Scatter(Ray{}, h, rng)
if !ok {
t.Fatal("lambertian must always scatter")
}
if math.IsNaN(out.Dir.X) || math.IsNaN(out.Dir.Y) || math.IsNaN(out.Dir.Z) {
t.Fatal("scatter produced a NaN direction")
}
if att != m.Albedo {
t.Fatalf("attenuation = %v, want %v", att, m.Albedo)
}
}
}
// Fuzz can push a metal's reflection below the surface; those rays must be
// absorbed rather than escaping through the object.
func TestMetalAbsorbsRaysBelowSurface(t *testing.T) {
rng := rand.New(rand.NewSource(4))
m := Metal{Albedo: vec.New(0.8, 0.8, 0.8), Fuzz: 1}
h := Hit{P: vec.Vec3{}, Normal: vec.New(0, 1, 0), FrontFace: true}
// Grazing incidence: the reflection comes off nearly tangent to the
// surface, which is the only regime where fuzz can push it below.
// A head-on ray reflects nearly straight up and fuzz of 1 can never
// flip it, so that case would prove nothing.
in := Ray{Origin: vec.New(-1, 0.01, 0), Dir: vec.New(1, -0.01, 0)}
absorbed := 0
for range 5000 {
out, _, ok := m.Scatter(in, h, rng)
if !ok {
absorbed++
continue
}
if out.Dir.Dot(h.Normal) <= 0 {
t.Fatal("scattered ray points into the surface")
}
}
if absorbed == 0 {
t.Error("with fuzz 1 at a grazing angle, some rays should be absorbed")
}
}
func TestMetalFuzzIsClamped(t *testing.T) {
rng := rand.New(rand.NewSource(5))
// Fuzz far outside [0,1] must not produce wild directions.
m := Metal{Albedo: vec.New(1, 1, 1), Fuzz: 100}
h := Hit{P: vec.Vec3{}, Normal: vec.New(0, 1, 0), FrontFace: true}
in := Ray{Origin: vec.New(0, 1, 0), Dir: vec.New(0, -1, 0)}
for range 1000 {
if out, _, ok := m.Scatter(in, h, rng); ok {
if out.Dir.Len() > 3 {
t.Fatalf("fuzz was not clamped: direction length %v", out.Dir.Len())
}
}
}
}
// Beyond the critical angle a dielectric must reflect rather than refract.
func TestDielectricTotalInternalReflection(t *testing.T) {
rng := rand.New(rand.NewSource(6))
m := Dielectric{RefIdx: 1.5}
// Inside the glass (FrontFace false) at a grazing angle.
h := Hit{P: vec.Vec3{}, Normal: vec.New(0, 1, 0), FrontFace: false}
in := Ray{Dir: vec.New(1, -0.05, 0).Unit()}
out, att, ok := m.Scatter(in, h, rng)
if !ok {
t.Fatal("dielectric must always scatter")
}
if att != vec.New(1, 1, 1) {
t.Errorf("glass should not attenuate: %v", att)
}
// Reflected, so it must stay on the incoming side of the surface.
if out.Dir.Dot(h.Normal) <= 0 {
t.Error("total internal reflection should send the ray back")
}
}
func TestDiffuseLightEmitsAndDoesNotScatter(t *testing.T) {
m := DiffuseLight{Colour: vec.New(4, 4, 4)}
if _, _, ok := m.Scatter(Ray{}, Hit{}, rand.New(rand.NewSource(1))); ok {
t.Error("a light should not scatter")
}
if m.Emitted() != vec.New(4, 4, 4) {
t.Errorf("Emitted = %v", m.Emitted())
}
}
func TestCameraRayHitsCentre(t *testing.T) {
cam := NewCamera(CameraOptions{
LookFrom: vec.New(0, 0, 0), LookAt: vec.New(0, 0, -1),
VFOVDegrees: 90, AspectRatio: 1,
})
rng := rand.New(rand.NewSource(1))
// The centre of the film must look straight down -Z.
r := cam.Ray(0.5, 0.5, rng)
d := r.Dir.Unit()
if math.Abs(d.X) > 1e-9 || math.Abs(d.Y) > 1e-9 || math.Abs(d.Z+1) > 1e-9 {
t.Errorf("centre ray direction = %v, want (0,0,-1)", d)
}
}
func TestCameraDefaults(t *testing.T) {
// A zero-value options struct must still produce a usable camera
// rather than NaNs from a zero focus distance or aspect ratio.
cam := NewCamera(CameraOptions{})
r := cam.Ray(0.5, 0.5, rand.New(rand.NewSource(1)))
if math.IsNaN(r.Dir.X) || math.IsNaN(r.Dir.Y) || math.IsNaN(r.Dir.Z) {
t.Fatalf("default camera produced NaN direction: %v", r.Dir)
}
}
func TestCameraApertureJittersOrigin(t *testing.T) {
cam := NewCamera(CameraOptions{
LookFrom: vec.New(0, 0, 0), LookAt: vec.New(0, 0, -1),
Aperture: 2, FocusDistance: 1,
})
rng := rand.New(rand.NewSource(1))
seen := map[vec.Vec3]bool{}
for range 50 {
seen[cam.Ray(0.5, 0.5, rng).Origin] = true
}
if len(seen) < 2 {
t.Error("a non-zero aperture should jitter the ray origin across the lens")
}
}