diff --git a/Sources/ScintillaLib/Color.swift b/Sources/ScintillaLib/Color.swift index 04bab6a..441f053 100644 --- a/Sources/ScintillaLib/Color.swift +++ b/Sources/ScintillaLib/Color.swift @@ -33,6 +33,10 @@ public struct Color { Color(self.r*scalar, self.g*scalar, self.b*scalar) } + func divideScalar(_ scalar: Double) -> Self { + return self.multiplyScalar(1.0/scalar) + } + func hadamard(_ other: Self) -> Self { Color(self.r*other.r, self.g*other.g, self.b*other.b) } diff --git a/Sources/ScintillaLib/Examples.swift b/Sources/ScintillaLib/Examples.swift index 3fbf90a..7d9c112 100644 --- a/Sources/ScintillaLib/Examples.swift +++ b/Sources/ScintillaLib/Examples.swift @@ -7,154 +7,154 @@ import Foundation -func testScene() -> World { - return World { - Light(point(-10, 10, -10)) - Camera(800, 600, PI/3, .view( - point(0, 10, -15), - point(0, 0, 0), - vector(0, 1, 0))) - for n in 0...3 { - Cube(.solidColor(Color(1, 0, 0))) - .rotateY(PI/6) - .rotateX(PI/6) - .rotateZ(PI/6) - .translate(4*cos(Double(n)*PI/2), 0, 4*sin(Double(n)*PI/2)) - } - } -} - -func testGroup() -> World { - return World { - Light(point(-10, 10, -10)) - Camera(800, 600, PI/3, .view( - point(0, 5, -10), - point(0, 0, 0), - vector(0, 1, 0))) - Group { - Sphere(.solidColor(Color(1, 0, 0))) - for n in 0...2 { - Sphere(.solidColor(Color(0, 1, 0))) - .translate(2, 0, 0) - .rotateY(2*Double(n)*PI/3) - } - } - .translate(0, 1, 0) - Plane(.pattern(Checkered2D(.black, .white, .identity))) - } -} - -func testTorus() -> World { - return World { - Light(point(-10, 10, -10)) - Camera(800, 600, PI/3, .view( - point(0, 5, -10), - point(0, 0, 0), - vector(0, 1, 0))) - Torus(.solidColor(Color(1, 0.5, 0))) - .translate(0, 1, 0) - Plane(.pattern(Checkered2D(.black, .white, .identity))) - } -} - -func testDie() -> World { - let material = Material.solidColor(Color(1, 0.5, 0)) - .reflective(0.2) - - return World { - Light(point(-10, 10, -10)) - Camera(800, 600, PI/3, .view( - point(0, 5, -10), - point(0, 0, 0), - vector(0, 1, 0))) - Cube(material).intersection { - Sphere(material) - .scale(1.55, 1.55, 1.55) - Cylinder(material, -2, 2, true) - .scale(1.35, 1.35, 1.35) - Cylinder(material, -2, 2, true) - .scale(1.35, 1.35, 1.35) - .rotateX(PI/2) - Cylinder(material, -2, 2, true) - .scale(1.35, 1.35, 1.35) - .rotateZ(PI/2) - }.difference { - for (x, y, z) in [ - // face with six dimples - (-0.6, 1.0, 0.6), - (-0.6, 1.0, 0.0), - (-0.6, 1.0, -0.6), - (0.6, 1.0, 0.6), - (0.6, 1.0, 0.0), - (0.6, 1.0, -0.6), - // face with five dimples - (0.0, 0.0, -1.0), - (0.6, 0.6, -1.0), - (0.6, -0.6, -1.0), - (-0.6, 0.6, -1.0), - (-0.6, -0.6, -1.0), - // face with four dimples - (1.0, 0.6, 0.6), - (1.0, 0.6, -0.6), - (1.0, -0.6, 0.6), - (1.0, -0.6, -0.6), - // face with three dimples - (-1.0, 0.6, 0.6), - (-1.0, 0, 0), - (-1.0, -0.6, -0.6), - // face with two dimples - (0.6, 0.6, 1.0), - (-0.6, -0.6, 1.0), - // face with one dimple - (0.0, -1.0, 0.0), - ] { - Sphere(.solidColor(Color(1, 1, 1))) - .scale(0.2, 0.2, 0.2) - .translate(x, y, z) - } - } - .rotateY(PI/3) - .translate(0.0, 1.0, 0.0) - Plane(.pattern(Checkered2D(.black, .white, .identity))) - } -} - -func chapterSevenScene() -> World { - return World { - Light(point(-10, 10, -10)) - Camera(800, 600, PI/3, .view( - point(0, 2, -5), - point(0, 0, 0), - vector(0, 1, 0))) - Sphere(.solidColor(Color(1, 0.9, 0.9))) - .scale(10, 0.01, 10) - Sphere(.solidColor(Color(1, 0.9, 0.9))) - .scale(10, 0.01, 10) - .rotateX(PI/2) - .rotateY(-PI/4) - .translate(0, 0, 5) - Sphere(.solidColor(Color(1, 0.9, 0.9))) - .scale(10, 0.01, 10) - .rotateX(PI/2) - .rotateY(PI/4) - .translate(0, 0, 5) - Sphere(.solidColor(Color(1, 0.8, 0.1)) - .diffuse(0.7) - .specular(0.3)) - .scale(0.33, 0.33, 0.33) - .translate(-1.5, 0.33, -0.75) - Sphere(.solidColor(Color(0.1, 1.0, 0.5)) - .diffuse(0.7) - .specular(0.3)) - .translate(-0.5, 1.0, 0.5) - Sphere(.solidColor(Color(0.5, 1, 0.1)) - .diffuse(0.7) - .specular(0.3)) - .scale(0.5, 0.5, 0.5) - .translate(1.5, 0.5, -0.5) - } -} - +//func testScene() -> World { +// return World { +// Light(point(-10, 10, -10)) +// Camera(800, 600, PI/3, .view( +// point(0, 10, -15), +// point(0, 0, 0), +// vector(0, 1, 0))) +// for n in 0...3 { +// Cube(.solidColor(Color(1, 0, 0))) +// .rotateY(PI/6) +// .rotateX(PI/6) +// .rotateZ(PI/6) +// .translate(4*cos(Double(n)*PI/2), 0, 4*sin(Double(n)*PI/2)) +// } +// } +//} +// +//func testGroup() -> World { +// return World { +// Light(point(-10, 10, -10)) +// Camera(800, 600, PI/3, .view( +// point(0, 5, -10), +// point(0, 0, 0), +// vector(0, 1, 0))) +// Group { +// Sphere(.solidColor(Color(1, 0, 0))) +// for n in 0...2 { +// Sphere(.solidColor(Color(0, 1, 0))) +// .translate(2, 0, 0) +// .rotateY(2*Double(n)*PI/3) +// } +// } +// .translate(0, 1, 0) +// Plane(.pattern(Checkered2D(.black, .white, .identity))) +// } +//} +// +//func testTorus() -> World { +// return World { +// Light(point(-10, 10, -10)) +// Camera(800, 600, PI/3, .view( +// point(0, 5, -10), +// point(0, 0, 0), +// vector(0, 1, 0))) +// Torus(.solidColor(Color(1, 0.5, 0))) +// .translate(0, 1, 0) +// Plane(.pattern(Checkered2D(.black, .white, .identity))) +// } +//} +// +//func testDie() -> World { +// let material = Material.solidColor(Color(1, 0.5, 0)) +// .reflective(0.2) +// +// return World { +// Light(point(-10, 10, -10)) +// Camera(800, 600, PI/3, .view( +// point(0, 5, -10), +// point(0, 0, 0), +// vector(0, 1, 0))) +// Cube(material).intersection { +// Sphere(material) +// .scale(1.55, 1.55, 1.55) +// Cylinder(material, -2, 2, true) +// .scale(1.35, 1.35, 1.35) +// Cylinder(material, -2, 2, true) +// .scale(1.35, 1.35, 1.35) +// .rotateX(PI/2) +// Cylinder(material, -2, 2, true) +// .scale(1.35, 1.35, 1.35) +// .rotateZ(PI/2) +// }.difference { +// for (x, y, z) in [ +// // face with six dimples +// (-0.6, 1.0, 0.6), +// (-0.6, 1.0, 0.0), +// (-0.6, 1.0, -0.6), +// (0.6, 1.0, 0.6), +// (0.6, 1.0, 0.0), +// (0.6, 1.0, -0.6), +// // face with five dimples +// (0.0, 0.0, -1.0), +// (0.6, 0.6, -1.0), +// (0.6, -0.6, -1.0), +// (-0.6, 0.6, -1.0), +// (-0.6, -0.6, -1.0), +// // face with four dimples +// (1.0, 0.6, 0.6), +// (1.0, 0.6, -0.6), +// (1.0, -0.6, 0.6), +// (1.0, -0.6, -0.6), +// // face with three dimples +// (-1.0, 0.6, 0.6), +// (-1.0, 0, 0), +// (-1.0, -0.6, -0.6), +// // face with two dimples +// (0.6, 0.6, 1.0), +// (-0.6, -0.6, 1.0), +// // face with one dimple +// (0.0, -1.0, 0.0), +// ] { +// Sphere(.solidColor(Color(1, 1, 1))) +// .scale(0.2, 0.2, 0.2) +// .translate(x, y, z) +// } +// } +// .rotateY(PI/3) +// .translate(0.0, 1.0, 0.0) +// Plane(.pattern(Checkered2D(.black, .white, .identity))) +// } +//} +// +//func chapterSevenScene() -> World { +// return World { +// Light(point(-10, 10, -10)) +// Camera(800, 600, PI/3, .view( +// point(0, 2, -5), +// point(0, 0, 0), +// vector(0, 1, 0))) +// Sphere(.solidColor(Color(1, 0.9, 0.9))) +// .scale(10, 0.01, 10) +// Sphere(.solidColor(Color(1, 0.9, 0.9))) +// .scale(10, 0.01, 10) +// .rotateX(PI/2) +// .rotateY(-PI/4) +// .translate(0, 0, 5) +// Sphere(.solidColor(Color(1, 0.9, 0.9))) +// .scale(10, 0.01, 10) +// .rotateX(PI/2) +// .rotateY(PI/4) +// .translate(0, 0, 5) +// Sphere(.solidColor(Color(1, 0.8, 0.1)) +// .diffuse(0.7) +// .specular(0.3)) +// .scale(0.33, 0.33, 0.33) +// .translate(-1.5, 0.33, -0.75) +// Sphere(.solidColor(Color(0.1, 1.0, 0.5)) +// .diffuse(0.7) +// .specular(0.3)) +// .translate(-0.5, 1.0, 0.5) +// Sphere(.solidColor(Color(0.5, 1, 0.1)) +// .diffuse(0.7) +// .specular(0.3)) +// .scale(0.5, 0.5, 0.5) +// .translate(1.5, 0.5, -0.5) +// } +//} +// //func chapterNineScene() -> World { // let floorMaterial = Material(.solidColor(Color(1, 0.9, 0.9)), 0.1, 0.9, 0.0, 200, 0.0, 0.0, 0.0) // let floor = Plane(.identity, floorMaterial) diff --git a/Sources/ScintillaLib/Intersection.swift b/Sources/ScintillaLib/Intersection.swift index e547355..0a50801 100644 --- a/Sources/ScintillaLib/Intersection.swift +++ b/Sources/ScintillaLib/Intersection.swift @@ -81,12 +81,13 @@ public struct Intersection { } } -func hit(_ intersections: inout [Intersection]) -> Optional { - intersections - .sort(by: { i1, i2 in +func hit(_ intersections: [Intersection], includeOnlyShadowingObjects: Bool = false) -> Optional { + return intersections + .sorted(by: { i1, i2 in i1.t < i2.t }) - return intersections - .filter({intersection in intersection.t > 0}) + .filter { intersection in + intersection.t > 0 && (includeOnlyShadowingObjects ? intersection.shape.castsShadow : true) + } .first } diff --git a/Sources/ScintillaLib/Jitter.swift b/Sources/ScintillaLib/Jitter.swift new file mode 100644 index 0000000..23242f3 --- /dev/null +++ b/Sources/ScintillaLib/Jitter.swift @@ -0,0 +1,37 @@ +// +// Jitter.swift +// +// +// Created by Danielle Kefford on 10/11/22. +// + +public protocol Jitter { + mutating func next() -> Double +} + +public struct NoJitter: Jitter { + public mutating func next() -> Double { + return 0.5 + } +} + +public struct PseudorandomJitter: Jitter { + var index: Int = 0 + var values: [Double] + + public init(_ values: [Double]) { + self.values = values + } + + public mutating func next() -> Double { + let nextValue = self.values[index] + index = (index+1) % self.values.count + return nextValue + } +} + +public struct RandomJitter: Jitter { + public func next() -> Double { + return Double.random(in: 0.0...1.0) + } +} diff --git a/Sources/ScintillaLib/Light.swift b/Sources/ScintillaLib/Light.swift index 028686d..8c56129 100644 --- a/Sources/ScintillaLib/Light.swift +++ b/Sources/ScintillaLib/Light.swift @@ -7,17 +7,62 @@ import Foundation -public struct Light { - var position: Tuple4 - var intensity: Color +public protocol Light { + var position: Tuple4 { get } + var color: Color { get } +} + +public struct PointLight: Light { + public var position: Tuple4 + public var color: Color public init(_ position: Tuple4) { self.position = position - self.intensity = .white + self.color = .white } - public init(_ position: Tuple4, _ intensity: Color) { + public init(_ position: Tuple4, _ color: Color) { self.position = position - self.intensity = intensity + self.color = color + } +} + +public struct AreaLight: Light { + public var position: Tuple4 + public var color: Color + var corner: Tuple4 + var uVec: Tuple4 + var uSteps: Int + var vVec: Tuple4 + var vSteps: Int + var samples: Int + var jitter: Jitter + + public init(_ corner: Tuple4, _ fullUVec: Tuple4, _ uSteps: Int, _ fullVVec: Tuple4, _ vSteps: Int) { + self.init(corner, .white, fullUVec, uSteps, fullVVec, vSteps, RandomJitter()) + } + + public init(_ corner: Tuple4, _ color: Color, _ fullUVec: Tuple4, _ uSteps: Int, _ fullVVec: Tuple4, _ vSteps: Int) { + self.init(corner, color, fullUVec, uSteps, fullVVec, vSteps, RandomJitter()) + } + + public init(_ corner: Tuple4, _ color: Color, _ fullUVec: Tuple4, _ uSteps: Int, _ fullVVec: Tuple4, _ vSteps: Int, _ jitter: Jitter) { + self.corner = corner + self.color = color + self.uSteps = uSteps + self.uVec = fullUVec.divideScalar(Double(uSteps)) + self.vSteps = vSteps + self.vVec = fullVVec.divideScalar(Double(vSteps)) + self.samples = uSteps * vSteps + self.position = corner + .add(uVec.multiplyScalar(Double(uSteps/2))) + .add(vVec.multiplyScalar(Double(vSteps/2))) + self.jitter = jitter + } + + public mutating func pointAt(_ u: Int, _ v: Int) -> Tuple4 { + return corner + .add(uVec.multiplyScalar(Double(u) + self.jitter.next())) + .add(vVec.multiplyScalar(Double(v) + self.jitter.next())) } } diff --git a/Sources/ScintillaLib/Material.swift b/Sources/ScintillaLib/Material.swift index 4eb8b87..a61b6e8 100644 --- a/Sources/ScintillaLib/Material.swift +++ b/Sources/ScintillaLib/Material.swift @@ -101,22 +101,57 @@ public class Material { return self } - func lighting(_ light: Light, _ object: Shape, _ point: Tuple4, _ eye: Tuple4, _ normal: Tuple4, _ isShadowed: Bool) -> Color { + func lighting(_ light: Light, _ object: Shape, _ point: Tuple4, _ eye: Tuple4, _ normal: Tuple4, _ intensity: Double) -> Color { // Combine the surface color with the light's color/intensity var effectiveColor: Color switch self.colorStrategy { case .solidColor(let color): - effectiveColor = color.hadamard(light.intensity) + effectiveColor = color.hadamard(light.color) case .pattern(let pattern): effectiveColor = pattern.colorAt(object, point) } - // Find the direction to the light source - let lightDirection = light.position.subtract(point).normalize() - // Compute the ambient contribution let ambient = effectiveColor.multiplyScalar(self.ambient) + switch light { + case let pointLight as PointLight: + let (diffuse, specular) = self.calculateDiffuseAndSpecular(pointLight.position, pointLight.color, point, effectiveColor, eye, normal, intensity) + return ambient.add(diffuse).add(specular) + case var areaLight as AreaLight: + var diffuseSamples: Color = .black + var specularSamples: Color = .black + + for u in 0.. (Color, Color) { + // Find the direction to the light source + let lightDirection = pointOnLight.subtract(point).normalize() + // light_dot_normal represents the cosine of the angle between the // light vector and the normal vector. A negative number means the // light is on the other side of the surface. @@ -124,7 +159,7 @@ public class Material { var diffuse: Color var specular: Color - if lightDotNormal < 0 || isShadowed == true { + if lightDotNormal < 0 { diffuse = .black specular = .black } else { @@ -142,10 +177,12 @@ public class Material { } else { // Compute the specular contribution let factor = pow(reflectDotEye, self.shininess) - specular = light.intensity.multiplyScalar(self.specular * factor) + specular = lightColor.multiplyScalar(self.specular * factor) } } + diffuse = diffuse.multiplyScalar(intensity) + specular = specular.multiplyScalar(intensity) - return ambient.add(diffuse).add(specular) + return (diffuse, specular) } } diff --git a/Sources/ScintillaLib/Shape.swift b/Sources/ScintillaLib/Shape.swift index f6b0398..4aaae16 100644 --- a/Sources/ScintillaLib/Shape.swift +++ b/Sources/ScintillaLib/Shape.swift @@ -20,6 +20,7 @@ public class Shape { var inverseTransform: Matrix4 var inverseTransposeTransform: Matrix4 var parent: Container? + var castsShadow: Bool public init(_ material: Material) { self.id = Self.latestId @@ -27,6 +28,7 @@ public class Shape { self.material = material self.inverseTransform = transform.inverse() self.inverseTransposeTransform = transform.inverse().transpose() + self.castsShadow = true Self.latestId += 1 } @@ -42,6 +44,12 @@ public class Shape { return CSG.makeCSG(.intersection, self, otherShapesBuilder) } + public func castsShadow(_ castsShadow: Bool) -> Self { + self.castsShadow = castsShadow + + return self + } + public func translate(_ x: Double, _ y: Double, _ z: Double) -> Self { self.transform = .translation(x, y, z) .multiplyMatrix(self.transform) diff --git a/Sources/ScintillaLib/World.swift b/Sources/ScintillaLib/World.swift index d618f24..3d256c2 100644 --- a/Sources/ScintillaLib/World.swift +++ b/Sources/ScintillaLib/World.swift @@ -68,7 +68,7 @@ public struct World { func shadeHit(_ computations: Computations, _ remainingCalls: Int) -> Color { let material = computations.object.material - let isShadowed = self.isShadowed(computations.overPoint) + let intensity = self.intensity(self.light, computations.overPoint) let surfaceColor = material.lighting( self.light, @@ -76,7 +76,7 @@ public struct World { computations.point, computations.eye, computations.normal, - isShadowed + intensity ) let reflectedColor = self.reflectedColorAt(computations, remainingCalls) @@ -142,8 +142,8 @@ public struct World { } func colorAt(_ ray: Ray, _ remainingCalls: Int) -> Color { - var allIntersections = self.intersect(ray) - let hit = hit(&allIntersections) + let allIntersections = self.intersect(ray) + let hit = hit(allIntersections) switch hit { case .none: return .black @@ -153,13 +153,13 @@ public struct World { } } - func isShadowed(_ point: Tuple4) -> Bool { - let lightVector = self.light.position.subtract(point) + func isShadowed(_ lightPoint: Tuple4, _ worldPoint: Tuple4) -> Bool { + let lightVector = lightPoint.subtract(worldPoint) let lightDistance = lightVector.magnitude() let lightDirection = lightVector.normalize() - let lightRay = Ray(point, lightDirection) - var intersections = self.intersect(lightRay) - let hit = hit(&intersections) + let lightRay = Ray(worldPoint, lightDirection) + let intersections = self.intersect(lightRay) + let hit = hit(intersections, includeOnlyShadowingObjects: true) if hit != nil && hit!.t < lightDistance { return true @@ -168,6 +168,25 @@ public struct World { } } + func intensity(_ light: Light, _ worldPoint: Tuple4) -> Double { + switch light { + case let pointLight as PointLight: + return isShadowed(pointLight.position, worldPoint) ? 0.0 : 1.0 + case var areaLight as AreaLight: + var intensity: Double = 0.0 + for u in 0.. Ray { // The offset from the edge of the canvas to the pixel's center let offsetX = (Double(pixelX) + 0.5) * self.camera.pixelSize diff --git a/Tests/ScintillaLibTests/IntersectionTests.swift b/Tests/ScintillaLibTests/IntersectionTests.swift index 213723d..ebc6243 100644 --- a/Tests/ScintillaLibTests/IntersectionTests.swift +++ b/Tests/ScintillaLibTests/IntersectionTests.swift @@ -13,8 +13,8 @@ class IntersectionTests: XCTestCase { let s = Sphere(.basicMaterial()) let i1 = Intersection(1, s) let i2 = Intersection(2, s) - var intersections = [i2, i1] - let h = hit(&intersections)! + let intersections = [i2, i1] + let h = hit(intersections)! XCTAssert(h.t.isAlmostEqual(i1.t)) } @@ -22,8 +22,8 @@ class IntersectionTests: XCTestCase { let s = Sphere(.basicMaterial()) let i1 = Intersection(-1, s) let i2 = Intersection(1, s) - var intersections = [i2, i1] - let h = hit(&intersections)! + let intersections = [i2, i1] + let h = hit(intersections)! XCTAssert(h.t.isAlmostEqual(i2.t)) } @@ -31,8 +31,8 @@ class IntersectionTests: XCTestCase { let s = Sphere(.basicMaterial()) let i1 = Intersection(-2, s) let i2 = Intersection(-1, s) - var intersections = [i2, i1] - let h = hit(&intersections) + let intersections = [i2, i1] + let h = hit(intersections) XCTAssertNil(h) } @@ -42,11 +42,25 @@ class IntersectionTests: XCTestCase { let i2 = Intersection(7, s) let i3 = Intersection(-3, s) let i4 = Intersection(2, s) - var intersections = [i1, i2, i3, i4] - let h = hit(&intersections)! + let intersections = [i1, i2, i3, i4] + let h = hit(intersections)! XCTAssert(h.t.isAlmostEqual(i4.t)) } + func testHitOnlyConsidersObjectsThatCastShadowsWhenCalledThatWay() throws { + let s1 = Sphere(.basicMaterial()) + .castsShadow(false) + let i1 = Intersection(1, s1) + let i2 = Intersection(3, s1) + let s2 = Sphere(.basicMaterial()) + .translate(3, 0, 0) + let i3 = Intersection(4, s2) + let i4 = Intersection(6, s2) + let intersections = [i1, i2, i3, i4] + let h = hit(intersections, includeOnlyShadowingObjects: true)! + XCTAssertEqual(h.shape.id, s2.id) + } + func testPrepareComputationsOutside() throws { let ray = Ray(point(0, 0, -5), vector(0, 0, 1)) let shape = Sphere(.basicMaterial()) diff --git a/Tests/ScintillaLibTests/LightTests.swift b/Tests/ScintillaLibTests/LightTests.swift new file mode 100644 index 0000000..74eff10 --- /dev/null +++ b/Tests/ScintillaLibTests/LightTests.swift @@ -0,0 +1,66 @@ +// +// LightTests.swift +// +// +// Created by Danielle Kefford on 10/11/22. +// + +import XCTest +@testable import ScintillaLib + +class LightTests: XCTestCase { + func testAreaLightIsCreatedProperly() throws { + let areaLight = AreaLight( + point(0, 0, 0), + Color(1, 1, 1), + vector(2, 0, 0), 4, + vector(0, 0, 1), 2) + + XCTAssertEqual(areaLight.samples, 8) + XCTAssert(areaLight.uVec.isAlmostEqual(vector(0.5, 0.0, 0.0))) + XCTAssert(areaLight.vVec.isAlmostEqual(vector(0.0, 0.0, 0.5))) + XCTAssert(areaLight.position.isAlmostEqual(point(1.0, 0.0, 0.5))) + } + + func testFindingAPointOnAnAreaLight() throws { + var areaLight = AreaLight( + point(0, 0, 0), + Color(1, 1, 1), + vector(2, 0, 0), 4, + vector(0, 0, 1), 2, + NoJitter()) + + let testCases = [ + (0, 0, point(0.25, 0, 0.25)), + (1, 0, point(0.75, 0, 0.25)), + (0, 1, point(0.25, 0, 0.75)), + (2, 0, point(1.25, 0, 0.25)), + (3, 1, point(1.75, 0, 0.75)), + ] + for (u, v, expectedPoint) in testCases { + let actualPoint = areaLight.pointAt(u, v) + XCTAssert(actualPoint.isAlmostEqual(expectedPoint)) + } + } + + func testFindingAPointOnAnAreaLightWithJitter() throws { + var areaLight = AreaLight( + point(0, 0, 0), + Color(1, 1, 1), + vector(2, 0, 0), 4, + vector(0, 0, 1), 2, + PseudorandomJitter([0.3, 0.7])) + + let testCases = [ + (0, 0, point(0.15, 0, 0.35)), + (1, 0, point(0.65, 0, 0.35)), + (0, 1, point(0.15, 0, 0.85)), + (2, 0, point(1.15, 0, 0.35)), + (3, 1, point(1.65, 0, 0.85)), + ] + for (u, v, expectedPoint) in testCases { + let actualPoint = areaLight.pointAt(u, v) + XCTAssert(actualPoint.isAlmostEqual(expectedPoint)) + } + } +} diff --git a/Tests/ScintillaLibTests/MaterialTests.swift b/Tests/ScintillaLibTests/MaterialTests.swift index 3072ecf..49d498d 100644 --- a/Tests/ScintillaLibTests/MaterialTests.swift +++ b/Tests/ScintillaLibTests/MaterialTests.swift @@ -15,8 +15,8 @@ class MaterialTests: XCTestCase { let position = point(0, 0, 0) let eye = vector(0, 0, -1) let normal = vector(0, 0, -1) - let light = Light(point(0, 0, -10), Color(1, 1, 1)) - let actualValue = m.lighting(light, shape, position, eye, normal, false) + let light = PointLight(point(0, 0, -10), Color(1, 1, 1)) + let actualValue = m.lighting(light, shape, position, eye, normal, 1.0) let expectedValue = Color(1.9, 1.9, 1.9) XCTAssert(actualValue.isAlmostEqual(expectedValue)) } @@ -27,8 +27,8 @@ class MaterialTests: XCTestCase { let position = point(0, 0, 0) let eye = vector(0, sqrt(2)/2, -sqrt(2)/2) let normal = vector(0, 0, -1) - let light = Light(point(0, 0, -10), Color(1, 1, 1)) - let actualValue = m.lighting(light, shape, position, eye, normal, false) + let light = PointLight(point(0, 0, -10), Color(1, 1, 1)) + let actualValue = m.lighting(light, shape, position, eye, normal, 1.0) let expectedValue = Color(1.0, 1.0, 1.0) XCTAssert(actualValue.isAlmostEqual(expectedValue)) } @@ -39,8 +39,8 @@ class MaterialTests: XCTestCase { let position = point(0, 0, 0) let eye = vector(0, 0, -1) let normal = vector(0, 0, -1) - let light = Light(point(0, 10, -10), Color(1, 1, 1)) - let actualValue = m.lighting(light, shape, position, eye, normal, false) + let light = PointLight(point(0, 10, -10), Color(1, 1, 1)) + let actualValue = m.lighting(light, shape, position, eye, normal, 1.0) let expectedValue = Color(0.7364, 0.7364, 0.7364) XCTAssert(actualValue.isAlmostEqual(expectedValue)) } @@ -51,8 +51,8 @@ class MaterialTests: XCTestCase { let position = point(0, 0, 0) let eye = vector(0, -sqrt(2)/2, -sqrt(2)/2) let normal = vector(0, 0, -1) - let light = Light(point(0, 10, -10), Color(1, 1, 1)) - let actualValue = m.lighting(light, shape, position, eye, normal, false) + let light = PointLight(point(0, 10, -10), Color(1, 1, 1)) + let actualValue = m.lighting(light, shape, position, eye, normal, 1.0) let expectedValue = Color(1.6364, 1.6364, 1.6364) XCTAssert(actualValue.isAlmostEqual(expectedValue)) } @@ -63,21 +63,20 @@ class MaterialTests: XCTestCase { let position = point(0, 0, 0) let eye = vector(0, 0, -1) let normal = vector(0, 0, -1) - let light = Light(point(0, 0, 10), Color(1, 1, 1)) - let actualValue = m.lighting(light, shape, position, eye, normal, false) + let light = PointLight(point(0, 0, 10), Color(1, 1, 1)) + let actualValue = m.lighting(light, shape, position, eye, normal, 1.0) let expectedValue = Color(0.1, 0.1, 0.1) XCTAssert(actualValue.isAlmostEqual(expectedValue)) } func testLightingSurfaceInShadow() throws { - let light = Light(point(0, 0, -10), Color(1, 1, 1)) + let light = PointLight(point(0, 0, -10), Color(1, 1, 1)) let position = point(0, 0, 0) let eye = vector(0, 0, -1) let normal = vector(0, 0, -1) - let isShadowed = true let material = Material.basicMaterial() let shape = Sphere(material) - let actualValue = material.lighting(light, shape, position, eye, normal, isShadowed) + let actualValue = material.lighting(light, shape, position, eye, normal, 0.0) let expectedValue = Color(0.1, 0.1, 0.1) XCTAssert(actualValue.isAlmostEqual(expectedValue)) } @@ -88,10 +87,59 @@ class MaterialTests: XCTestCase { let shape = Sphere(material) let eye = vector(0, 0, -1) let normal = vector(0, 0, -1) - let light = Light(point(0, 0, -10), Color(1, 1, 1)) - let color1 = material.lighting(light, shape, point(0.9, 0, 0), eye, normal, false) + let light = PointLight(point(0, 0, -10), Color(1, 1, 1)) + let color1 = material.lighting(light, shape, point(0.9, 0, 0), eye, normal, 1.0) XCTAssertTrue(color1.isAlmostEqual(.white)) - let color2 = material.lighting(light, shape, point(1.1, 0, 0), eye, normal, false) + let color2 = material.lighting(light, shape, point(1.1, 0, 0), eye, normal, 1.0) XCTAssertTrue(color2.isAlmostEqual(.black)) } + + func testLightUsesIntensityToAttenuateColor() throws { + let light = PointLight(point(0, 0, -10)) + let shape = Sphere(.solidColor(.white) + .ambient(0.1) + .diffuse(0.9) + .specular(0.0) + .refractive(0.0) + ) + let point = point(0, 0, -1) + let eye = vector(0, 0, -1) + let normal = vector(0, 0, -1) + + let testCases = [ + (1.0, Color(1, 1, 1)), + (0.5, Color(0.55, 0.55, 0.55)), + (0.0, Color(0.1, 0.1, 0.1)) + ] + for (intensity, expectedLighting) in testCases { + let actualLighting = shape.material.lighting(light, shape, point, eye, normal, intensity) + XCTAssertTrue(actualLighting.isAlmostEqual(expectedLighting)) + } + } + + func testLightingSamplesAreaLight() throws { + let areaLight = AreaLight( + point(-0.5, -0.5, -5), + Color(1, 1, 1), + vector(1, 0, 0), 2, + vector(0, 1, 0), 2, + NoJitter()) + let material: Material = .solidColor(.white) + .ambient(0.1) + .diffuse(0.9) + .specular(0.0) + let shape = Sphere(material) + let eye = point(0, 0, -5) + + let testCases = [ + (point(0, 0, -1), Color(0.9965, 0.9965, 0.9965)), + (point(0, 0.7071, -0.7071), Color(0.62318, 0.62318, 0.62318)), + ] + for (point, expectedColor) in testCases { + let eyeV = eye.subtract(point).normalize() + let normalV = vector(point.x, point.y, point.z) + let actualColor = material.lighting(areaLight, shape, point, eyeV, normalV, 1.0) + XCTAssert(actualColor.isAlmostEqual(expectedColor)) + } + } } diff --git a/Tests/ScintillaLibTests/WorldTests.swift b/Tests/ScintillaLibTests/WorldTests.swift index b5c74fd..2a51d15 100644 --- a/Tests/ScintillaLibTests/WorldTests.swift +++ b/Tests/ScintillaLibTests/WorldTests.swift @@ -15,7 +15,7 @@ let testCamera = Camera(800, 600, PI/3, .view( func testWorld() -> World { World { - Light(point(-10, 10, -10)) + PointLight(point(-10, 10, -10)) Camera(800, 600, PI/3, .view( point(0, 1, -1), point(0, 0, 0), @@ -56,7 +56,7 @@ class WorldTests: XCTestCase { func testShadeHitInside() throws { var world = testWorld() - let light = Light(point(0, 0.25, 0), Color(1, 1, 1)) + let light = PointLight(point(0, 0.25, 0), Color(1, 1, 1)) world.light = light let ray = Ray(point(0, 0, 0), vector(0, 0, 1)) let shape = world.objects[1] @@ -72,7 +72,7 @@ class WorldTests: XCTestCase { let s2 = Sphere(.basicMaterial()) .translate(0, 0, 10) let world = World { - Light(point(0, 0, -10), Color(1, 1, 1)) + PointLight(point(0, 0, -10), Color(1, 1, 1)) Camera(800, 600, PI/3, .view( point(0, 1, -1), point(0, 0, 0), @@ -120,26 +120,119 @@ class WorldTests: XCTestCase { func testIsShadowedPointAndLightNotCollinear() throws { let world = testWorld() - let point = point(0, 10, 0) - XCTAssertFalse(world.isShadowed(point)) + let worldPoint = point(0, 10, 0) + XCTAssertFalse(world.isShadowed(world.light.position, worldPoint)) } func testIsShadowedObjectBetweenPointAndLight() throws { let world = testWorld() - let point = point(10, -10, 10) - XCTAssertTrue(world.isShadowed(point)) + let worldPoint = point(10, -10, 10) + XCTAssertTrue(world.isShadowed(world.light.position, worldPoint)) } func testIsShadowedObjectBehindLight() throws { let world = testWorld() - let point = point(-20, 20, -20) - XCTAssertFalse(world.isShadowed(point)) + let worldPoint = point(-20, 20, -20) + XCTAssertFalse(world.isShadowed(world.light.position, worldPoint)) } func testIsShadowedObjectBehindPoint() throws { let world = testWorld() - let point = point(-2, 2, -2) - XCTAssertFalse(world.isShadowed(point)) + let worldPoint = point(-2, 2, -2) + XCTAssertFalse(world.isShadowed(world.light.position, worldPoint)) + } + + func testIntensityOfPointLight() throws { + let testCases = [ + (point(0, 1.0001, 0), 1.0), + (point(-1.0001, 0, 0), 1.0), + (point(0, 0, -1.0001), 1.0), + (point(0, 0, 1.0001), 0.0), + (point(1.0001, 0, 0), 0.0), + (point(0, -1.0001, 0), 0.0), + (point(0, 0, 0), 0.0), + ] + + let world = testWorld() + let light = world.light + + for (worldPoint, expectedIntensity) in testCases { + let actualIntesity = world.intensity(light, worldPoint) + XCTAssertEqual(actualIntesity, expectedIntensity) + } + } + + func testIntensityOfAreaLightWithNoJitter() throws { + let areaLight = AreaLight( + point(-0.5, -0.5, -5), + Color(1, 1, 1), + vector(1, 0, 0), 2, + vector(0, 1, 0), 2, + NoJitter()) + let world = World { + areaLight + Camera(800, 600, PI/3, .view( + point(0, 1, -1), + point(0, 0, 0), + vector(0, 1, 0))) + Sphere(.solidColor(Color(0.8, 1.0, 0.6)) + .ambient(0.1) + .diffuse(0.7) + .specular(0.2) + .refractive(0.0) + ) + Sphere(.basicMaterial()) + .scale(0.5, 0.5, 0.5) + } + + let testCases = [ + (point(0, 0, 2), 0.0), + (point(1, -1, 2), 0.25), + (point(1.5, 0, 2), 0.5), + (point(1.25, 1.25, 3), 0.75), + (point(0, 0, -2), 1.0), + ] + for (worldPoint, expectedIntensity) in testCases { + let actualIntensity = world.intensity(areaLight, worldPoint) + XCTAssertEqual(actualIntensity, expectedIntensity) + } + } + + func testIntensityOfAreaLightWithPseduorandomJitter() throws { + let areaLight = AreaLight( + point(-0.5, -0.5, -5), + Color(1, 1, 1), + vector(1, 0, 0), 2, + vector(0, 1, 0), 2, + PseudorandomJitter([0.7, 0.3, 0.9, 0.1, 0.5])) + let world = World { + areaLight + Camera(800, 600, PI/3, .view( + point(0, 1, -1), + point(0, 0, 0), + vector(0, 1, 0))) + Sphere(.solidColor(Color(0.8, 1.0, 0.6)) + .ambient(0.1) + .diffuse(0.7) + .specular(0.2) + .refractive(0.0) + ) + Sphere(.basicMaterial()) + .scale(0.5, 0.5, 0.5) + } + + let testCases = [ + (point(0, 0, 2), 0.0), + (point(1, -1, 2), 0.5), + (point(1.5, 0, 2), 1.0), + (point(1.25, 1.25, 3), 0.75), + (point(0, 0, -2), 1.0), + ] + for (worldPoint, expectedIntensity) in testCases { + let actualIntensity = world.intensity(areaLight, worldPoint) + XCTAssertEqual(actualIntensity, expectedIntensity) + print(actualIntensity) + } } func testReflectedColorForNonreflectiveMaterial() { @@ -172,7 +265,7 @@ class WorldTests: XCTestCase { func testColorAtTerminatesForWorldWithMutuallyReflectiveSurfaces() throws { let world = World { - Light(point(0, 0, 0)) + PointLight(point(0, 0, 0)) Camera(800, 600, PI/3, .view( point(0, 1, -1), point(0, 0, 0), @@ -313,7 +406,7 @@ class WorldTests: XCTestCase { let glass = Material(.solidColor(.white), 0.1, 0.9, 0.9, 200, 0.0, 1.0, 1.5) let glassySphere = Sphere(glass) let world = World { - Light(point(-10, 10, -10)) + PointLight(point(-10, 10, -10)) Camera(800, 600, PI/3, .view( point(0, 1, -1), point(0, 0, 0), @@ -336,7 +429,7 @@ class WorldTests: XCTestCase { let glass = Material(.solidColor(.white), 0.1, 0.9, 0.9, 200, 0.0, 1.0, 1.5) let glassySphere = Sphere(glass) let world = World { - Light(point(-10, 10, -10)) + PointLight(point(-10, 10, -10)) Camera(800, 600, PI/3, .view( point(0, 1, -1), point(0, 0, 0), @@ -359,7 +452,7 @@ class WorldTests: XCTestCase { let glass = Material(.solidColor(.white), 0.1, 0.9, 0.9, 200, 0.0, 1.0, 1.5) let glassySphere = Sphere(glass) let world = World { - Light(point(-10, 10, -10)) + PointLight(point(-10, 10, -10)) Camera(800, 600, PI/3, .view( point(0, 1, -1), point(0, 0, 0), @@ -400,7 +493,7 @@ class WorldTests: XCTestCase { } func testRayForPixelForCenterOfCanvas() throws { - let light = Light(point(-10, 10, -10)) + let light = PointLight(point(-10, 10, -10)) let camera = Camera(201, 101, PI/2, .identity) let objects: [Shape] = [] let world = World(light, camera, objects) @@ -411,7 +504,7 @@ class WorldTests: XCTestCase { } func testRayForPixelForCornerOfCanvas() throws { - let light = Light(point(-10, 10, -10)) + let light = PointLight(point(-10, 10, -10)) let camera = Camera(201, 101, PI/2, .identity) let objects: [Shape] = [] let world = World(light, camera, objects) @@ -422,7 +515,7 @@ class WorldTests: XCTestCase { } func testRayForPixelForTransformedCamera() throws { - let light = Light(point(-10, 10, -10)) + let light = PointLight(point(-10, 10, -10)) let transform = Matrix4.rotationY(PI/4) .multiplyMatrix(.translation(0, -2, 5)) let camera = Camera(201, 101, PI/2, transform)