├── .gitignore ├── images ├── demo_image_old.png ├── polygons │ └── demo-10.png ├── reflections_demo.png ├── lighting_parameters │ ├── demo.png │ ├── demo-0.png │ ├── demo-1.png │ ├── demo-10.png │ ├── demo-11.png │ ├── demo-12.png │ ├── demo-13.png │ ├── demo-2.png │ ├── demo-3.png │ ├── demo-4.png │ ├── demo-5.png │ ├── demo-6.png │ ├── demo-7.png │ ├── demo-8.png │ └── demo-9.png └── partial_reflections │ ├── paint_based_reflections.png │ ├── weighted_average_reflections_1.png │ └── weighted_average_reflections_2.png ├── src ├── main │ └── java │ │ └── me │ │ └── kahlil │ │ ├── graphics │ │ ├── Point2D.java │ │ ├── Shader.java │ │ ├── SamplingRadius.java │ │ ├── AntiAliasingMethod.java │ │ ├── RenderingResult.java │ │ ├── Colors.java │ │ ├── RayTracerWorker.java │ │ ├── RandomAntiAliasingMethod.java │ │ ├── RayIntersections.java │ │ ├── RayTracer.java │ │ ├── MutableColor.java │ │ ├── ColorComputation.java │ │ ├── GridAntiAliasingMethod.java │ │ ├── SimpleAntiAliaser.java │ │ ├── CoordinateMapper.java │ │ ├── ReflectiveRayTracer.java │ │ ├── RayTracerCoordinator.java │ │ └── PhongShading.java │ │ ├── geometry │ │ ├── BoundingVolume.java │ │ ├── DoubleHelper.java │ │ ├── Intersectable.java │ │ ├── Constants.java │ │ ├── Polygon.java │ │ ├── LightSphere.java │ │ ├── PointObject.java │ │ ├── RayHit.java │ │ ├── BoundingBox.java │ │ ├── Plane.java │ │ ├── Ray.java │ │ ├── Sphere.java │ │ ├── Shape.java │ │ ├── Matrix.java │ │ ├── PolygonSphere.java │ │ ├── Vector.java │ │ ├── Triangle.java │ │ ├── LinearTransformation.java │ │ ├── Extents.java │ │ └── ConvexPolygon.java │ │ ├── config │ │ ├── JavaStyle.java │ │ ├── Parameters.java │ │ └── Counters.java │ │ ├── scene │ │ ├── PointLight.java │ │ ├── Scene.java │ │ ├── Camera.java │ │ ├── Cameras.java │ │ ├── Materials.java │ │ ├── Raster.java │ │ └── Material.java │ │ ├── octree │ │ ├── Octree.java │ │ ├── BoundsHelper.java │ │ └── OctreeNode.java │ │ └── demos │ │ └── Demo.java └── test │ └── java │ └── me │ └── kahlil │ ├── geometry │ ├── DoubleHelperTest.java │ ├── PlaneTest.java │ ├── PointObjectTest.java │ ├── SphereTest.java │ ├── MatrixTest.java │ ├── PolygonSphereTest.java │ ├── BoundingBoxTest.java │ ├── ExtentsTest.java │ ├── ConvexPolygonTest.java │ ├── TriangleTest.java │ ├── VectorTest.java │ ├── ShapeTest.java │ └── LinearTransformationTest.java │ ├── scene │ └── RasterTest.java │ ├── graphics │ ├── MutableColorComputationTest.java │ ├── PhongShadingTest.java │ └── CoordinateMapperTest.java │ └── octree │ └── OctreeTest.java ├── LICENSE ├── README.md └── pom.xml /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/* 2 | reflection.txt 3 | *.iml 4 | target/ 5 | images/tmp/ 6 | -------------------------------------------------------------------------------- /images/demo_image_old.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kahliloppenheimer/simple-java-ray-tracer/HEAD/images/demo_image_old.png -------------------------------------------------------------------------------- /images/polygons/demo-10.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kahliloppenheimer/simple-java-ray-tracer/HEAD/images/polygons/demo-10.png -------------------------------------------------------------------------------- /images/reflections_demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kahliloppenheimer/simple-java-ray-tracer/HEAD/images/reflections_demo.png -------------------------------------------------------------------------------- /images/lighting_parameters/demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kahliloppenheimer/simple-java-ray-tracer/HEAD/images/lighting_parameters/demo.png -------------------------------------------------------------------------------- /images/lighting_parameters/demo-0.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kahliloppenheimer/simple-java-ray-tracer/HEAD/images/lighting_parameters/demo-0.png -------------------------------------------------------------------------------- /images/lighting_parameters/demo-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kahliloppenheimer/simple-java-ray-tracer/HEAD/images/lighting_parameters/demo-1.png -------------------------------------------------------------------------------- /images/lighting_parameters/demo-10.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kahliloppenheimer/simple-java-ray-tracer/HEAD/images/lighting_parameters/demo-10.png -------------------------------------------------------------------------------- /images/lighting_parameters/demo-11.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kahliloppenheimer/simple-java-ray-tracer/HEAD/images/lighting_parameters/demo-11.png -------------------------------------------------------------------------------- /images/lighting_parameters/demo-12.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kahliloppenheimer/simple-java-ray-tracer/HEAD/images/lighting_parameters/demo-12.png -------------------------------------------------------------------------------- /images/lighting_parameters/demo-13.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kahliloppenheimer/simple-java-ray-tracer/HEAD/images/lighting_parameters/demo-13.png -------------------------------------------------------------------------------- /images/lighting_parameters/demo-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kahliloppenheimer/simple-java-ray-tracer/HEAD/images/lighting_parameters/demo-2.png -------------------------------------------------------------------------------- /images/lighting_parameters/demo-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kahliloppenheimer/simple-java-ray-tracer/HEAD/images/lighting_parameters/demo-3.png -------------------------------------------------------------------------------- /images/lighting_parameters/demo-4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kahliloppenheimer/simple-java-ray-tracer/HEAD/images/lighting_parameters/demo-4.png -------------------------------------------------------------------------------- /images/lighting_parameters/demo-5.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kahliloppenheimer/simple-java-ray-tracer/HEAD/images/lighting_parameters/demo-5.png -------------------------------------------------------------------------------- /images/lighting_parameters/demo-6.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kahliloppenheimer/simple-java-ray-tracer/HEAD/images/lighting_parameters/demo-6.png -------------------------------------------------------------------------------- /images/lighting_parameters/demo-7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kahliloppenheimer/simple-java-ray-tracer/HEAD/images/lighting_parameters/demo-7.png -------------------------------------------------------------------------------- /images/lighting_parameters/demo-8.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kahliloppenheimer/simple-java-ray-tracer/HEAD/images/lighting_parameters/demo-8.png -------------------------------------------------------------------------------- /images/lighting_parameters/demo-9.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kahliloppenheimer/simple-java-ray-tracer/HEAD/images/lighting_parameters/demo-9.png -------------------------------------------------------------------------------- /images/partial_reflections/paint_based_reflections.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kahliloppenheimer/simple-java-ray-tracer/HEAD/images/partial_reflections/paint_based_reflections.png -------------------------------------------------------------------------------- /images/partial_reflections/weighted_average_reflections_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kahliloppenheimer/simple-java-ray-tracer/HEAD/images/partial_reflections/weighted_average_reflections_1.png -------------------------------------------------------------------------------- /images/partial_reflections/weighted_average_reflections_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kahliloppenheimer/simple-java-ray-tracer/HEAD/images/partial_reflections/weighted_average_reflections_2.png -------------------------------------------------------------------------------- /src/main/java/me/kahlil/graphics/Point2D.java: -------------------------------------------------------------------------------- 1 | package me.kahlil.graphics; 2 | 3 | import me.kahlil.config.JavaStyle; 4 | import org.immutables.value.Value.Immutable; 5 | 6 | @Immutable 7 | @JavaStyle 8 | public interface Point2D { 9 | 10 | double getX(); 11 | double getY(); 12 | 13 | } 14 | -------------------------------------------------------------------------------- /src/main/java/me/kahlil/geometry/BoundingVolume.java: -------------------------------------------------------------------------------- 1 | package me.kahlil.geometry; 2 | 3 | public interface BoundingVolume { 4 | 5 | /** 6 | * Returns time of intersection with bounding volume. -1 if no intersection occurs. 7 | */ 8 | double intersectWithBoundingVolume(Ray ray); 9 | 10 | } 11 | -------------------------------------------------------------------------------- /src/main/java/me/kahlil/config/JavaStyle.java: -------------------------------------------------------------------------------- 1 | package me.kahlil.config; 2 | 3 | import org.immutables.value.Value; 4 | 5 | /** Immutables style for traditional java isFoo(), getFoo() and setFoo() method naming patterns. */ 6 | @Value.Style( 7 | get = {"get*", "is*"}, 8 | init = "set*") 9 | public @interface JavaStyle {} 10 | -------------------------------------------------------------------------------- /src/main/java/me/kahlil/graphics/Shader.java: -------------------------------------------------------------------------------- 1 | package me.kahlil.graphics; 2 | 3 | import me.kahlil.graphics.MutableColor; 4 | import me.kahlil.geometry.RayHit; 5 | 6 | /** 7 | * A shading model that knows how to determine the color of a particular {@link RayHit}. 8 | */ 9 | public interface Shader { 10 | 11 | MutableColor shade(RayHit rayHit); 12 | 13 | } 14 | -------------------------------------------------------------------------------- /src/main/java/me/kahlil/graphics/SamplingRadius.java: -------------------------------------------------------------------------------- 1 | package me.kahlil.graphics; 2 | 3 | import me.kahlil.config.JavaStyle; 4 | import org.immutables.value.Value.Immutable; 5 | 6 | /** A rectangular sampling area in which anti-aliasing methods can trace rays. */ 7 | @Immutable 8 | @JavaStyle 9 | interface SamplingRadius { 10 | 11 | double getHeight(); 12 | 13 | double getWidth(); 14 | } 15 | -------------------------------------------------------------------------------- /src/main/java/me/kahlil/geometry/DoubleHelper.java: -------------------------------------------------------------------------------- 1 | package me.kahlil.geometry; 2 | 3 | import static me.kahlil.geometry.Constants.EPSILON; 4 | 5 | public class DoubleHelper { 6 | 7 | /** 8 | * Returns true iff d1 and d2 are basically equal (sans {@link EPSILON}). 9 | */ 10 | public static boolean nearEquals(double d1, double d2) { 11 | return Math.abs(d2 - d1) < EPSILON; 12 | } 13 | 14 | } 15 | -------------------------------------------------------------------------------- /src/main/java/me/kahlil/geometry/Intersectable.java: -------------------------------------------------------------------------------- 1 | package me.kahlil.geometry; 2 | 3 | import java.util.Optional; 4 | 5 | /** Representation of a geometric object which can describe its intersections with Rays. */ 6 | public interface Intersectable { 7 | 8 | /** 9 | * Returns a {@link Optional} describing the intersection if it ocurred, and {@link 10 | * Optional#empty otherwise}. 11 | */ 12 | Optional intersectWith(Ray ray); 13 | } 14 | -------------------------------------------------------------------------------- /src/main/java/me/kahlil/graphics/AntiAliasingMethod.java: -------------------------------------------------------------------------------- 1 | package me.kahlil.graphics; 2 | 3 | import me.kahlil.geometry.Ray; 4 | 5 | /** A method for picking out points within a sampling radius to apply anti-aliasing. */ 6 | interface AntiAliasingMethod { 7 | 8 | /** 9 | * Generates the rays to sample to anti-alias a given ray within a given rectangular sampling 10 | * radius. 11 | */ 12 | Ray[] getRaysToSample(Ray ray, SamplingRadius samplingRadius); 13 | } 14 | -------------------------------------------------------------------------------- /src/main/java/me/kahlil/geometry/Constants.java: -------------------------------------------------------------------------------- 1 | package me.kahlil.geometry; 2 | 3 | public final class Constants { 4 | 5 | private Constants() { } 6 | 7 | /** 8 | * Represents a tiny delta used for approximating equality to zero. 9 | */ 10 | public static final double EPSILON = 0.0000001; 11 | 12 | /** 13 | * Represents the origin of the x, y, z coordinate plane. 14 | */ 15 | public static final Vector ORIGIN = new Vector(0.0, 0.0, 0.0); 16 | } 17 | 18 | -------------------------------------------------------------------------------- /src/main/java/me/kahlil/graphics/RenderingResult.java: -------------------------------------------------------------------------------- 1 | package me.kahlil.graphics; 2 | 3 | import me.kahlil.graphics.MutableColor; 4 | import me.kahlil.config.JavaStyle; 5 | import org.immutables.value.Value.Immutable; 6 | 7 | @Immutable 8 | @JavaStyle 9 | public interface RenderingResult { 10 | 11 | // Shaded color of intersection. 12 | MutableColor getColor(); 13 | // Number of rays traced during the color computation for this pixel. 14 | long getNumRaysTraced(); 15 | 16 | } 17 | -------------------------------------------------------------------------------- /src/main/java/me/kahlil/geometry/Polygon.java: -------------------------------------------------------------------------------- 1 | package me.kahlil.geometry; 2 | 3 | /** 4 | * Represents a shape that can be broken into {@link Triangle}s. 5 | */ 6 | public interface Polygon { 7 | 8 | /** 9 | * Returns all {@link Triangle}s that compose this shape. 10 | */ 11 | Triangle[] getTriangles(); 12 | 13 | /** 14 | * Returns the min bound of this shape. 15 | */ 16 | Vector minBound(); 17 | 18 | /** 19 | * Returns the max bound of this shape. 20 | */ 21 | Vector maxBound(); 22 | 23 | } 24 | -------------------------------------------------------------------------------- /src/main/java/me/kahlil/scene/PointLight.java: -------------------------------------------------------------------------------- 1 | package me.kahlil.scene; 2 | 3 | import me.kahlil.graphics.MutableColor; 4 | import me.kahlil.config.JavaStyle; 5 | import me.kahlil.geometry.Vector; 6 | import org.immutables.value.Value.Immutable; 7 | 8 | /** A representation of a simple light source with a location and intensity. */ 9 | @Immutable 10 | @JavaStyle 11 | public interface PointLight { 12 | 13 | /** Returns the location in 3-dimensional space of this light. */ 14 | Vector getLocation(); 15 | 16 | /** Returns the color that this light emanates. */ 17 | MutableColor getColor(); 18 | } 19 | -------------------------------------------------------------------------------- /src/main/java/me/kahlil/graphics/Colors.java: -------------------------------------------------------------------------------- 1 | package me.kahlil.graphics; 2 | 3 | /** 4 | * A few handy {@link MutableColor}s. 5 | */ 6 | public final class Colors { 7 | 8 | public static final MutableColor BLACK = new MutableColor(0f, 0f, 0f); 9 | 10 | public static final MutableColor BLUE = new MutableColor(0f, 0f, 1.0f); 11 | 12 | public static final MutableColor CYAN = new MutableColor(0f, 1.0f, 1.0f); 13 | 14 | public static final MutableColor GREEN = new MutableColor(0f, 1.0f, 0f); 15 | 16 | public static final MutableColor MAGENTA = new MutableColor(1.0f, 0f, 1.0f); 17 | 18 | public static final MutableColor RED = new MutableColor(1.0f, 0f, 0f); 19 | 20 | public static final MutableColor WHITE = new MutableColor(1.0f, 1.0f, 1.0f); 21 | 22 | } 23 | -------------------------------------------------------------------------------- /src/main/java/me/kahlil/scene/Scene.java: -------------------------------------------------------------------------------- 1 | package me.kahlil.scene; 2 | 3 | import com.google.common.collect.ImmutableList; 4 | import me.kahlil.config.JavaStyle; 5 | import me.kahlil.geometry.Shape; 6 | import me.kahlil.graphics.MutableColor; 7 | import org.immutables.value.Value.Immutable; 8 | 9 | /** Represents all of hte lights and objects present in a scene to render. */ 10 | @Immutable 11 | @JavaStyle 12 | public interface Scene { 13 | 14 | // List of all objects in the scene 15 | ImmutableList getShapes(); 16 | 17 | // List of all lights in the scene 18 | ImmutableList getLights(); 19 | 20 | // Background color of the scene 21 | MutableColor getBackgroundColor(); 22 | 23 | // Ambient lighting of the scene 24 | MutableColor getAmbient(); 25 | } 26 | -------------------------------------------------------------------------------- /src/main/java/me/kahlil/geometry/LightSphere.java: -------------------------------------------------------------------------------- 1 | package me.kahlil.geometry; 2 | 3 | import static me.kahlil.geometry.LinearTransformation.translate; 4 | 5 | import me.kahlil.graphics.MutableColor; 6 | import me.kahlil.scene.ImmutableMaterial; 7 | import me.kahlil.scene.PointLight; 8 | 9 | /** A sphere which represents a light source in a scene (i.e. if scene in a reflection). */ 10 | public class LightSphere extends Sphere { 11 | 12 | public LightSphere(PointLight light) { 13 | super( 14 | light.getLocation(), 15 | 0.5, 16 | ImmutableMaterial.builder() 17 | .setColor(new MutableColor(1.0f, 1.0f, 1.0f)) 18 | .setSpecularIntensity(1.0) 19 | .setHardness(240) 20 | .build()); 21 | transform(translate(light.getLocation())); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/main/java/me/kahlil/scene/Camera.java: -------------------------------------------------------------------------------- 1 | package me.kahlil.scene; 2 | 3 | import static com.google.common.base.Preconditions.checkArgument; 4 | 5 | import me.kahlil.config.JavaStyle; 6 | import me.kahlil.geometry.Vector; 7 | import org.immutables.value.Value; 8 | import org.immutables.value.Value.Check; 9 | 10 | /** Immutables implementation of {@link Camera}. */ 11 | @Value.Immutable 12 | @JavaStyle 13 | public interface Camera { 14 | 15 | Vector getLocation(); 16 | 17 | double getFieldOfVisionDegrees(); 18 | 19 | @Check 20 | default void checkPreconditions() { 21 | double fieldOfVisionDegrees = getFieldOfVisionDegrees(); 22 | checkArgument(0 < fieldOfVisionDegrees && fieldOfVisionDegrees < 180, 23 | "Field of vision must be between 1 and 179 degrees, but was: %f", fieldOfVisionDegrees); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/main/java/me/kahlil/geometry/PointObject.java: -------------------------------------------------------------------------------- 1 | package me.kahlil.geometry; 2 | 3 | import static me.kahlil.geometry.Constants.EPSILON; 4 | import static me.kahlil.scene.Materials.DUMMY_MATERIAL; 5 | 6 | import java.util.Optional; 7 | 8 | /** 9 | * Represents an object which is a single point. Only used for testing various properties of objects 10 | * like rotations. Should not actually be used when rendering scenes. 11 | */ 12 | public class PointObject extends Shape { 13 | 14 | private final Sphere pointSphere; 15 | 16 | PointObject(double x, double y, double z) { 17 | this.pointSphere = new Sphere(new Vector(x, y, z), EPSILON, DUMMY_MATERIAL); 18 | } 19 | 20 | @Override 21 | protected Optional internalIntersectInObjectSpace(Ray ray) { 22 | return pointSphere.intersectInObjectSpace(ray); 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /src/test/java/me/kahlil/geometry/DoubleHelperTest.java: -------------------------------------------------------------------------------- 1 | package me.kahlil.geometry; 2 | 3 | import static com.google.common.truth.Truth.assertThat; 4 | import static me.kahlil.geometry.Constants.EPSILON; 5 | import static me.kahlil.geometry.DoubleHelper.nearEquals; 6 | 7 | import org.junit.Test; 8 | import org.junit.runner.RunWith; 9 | import org.junit.runners.JUnit4; 10 | 11 | /** 12 | * Unit tests for {@link DoubleHelper}. 13 | */ 14 | @RunWith(JUnit4.class) 15 | public class DoubleHelperTest { 16 | 17 | @Test 18 | public void basicChecks() { 19 | // Check just within bounds. 20 | assertThat(nearEquals(1.0, 1.0 + (0.9 * EPSILON))).isTrue(); 21 | assertThat(nearEquals(1.0, 1.0 - (0.9 * EPSILON))).isTrue(); 22 | // Check just outside of bounds. 23 | assertThat(nearEquals(1.0, 1.0 + (1.1 * EPSILON))).isFalse(); 24 | assertThat(nearEquals(1.0, 1.0 - (1.1 * EPSILON))).isFalse(); 25 | } 26 | 27 | } -------------------------------------------------------------------------------- /src/main/java/me/kahlil/graphics/RayTracerWorker.java: -------------------------------------------------------------------------------- 1 | package me.kahlil.graphics; 2 | 3 | import me.kahlil.graphics.MutableColor; 4 | import me.kahlil.scene.Raster; 5 | 6 | public class RayTracerWorker implements Runnable { 7 | 8 | private final RayTracer rayTracer; 9 | private final Raster frame; 10 | private final int startingPixel; 11 | private final int pixelIncrement; 12 | 13 | RayTracerWorker( 14 | RayTracer rayTracer, 15 | Raster frame, 16 | int startingPixel, 17 | int pixelIncrement) { 18 | this.rayTracer = rayTracer; 19 | this.frame = frame; 20 | this.startingPixel = startingPixel; 21 | this.pixelIncrement = pixelIncrement; 22 | } 23 | 24 | @Override 25 | public void run() { 26 | for (int i = 0; i < frame.getHeightPx(); ++i) { 27 | for (int j = startingPixel; j < frame.getWidthPx(); j += pixelIncrement) { 28 | MutableColor color = rayTracer.traceRay(i, j); 29 | frame.setPixel(i, j, color); 30 | } 31 | } 32 | System.out.println("Thread " + startingPixel + " finished!"); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/main/java/me/kahlil/scene/Cameras.java: -------------------------------------------------------------------------------- 1 | package me.kahlil.scene; 2 | 3 | import me.kahlil.geometry.Vector; 4 | 5 | /** A static helper class which contains useful camera constants. */ 6 | public final class Cameras { 7 | 8 | /** 9 | * The standard camera centered at the origin (0, 0, 0) which should be used for all ray tracing 10 | * as described at: 11 | * 12 | *

https://www.scratchapixel.com/lessons/3d-basic-rendering/ray-tracing-generating-camera-rays/generating-camera-rays 13 | */ 14 | public static final Camera STANDARD_CAMERA = 15 | ImmutableCamera.builder() 16 | .setLocation(new Vector(0, 0, 0)) 17 | .setFieldOfVisionDegrees(60) 18 | .build(); 19 | 20 | /** 21 | * A camera centered at the origin (0, 0, 0) but with a 90 degree FOV, which is useful for testing 22 | * because the FOV effect (tan(FOV / 2)) is 1, and does not affect the coordinate computation. 23 | */ 24 | public static final Camera NINETY_DEGREE_FOV = 25 | ImmutableCamera.builder() 26 | .setLocation(new Vector(0, 0, 0)) 27 | .setFieldOfVisionDegrees(90) 28 | .build(); 29 | } 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Kahlil Oppenheimer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /src/main/java/me/kahlil/config/Parameters.java: -------------------------------------------------------------------------------- 1 | package me.kahlil.config; 2 | 3 | public final class Parameters { 4 | 5 | // File location for demo images. 6 | public static final String IMAGES_DEMO_PNG_PATH = "images/tmp/demo.png"; 7 | 8 | // Output image pixel height/width (only square images for now). 9 | public static final int IMAGE_SIZE = 500; 10 | 11 | // Number of rays to sample for anti aliasing. 12 | public static final int NUM_ANTI_ALIASING_SAMPLES = 1; 13 | 14 | // Whether or not shadows are enabled. 15 | public static final boolean SHADOWS_ENABLED = true; 16 | 17 | // Maximum ray depth for reflections. 18 | public static final int MAX_RAY_DEPTH = 1; 19 | 20 | // Number of threads to use during computation. 21 | // public static final int NUM_THREADS = Runtime.getRuntime().availableProcessors(); 22 | public static final int NUM_THREADS = 1; 23 | 24 | // Maximum number of shapes that can occur on a leaf. 25 | public static final int OCTREE_MAX_SHAPES_PER_LEAF = 50; 26 | 27 | // Maximum depth of the Octree. 28 | public static final int OCTREE_MAX_DEPTH = 10; 29 | 30 | public static final boolean OCTREE_ENABLED = true; 31 | } 32 | -------------------------------------------------------------------------------- /src/test/java/me/kahlil/scene/RasterTest.java: -------------------------------------------------------------------------------- 1 | package me.kahlil.scene; 2 | 3 | import static com.google.common.truth.Truth.assertThat; 4 | 5 | import me.kahlil.graphics.MutableColor; 6 | import org.junit.Test; 7 | import org.junit.runner.RunWith; 8 | import org.junit.runners.JUnit4; 9 | 10 | /** 11 | * Unit tests for {@link Raster}. 12 | */ 13 | @RunWith(JUnit4.class) 14 | public class RasterTest { 15 | 16 | @Test 17 | public void pixelSizesAreCorrect() { 18 | Raster raster = new Raster(15, 10); 19 | assertThat(raster.getWidthPx()).isEqualTo(15); 20 | assertThat(raster.getHeightPx()).isEqualTo(10); 21 | } 22 | 23 | @Test 24 | public void pixelSetAndGetFunctionsAsExpected() { 25 | Raster raster = new Raster(15, 10); 26 | for (int i = 0; i < raster.getHeightPx(); i++) { 27 | for (int j = 0; j < raster.getWidthPx(); j++) { 28 | float colorVal = (i + j) / (1.0f * raster.getHeightPx() * raster.getWidthPx()); 29 | raster.setPixel(i, j, new MutableColor(colorVal, colorVal, colorVal)); 30 | assertThat(raster.getPixel(i, j)) 31 | .isEqualTo(new MutableColor(colorVal, colorVal, colorVal)); 32 | } 33 | } 34 | } 35 | 36 | } -------------------------------------------------------------------------------- /src/main/java/me/kahlil/geometry/RayHit.java: -------------------------------------------------------------------------------- 1 | package me.kahlil.geometry; 2 | 3 | import me.kahlil.config.JavaStyle; 4 | import me.kahlil.scene.Material; 5 | import org.immutables.value.Value.Derived; 6 | import org.immutables.value.Value.Immutable; 7 | 8 | /** Represents the intersection of a ray with an object. */ 9 | @Immutable 10 | @JavaStyle 11 | public abstract class RayHit { 12 | 13 | // Ray involved in the collision. 14 | public abstract Ray getRay(); 15 | 16 | // Time at which the ray intersected the object. 17 | public abstract double getTime(); 18 | 19 | // Object that the ray intersects. 20 | public abstract Intersectable getObject(); 21 | 22 | // Material at point of intersection. 23 | public abstract Material getMaterial(); 24 | 25 | // Normal at the point of intersection. 26 | public abstract Vector getNormal(); 27 | 28 | // Point at which the ray first intersects the object. 29 | @Derived 30 | public Vector getIntersection() { 31 | return getRay().atTime(getTime()); 32 | } 33 | 34 | // Distance along ray to the first intersection. 35 | @Derived 36 | public double getDistance() { 37 | return getIntersection().subtract(getRay().getStart()).magnitude(); 38 | } 39 | 40 | } 41 | -------------------------------------------------------------------------------- /src/test/java/me/kahlil/graphics/MutableColorComputationTest.java: -------------------------------------------------------------------------------- 1 | package me.kahlil.graphics; 2 | 3 | import static com.google.common.truth.Truth.assertThat; 4 | 5 | import me.kahlil.graphics.MutableColor; 6 | import org.junit.Test; 7 | import org.junit.runner.RunWith; 8 | import org.junit.runners.JUnit4; 9 | 10 | /** Unit tests for {@link ColorComputation}. */ 11 | @RunWith(JUnit4.class) 12 | public class MutableColorComputationTest { 13 | 14 | @Test 15 | public void testAddition() { 16 | assertThat(ColorComputation.of(new MutableColor(1, 2, 3)).add(new MutableColor(3, 2, 1)).compute()) 17 | .isEqualTo(new MutableColor(4, 4, 4)); 18 | } 19 | 20 | @Test 21 | public void testScaling() { 22 | assertThat(ColorComputation.of(new MutableColor(0.1f, 0.2f, 0.3f)).scaleFloat(2.0f).compute()) 23 | .isEqualTo(new MutableColor(2.0f * 0.1f, 2.0f * 0.2f, 2.0f * 0.3f)); 24 | } 25 | 26 | @Test 27 | public void testMultiplication() { 28 | assertThat( 29 | ColorComputation.of(new MutableColor(0.2f, 0.3f, 0.4f)) 30 | .multiply(new MutableColor(0.5f, 0.6f, 0.7f)) 31 | .compute()) 32 | .isEqualTo(new MutableColor(0.2f * 0.5f, 0.3f * 0.6f, 0.4f * 0.7f)); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Java Ray Tracer 2 | This is my implementation of a Ray rayTracer built from scratch in Java. Below are a list of the objects it can currently render, a list of its current features, and a list of the features that are in the works. 3 | 4 | Here is a sample image the current version rendered: 5 | 6 | ![Sample Image](https://github.com/kahliloppenheimer/Java-Ray-Tracer/blob/master/images/polygons/demo-10.png?raw=true) 7 | 8 | ## How to run 9 | Run: 10 | 11 | ``` 12 | mvn clean package 13 | 14 | ``` 15 | Then execute `demo.java` and take a look at images/tmp. 16 | 17 | ## Currently Supported Shapes 18 | - Spheres 19 | - Planes 20 | - Triangles 21 | - Arbitrary Polygons Meshes 22 | 23 | ## Implemented Features 24 | - Anti-aliasing 25 | - Shadows 26 | - Diffuse, Specular, and Ambient lighting (Phong model) 27 | - Reflections 28 | - Linear transformations of objects 29 | - Multi-threaded rendering 30 | - Octree bounding hierarchical volume acceleration structure 31 | 32 | ## Developing Features 33 | - Custom textures 34 | - Dynamic/soft shadows (sample randomly-distributed rays to determine shading) 35 | - Global illumination 36 | - Refractions 37 | - Reading standard polygon mesh file formats 38 | - Optimizations (reducing # ray-triangle intersection tests) 39 | -------------------------------------------------------------------------------- /src/main/java/me/kahlil/graphics/RandomAntiAliasingMethod.java: -------------------------------------------------------------------------------- 1 | package me.kahlil.graphics; 2 | 3 | import java.util.Random; 4 | import me.kahlil.geometry.Ray; 5 | 6 | public final class RandomAntiAliasingMethod implements AntiAliasingMethod { 7 | 8 | private static final ThreadLocal RAND = ThreadLocal.withInitial(Random::new); 9 | private final int numSamples; 10 | 11 | public RandomAntiAliasingMethod(int numSamples) { 12 | this.numSamples = numSamples; 13 | } 14 | 15 | @Override 16 | public Ray[] getRaysToSample(Ray ray, SamplingRadius samplingRadius) { 17 | Ray[] raysToSample = new Ray[numSamples]; 18 | for (int i = 0; i < raysToSample.length; i++) { 19 | raysToSample[i] = 20 | new Ray( 21 | ray.getStart(), 22 | ray.getDirection() 23 | .translate( 24 | RAND.get().nextDouble() * samplingRadius.getWidth() * negativeOrPositive(), 25 | RAND.get().nextDouble() * samplingRadius.getHeight() * negativeOrPositive())); 26 | } 27 | return raysToSample; 28 | } 29 | 30 | /** Returns either 1 or -1 with a 50% chance for both. */ 31 | private static int negativeOrPositive() { 32 | return RAND.get().nextBoolean() ? -1 : 1; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/main/java/me/kahlil/scene/Materials.java: -------------------------------------------------------------------------------- 1 | package me.kahlil.scene; 2 | 3 | import static me.kahlil.graphics.Colors.BLACK; 4 | import static me.kahlil.graphics.Colors.GREEN; 5 | 6 | public final class Materials { 7 | 8 | private Materials() { } 9 | 10 | public static Material REFLECTIVE = ImmutableMaterial.builder() 11 | .setColor(BLACK) 12 | .setReflectiveness(1.0) 13 | .setHardness(250) 14 | .setSpecularIntensity(1.0) 15 | .build(); 16 | 17 | public static Material DUMMY_MATERIAL = ImmutableMaterial.builder() 18 | .setColor(GREEN) 19 | .setHardness(200) 20 | .setSpecularIntensity(0.5f) 21 | .setReflectiveness(0.0) 22 | .build(); 23 | 24 | /** 25 | * Returns an glossy material builder with no color specified. 26 | */ 27 | public static ImmutableMaterial.Builder glossy() { 28 | return ImmutableMaterial.builder() 29 | .setHardness(10) 30 | .setSpecularIntensity(0.4) 31 | .setReflectiveness(0.0); 32 | } 33 | 34 | /** 35 | * Returns a shiny material builder with no color specified. 36 | */ 37 | public static ImmutableMaterial.Builder shiny() { 38 | return ImmutableMaterial.builder() 39 | .setHardness(250) 40 | .setSpecularIntensity(1.0) 41 | .setReflectiveness(0.55); 42 | } 43 | 44 | } 45 | -------------------------------------------------------------------------------- /src/main/java/me/kahlil/geometry/BoundingBox.java: -------------------------------------------------------------------------------- 1 | package me.kahlil.geometry; 2 | 3 | import static java.lang.Double.NEGATIVE_INFINITY; 4 | import static java.lang.Double.POSITIVE_INFINITY; 5 | import static java.lang.Math.max; 6 | import static java.lang.Math.min; 7 | 8 | /** 9 | * Intersection borrowed from: 10 | * https://tavianator.com/fast-branchless-raybounding-box-intersections-part-2-nans/ 11 | * */ 12 | public final class BoundingBox implements BoundingVolume { 13 | 14 | private final Vector minBound; 15 | private final Vector maxBound; 16 | 17 | public BoundingBox(Vector minBound, Vector maxBound) { 18 | this.minBound = minBound; 19 | this.maxBound = maxBound; 20 | } 21 | 22 | @Override 23 | public double intersectWithBoundingVolume(Ray ray) { 24 | double tmin = NEGATIVE_INFINITY, tmax = POSITIVE_INFINITY; 25 | 26 | for (int i = 0; i < 3; ++i) { 27 | double t1 = (minBound.getComponent(i) - ray.getStart().getComponent(i)) * ray.getInvertedDirection().getComponent(i); 28 | double t2 = (maxBound.getComponent(i) - ray.getStart().getComponent(i)) * ray.getInvertedDirection().getComponent(i); 29 | 30 | tmin = max(tmin, min(t1, t2)); 31 | tmax = min(tmax, max(t1, t2)); 32 | } 33 | if (tmax < max(tmin, 0.0)) { 34 | return -1; 35 | } 36 | return tmin; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/main/java/me/kahlil/octree/Octree.java: -------------------------------------------------------------------------------- 1 | package me.kahlil.octree; 2 | 3 | import static me.kahlil.octree.BoundsHelper.computeGlobalMinAndMax; 4 | 5 | import com.google.common.annotations.VisibleForTesting; 6 | import java.util.Optional; 7 | import me.kahlil.geometry.Extents; 8 | import me.kahlil.geometry.Intersectable; 9 | import me.kahlil.geometry.Polygon; 10 | import me.kahlil.geometry.Ray; 11 | import me.kahlil.geometry.RayHit; 12 | import me.kahlil.geometry.Vector; 13 | 14 | /** 15 | * Implementation of an Octree. 16 | */ 17 | public class Octree implements Intersectable { 18 | 19 | @VisibleForTesting 20 | final OctreeNode root; 21 | final int maxObjectsPerLeaf; 22 | final int maxDepth; 23 | final Extents extents; 24 | 25 | public Octree( 26 | T[] shapes, 27 | int maxObjectsPerLeaf, 28 | int maxDepth) { 29 | this.maxObjectsPerLeaf = maxObjectsPerLeaf; 30 | this.maxDepth = maxDepth; 31 | Vector[] minAndMax = computeGlobalMinAndMax(shapes); 32 | this.root = new OctreeNode<>(shapes, maxObjectsPerLeaf, maxDepth, minAndMax[0], minAndMax[1], 0); 33 | 34 | for (int i = 0; i < shapes.length; i++) { 35 | root.insert(i); 36 | } 37 | 38 | this.extents = root.computeExtents(); 39 | } 40 | 41 | @Override 42 | public Optional intersectWith(Ray ray) { 43 | return root.intersectWith(ray); 44 | } 45 | 46 | } 47 | -------------------------------------------------------------------------------- /src/main/java/me/kahlil/scene/Raster.java: -------------------------------------------------------------------------------- 1 | package me.kahlil.scene; 2 | 3 | import static com.google.common.base.Preconditions.checkState; 4 | 5 | import me.kahlil.graphics.MutableColor; 6 | import me.kahlil.geometry.Vector; 7 | 8 | /** 9 | * Represents the frame that the 3d scene is projected onto. This frame implementation is simply 10 | * parallel to the XY plane. 11 | */ 12 | public class Raster { 13 | private final Vector bottomLeftCorner; 14 | // Number of pixels for width in height of the frame 15 | private final int widthPx; 16 | private final int heightPx; 17 | private MutableColor[][] pixels; 18 | 19 | public Raster(int widthPx, int heightPx) { 20 | this.bottomLeftCorner = new Vector(-1, -1, -1); 21 | this.widthPx = widthPx; 22 | this.heightPx = heightPx; 23 | this.pixels = new MutableColor[heightPx][widthPx]; 24 | } 25 | 26 | /** Returns the pixel at the specified coordinate */ 27 | public MutableColor getPixel(int i, int j) { 28 | return pixels[i][j]; 29 | } 30 | 31 | /** Sets the pixel at the specified coorindate */ 32 | public void setPixel(int i, int j, MutableColor c) { 33 | checkState(pixels[i][j] == null, "Same pixel should not be modified twice: (%d, %d)", i, j); 34 | pixels[i][j] = c; 35 | } 36 | 37 | public int getWidthPx() { 38 | return widthPx; 39 | } 40 | 41 | public int getHeightPx() { 42 | return heightPx; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/main/java/me/kahlil/octree/BoundsHelper.java: -------------------------------------------------------------------------------- 1 | package me.kahlil.octree; 2 | 3 | import static com.google.common.base.Preconditions.checkState; 4 | import static java.lang.Float.NEGATIVE_INFINITY; 5 | import static java.lang.Float.POSITIVE_INFINITY; 6 | 7 | import java.util.Arrays; 8 | import me.kahlil.geometry.Polygon; 9 | import me.kahlil.geometry.Vector; 10 | 11 | public final class BoundsHelper { 12 | 13 | /** 14 | * Returns a 2-sized array containing the minimum and maximum bounds of all of the given shapes. 15 | */ 16 | public static Vector[] computeGlobalMinAndMax(Polygon[] polygons) { 17 | checkState(polygons.length > 0); 18 | 19 | double[] minXyz = new double[3]; 20 | Arrays.fill(minXyz, POSITIVE_INFINITY); 21 | double[] maxXyz = new double[3]; 22 | Arrays.fill(maxXyz, NEGATIVE_INFINITY); 23 | 24 | for (Polygon polygon : polygons) { 25 | Vector min = polygon.minBound(); 26 | Vector max = polygon.maxBound(); 27 | for (int i = 0; i < 3; i++) { 28 | if (min.getComponent(i) < minXyz[i]) { 29 | minXyz[i] = min.getComponent(i); 30 | } 31 | if (max.getComponent(i) > maxXyz[i]) { 32 | maxXyz[i] = max.getComponent(i); 33 | } 34 | } 35 | } 36 | 37 | return new Vector[]{ 38 | new Vector(minXyz[0], minXyz[1], minXyz[2]), 39 | new Vector(maxXyz[0], maxXyz[1], maxXyz[2]) 40 | }; 41 | } 42 | 43 | } 44 | -------------------------------------------------------------------------------- /src/test/java/me/kahlil/geometry/PlaneTest.java: -------------------------------------------------------------------------------- 1 | package me.kahlil.geometry; 2 | 3 | import static com.google.common.truth.Truth8.assertThat; 4 | import static com.google.common.truth.Truth.assertThat; 5 | import static me.kahlil.scene.Materials.DUMMY_MATERIAL; 6 | 7 | import java.util.Optional; 8 | import org.junit.Test; 9 | import org.junit.runner.RunWith; 10 | import org.junit.runners.JUnit4; 11 | 12 | @RunWith(JUnit4.class) 13 | public class PlaneTest { 14 | 15 | private static final Plane xzPlane = 16 | new Plane(new Vector(0, 0, 0), new Vector(0, 1, 0), DUMMY_MATERIAL); 17 | 18 | @Test 19 | public void testPerfectlyNormalIntersection() { 20 | Ray directlyAbove = new Ray(new Vector(0, 1, 0), new Vector(0, -1, 0)); 21 | Optional rayHit = xzPlane.intersectWith(directlyAbove); 22 | 23 | assertThat(rayHit).isPresent(); 24 | RayHit expected = 25 | ImmutableRayHit.builder() 26 | .setObject(xzPlane) 27 | .setMaterial(DUMMY_MATERIAL) 28 | .setNormal(new Vector(0, 1, 0)) 29 | .setRay(directlyAbove) 30 | .setTime(1) 31 | .build(); 32 | assertThat(rayHit.get()).isEqualTo(expected); 33 | } 34 | 35 | @Test 36 | public void testPerfectlyParallelIntersection() { 37 | Ray parallel = new Ray(new Vector(0, 0, 0), new Vector(1, 0, 1)); 38 | Optional rayHit = xzPlane.intersectWith(parallel); 39 | assertThat(rayHit).isEmpty(); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/main/java/me/kahlil/graphics/RayIntersections.java: -------------------------------------------------------------------------------- 1 | package me.kahlil.graphics; 2 | 3 | import static com.google.common.collect.ImmutableList.toImmutableList; 4 | 5 | import com.google.common.collect.ImmutableList; 6 | import com.google.common.collect.Streams; 7 | import com.google.common.primitives.Doubles; 8 | import java.util.Optional; 9 | import me.kahlil.geometry.LightSphere; 10 | import me.kahlil.geometry.Ray; 11 | import me.kahlil.geometry.RayHit; 12 | import me.kahlil.scene.Scene; 13 | 14 | /** Static helper class for determining ray intersections with a given scene. */ 15 | final class RayIntersections { 16 | 17 | private RayIntersections() {} 18 | 19 | /** 20 | * Returns the RayHit with the lowest distance from the visionVector to each obj in the scene. 21 | * Returns optional.empty() if no object is hit. 22 | */ 23 | static Optional findFirstIntersection(Ray visionVector, Scene scene) { 24 | return findAllIntersections(visionVector, scene).stream() 25 | .min((rayHit1, rayHit2) -> Doubles.compare(rayHit1.getDistance(), rayHit2.getDistance())); 26 | } 27 | 28 | /** Returns all intersections the given ray has with the objects in the scene. */ 29 | static ImmutableList findAllIntersections(Ray visionVector, Scene scene) { 30 | return Streams.concat( 31 | scene.getShapes().stream(), scene.getLights().stream().map(LightSphere::new)) 32 | .map(object -> object.intersectWith(visionVector)).collect(toImmutableList()).stream() 33 | .flatMap(Optional::stream) 34 | .collect(toImmutableList()); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/main/java/me/kahlil/graphics/RayTracer.java: -------------------------------------------------------------------------------- 1 | package me.kahlil.graphics; 2 | 3 | import static me.kahlil.config.Counters.NUM_PRIMARY_RAYS; 4 | import static me.kahlil.graphics.CoordinateMapper.convertPixelToCameraSpaceCoordinates; 5 | 6 | import me.kahlil.graphics.MutableColor; 7 | import me.kahlil.geometry.Ray; 8 | import me.kahlil.geometry.Vector; 9 | import me.kahlil.scene.Camera; 10 | import me.kahlil.scene.Raster; 11 | 12 | /** 13 | * An object which traces a single ray and returns the corresponding color that should be rendered, 14 | * as defined by the ray tracing algorithm. 15 | */ 16 | public abstract class RayTracer { 17 | 18 | private final Camera camera; 19 | private final Raster raster; 20 | 21 | RayTracer(Raster raster, Camera camera) { 22 | this.camera = camera; 23 | this.raster = raster; 24 | } 25 | 26 | /** 27 | * Traces the given ray, returning the corresponding color. Note, this is called with a ray that 28 | * points to the middle of a given pixel during the main ray tracing algorithm. 29 | */ 30 | abstract MutableColor traceRay(Ray ray); 31 | 32 | /** Traces a ray through ith and jth pixel, returning a color for that pixel. */ 33 | final MutableColor traceRay(int i, int j) { 34 | NUM_PRIMARY_RAYS.getAndIncrement(); 35 | Point2D inCameraSpace = convertPixelToCameraSpaceCoordinates(raster, camera, i, j); 36 | return traceRay( 37 | new Ray( 38 | camera.getLocation(), 39 | new Vector(inCameraSpace.getX(), inCameraSpace.getY(), -1.0) 40 | .subtract(camera.getLocation()))); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/main/java/me/kahlil/geometry/Plane.java: -------------------------------------------------------------------------------- 1 | package me.kahlil.geometry; 2 | 3 | import static me.kahlil.geometry.Constants.EPSILON; 4 | 5 | import java.util.Optional; 6 | import me.kahlil.scene.Material; 7 | 8 | /** 9 | * Representation of a plane in 3-dimensional space. 10 | * 11 | *

All planes initially pass through the origin, but may be transformed. 12 | */ 13 | public class Plane extends Shape { 14 | private final Vector normal; 15 | private final Vector point; 16 | private final Material material; 17 | 18 | public Plane(Vector point, Vector normal, Material front) { 19 | this.normal = normal.normalize(); 20 | this.point = point; 21 | this.material = front; 22 | } 23 | 24 | @Override 25 | protected Optional internalIntersectInObjectSpace(Ray ray) { 26 | 27 | double denominator = ray.getDirection().dot(normal); 28 | if (Math.abs(denominator) < EPSILON) { 29 | return Optional.empty(); 30 | } 31 | 32 | double time = (point.subtract(ray.getStart())).dot(normal) / denominator; 33 | if (time < 0.0) { 34 | return Optional.empty(); 35 | } 36 | 37 | // Check if the ray hit the back of the plane, and we need to invert the normal. 38 | boolean hitBackOfPlane = denominator > 0; 39 | Vector adjustedNormal = hitBackOfPlane ? this.normal.scale(-1) : this.normal; 40 | 41 | return Optional.of( 42 | ImmutableRayHit.builder() 43 | .setRay(ray) 44 | .setTime(time) 45 | .setNormal(adjustedNormal) 46 | .setObject(this) 47 | .setMaterial(material) 48 | .build()); 49 | } 50 | 51 | } 52 | -------------------------------------------------------------------------------- /src/main/java/me/kahlil/graphics/MutableColor.java: -------------------------------------------------------------------------------- 1 | package me.kahlil.graphics; 2 | 3 | import java.awt.Color; 4 | import java.util.Arrays; 5 | 6 | /** 7 | * MutableColor representation that exposes a mutable float array to optimize performance. 8 | * 9 | * Computation around color can happen in-place, rather than through constructing unnecessary 10 | * copies of a MutableColor object for each intermediate step. This would not be possible with the native 11 | * Java color object. 12 | */ 13 | public class MutableColor implements Cloneable { 14 | 15 | private final float[] rgb; 16 | 17 | public MutableColor(float red, float green, float blue) { 18 | this(new float[]{red, green, blue}); 19 | } 20 | 21 | public MutableColor(float[] rgb) { 22 | this.rgb = rgb; 23 | } 24 | 25 | public MutableColor(int red, int green, int blue) { 26 | this(new float[]{red / 255f, green / 255f, blue / 255f}); 27 | } 28 | 29 | public MutableColor(int[] rgb) { 30 | this(rgb[0], rgb[1], rgb[2]); 31 | } 32 | 33 | /** 34 | * Returns a mutable reference to the RGB values of this color. 35 | */ 36 | public float[] getRgb() { 37 | return this.rgb; 38 | } 39 | 40 | public void setRgb(float red, float green, float blue) { 41 | this.rgb[0] = red; 42 | this.rgb[1] = green; 43 | this.rgb[2] = blue; 44 | } 45 | 46 | public Color toColor() { 47 | return new Color(this.rgb[0], this.rgb[1], this.rgb[2]); 48 | } 49 | 50 | @Override 51 | public boolean equals(Object o) { 52 | if (this == o) { 53 | return true; 54 | } 55 | if (o == null || getClass() != o.getClass()) { 56 | return false; 57 | } 58 | MutableColor that = (MutableColor) o; 59 | return Arrays.equals(rgb, that.rgb); 60 | } 61 | 62 | @Override 63 | public int hashCode() { 64 | return Arrays.hashCode(rgb); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/test/java/me/kahlil/graphics/PhongShadingTest.java: -------------------------------------------------------------------------------- 1 | package me.kahlil.graphics; 2 | 3 | import static com.google.common.truth.Truth.assertThat; 4 | import static me.kahlil.geometry.Constants.ORIGIN; 5 | import static me.kahlil.graphics.Colors.RED; 6 | import static me.kahlil.scene.Materials.DUMMY_MATERIAL; 7 | 8 | import me.kahlil.geometry.ImmutableRayHit; 9 | import me.kahlil.geometry.Ray; 10 | import me.kahlil.geometry.RayHit; 11 | import me.kahlil.geometry.Sphere; 12 | import me.kahlil.geometry.Vector; 13 | import me.kahlil.scene.ImmutablePointLight; 14 | import me.kahlil.scene.PointLight; 15 | import org.junit.Test; 16 | import org.junit.runner.RunWith; 17 | import org.junit.runners.JUnit4; 18 | 19 | /** Unit tests for {@link me.kahlil.graphics.PhongShading}. */ 20 | @RunWith(JUnit4.class) 21 | public class PhongShadingTest { 22 | 23 | private static final Sphere DUMMY_SPHERE = new Sphere(DUMMY_MATERIAL); 24 | 25 | /** 26 | * When computing the diffuse light at a point, it is possible that the dot product of the surface 27 | * normal and the light vector is negative. This means that the light should have no effect at 28 | * this angle, and thus the diffuse lighting should be zero. 29 | */ 30 | @Test 31 | public void diffuseLightingIsZeroWhenSurfaceNormalDotLightIsNegative() { 32 | PointLight light = 33 | ImmutablePointLight.builder().setLocation(ORIGIN).setColor(RED).build(); 34 | RayHit rayHit = 35 | ImmutableRayHit.builder() 36 | // Unused for this test but required by builder to be set 37 | .setMaterial(DUMMY_MATERIAL) 38 | .setObject(DUMMY_SPHERE) 39 | .setTime(1) 40 | .setRay(new Ray(new Vector(0, 0, 0), new Vector(1, 0, 0))) 41 | // Relevant attributes for this test 42 | .setNormal(new Vector(1, 0, 0)) 43 | .build(); 44 | 45 | assertThat(PhongShading.diffuse(light, rayHit)).isZero(); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/main/java/me/kahlil/scene/Material.java: -------------------------------------------------------------------------------- 1 | package me.kahlil.scene; 2 | 3 | import static com.google.common.base.Preconditions.checkArgument; 4 | 5 | import me.kahlil.config.JavaStyle; 6 | import me.kahlil.graphics.MutableColor; 7 | import org.immutables.value.Value.Check; 8 | import org.immutables.value.Value.Default; 9 | import org.immutables.value.Value.Immutable; 10 | 11 | /** A representation of the material of a given shape. */ 12 | @Immutable 13 | @JavaStyle 14 | public interface Material { 15 | 16 | /** The color of the material. */ 17 | MutableColor getColor(); 18 | 19 | /** 20 | * An integer between 1 (inclusive) and 511 (inclusive) indicating the "hardness" of a meterial, 21 | * which factors into specular lighting computation. 22 | */ 23 | int getHardness(); 24 | 25 | /** 26 | * The specular intensity of a material (i.e. "shiny-ness") which is specified as a double between 27 | * 0.0 (inclusive) and 1.0 (inclusive). 28 | */ 29 | double getSpecularIntensity(); 30 | 31 | /** 32 | * A parameter indicating how reflective a surface is with 1.0 being the highest and 0.0 being 33 | * the lowest. 34 | */ 35 | @Default 36 | default double getReflectiveness() { 37 | return 0.0; 38 | } 39 | 40 | @Check 41 | default void checkPreconditions() { 42 | checkArgument( 43 | getHardness() > 0 && getHardness() < 512, 44 | "Hardness must be an integer between 1 (inclusive) and 511 (inclusive) but was %d", 45 | getHardness()); 46 | checkArgument( 47 | getSpecularIntensity() >= 0.0 && getSpecularIntensity() <= 1.0, 48 | "Specular intensity must be between 0.0 (inclusive) and 1.0 (inclusive) but was %f", 49 | getSpecularIntensity()); 50 | checkArgument(getReflectiveness() >= 0.0 && getReflectiveness() <= 1.0, 51 | "Reflectiveness must be between 0.0 (inclusive) and 1.0 (inclusive) but was %f", 52 | getReflectiveness()); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/main/java/me/kahlil/geometry/Ray.java: -------------------------------------------------------------------------------- 1 | package me.kahlil.geometry; 2 | 3 | /** 4 | * Representation of a ray in 3-dimensional space. 5 | * 6 | *

A ray is simply represented as a Vector of the starting point of the ray, and a vector of the 7 | * direction of the ray. 8 | */ 9 | public class Ray { 10 | private final Vector start; 11 | private final Vector direction; 12 | private final Vector invertedDirection; 13 | 14 | /** 15 | * This represents a 3D ray with a specified start and direction. The direction of a ray is a 16 | * normalized vector. 17 | */ 18 | public Ray(Vector start, Vector direction) { 19 | direction = direction.normalize(); 20 | this.start = new Vector(start.getX(), start.getY(), start.getZ(), 1); 21 | this.direction = new Vector(direction.getX(), direction.getY(), direction.getZ(), 0); 22 | this.invertedDirection = 23 | new Vector(1.0 / direction.getX(), 1.0 / direction.getY(), 1.0 / direction.getZ()); 24 | } 25 | 26 | /** Returns the point along the ray, t units from its origin p */ 27 | Vector atTime(double t) { 28 | return start.add(direction.scale(t)); 29 | } 30 | 31 | /** Returns the value of t at which this.atTime() should yield point. */ 32 | double timeToPoint(Vector point) { 33 | Vector distanceBetween = point.subtract(this.getStart()); 34 | if (Math.abs(this.direction.getX()) > 0.0) { 35 | return distanceBetween.getX() / this.direction.getX(); 36 | } 37 | if (Math.abs(this.direction.getY()) > 0.0) { 38 | return distanceBetween.getY() / this.direction.getY(); 39 | } 40 | if (Math.abs(this.direction.getZ()) > 0.0) { 41 | return distanceBetween.getZ() / this.direction.getZ(); 42 | } 43 | throw new IllegalStateException("This ray has invalid direction vector: " + this.direction); 44 | } 45 | 46 | public Vector getStart() { 47 | return start; 48 | } 49 | 50 | public Vector getDirection() { 51 | return direction; 52 | } 53 | 54 | public Vector getInvertedDirection() { return invertedDirection; } 55 | 56 | public String toString() { 57 | return String.format("start = %s, direction = %s", start, direction); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/main/java/me/kahlil/graphics/ColorComputation.java: -------------------------------------------------------------------------------- 1 | package me.kahlil.graphics; 2 | 3 | import java.util.Arrays; 4 | 5 | public final class ColorComputation { 6 | 7 | private MutableColor color; 8 | 9 | private ColorComputation(MutableColor color) { 10 | this.color = color; 11 | } 12 | 13 | /** 14 | * Creates a new {@link ColorComputation} by copying the input so it is not modified. 15 | */ 16 | public static ColorComputation of(MutableColor color) { 17 | return new ColorComputation(new MutableColor(Arrays.copyOf(color.getRgb(), 3))); 18 | } 19 | 20 | /** 21 | * Creates a new {@link ColorComputation} without copying the input. In other words, the input 22 | * will be modified in-place. 23 | */ 24 | public static ColorComputation modifyingInPlace(MutableColor color) { 25 | return new ColorComputation(color); 26 | } 27 | 28 | /** Multiplies each component of each color by the other */ 29 | public ColorComputation multiply(MutableColor second) { 30 | float[] firstRgb = this.color.getRgb(); 31 | float[] secondRgb = second.getRgb(); 32 | this.color.setRgb( 33 | firstRgb[0] * secondRgb[0], 34 | firstRgb[1] * secondRgb[1], 35 | firstRgb[2] * secondRgb[2]); 36 | return this; 37 | } 38 | 39 | /** 40 | * Returns the color generated by adding each RGB component of both colors. Will bound any results 41 | * to be within 0.0 to 1.0 inclusively. 42 | */ 43 | public ColorComputation add(MutableColor second) { 44 | float[] firstRgb = this.color.getRgb(); 45 | float[] secondRgb = second.getRgb(); 46 | this.color.setRgb( 47 | bound(firstRgb[0] + secondRgb[0]), 48 | bound(firstRgb[1] + secondRgb[1]), 49 | bound(firstRgb[2] + secondRgb[2])); 50 | return this; 51 | } 52 | 53 | public ColorComputation scaleFloat(float scaleFactor) { 54 | float[] rgb = this.color.getRgb(); 55 | this.color.setRgb( 56 | bound(rgb[0] * scaleFactor), bound(rgb[1] * scaleFactor), bound(rgb[2] * scaleFactor)); 57 | return this; 58 | } 59 | 60 | public MutableColor compute() { 61 | return color; 62 | } 63 | 64 | private static float bound(float v) { 65 | return Math.min(Math.max(v, 0f), 1.0f); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/main/java/me/kahlil/graphics/GridAntiAliasingMethod.java: -------------------------------------------------------------------------------- 1 | package me.kahlil.graphics; 2 | 3 | import me.kahlil.geometry.Ray; 4 | 5 | /** 6 | * A method for anti-aliasing by generating a deterministic, uniformly distributed grid of points to 7 | * sample. 8 | */ 9 | class GridAntiAliasingMethod implements AntiAliasingMethod { 10 | 11 | private final int gridSize; 12 | 13 | /** 14 | * Constructs an instance given a gridSize, which determines the number of samples along one 15 | * length of the grid (i.e. the sqrt of the total number of samples that will be taken). 16 | */ 17 | GridAntiAliasingMethod(int gridSize) { 18 | this.gridSize = gridSize; 19 | } 20 | 21 | /** 22 | * Returns rays sampled deterministically and uniformly in a grid produced from a given sampling 23 | * radius. The width/height deltas between points in the grid should be: 24 | * 25 | *

1/1 * 2 * samplingRadius for grid size of 2 (i.e. leftmost and rightmost). 1/2 * 2 * 26 | * samplingRadius for grid size of 3 (i.e. leftmost, center, and rightmost). 1/3 * 2 * 27 | * samplingRadius for grid size of 4 (i.e. leftmost, first-third, second-third, rightmost). ... 28 | * 1/(n - 1) * 2 * samplingRadius for grid size of n. 29 | */ 30 | @Override 31 | public Ray[] getRaysToSample(Ray ray, SamplingRadius samplingRadius) { 32 | if (gridSize == 1) { 33 | return new Ray[] {ray}; 34 | } 35 | // We start in middle of pixel, so offset to lowest values of (x, y) in the grid. 36 | Ray[] samples = new Ray[gridSize * gridSize]; 37 | Ray origin = 38 | new Ray( 39 | ray.getStart(), 40 | ray.getDirection() 41 | .translate(-1.0 * samplingRadius.getWidth(), -1.0 * samplingRadius.getHeight())); 42 | 43 | double heightDelta = (1.0 / (gridSize - 1)) * 2 * samplingRadius.getHeight(); 44 | double widthDelta = (1.0 / (gridSize - 1)) * 2 * samplingRadius.getWidth(); 45 | for (int i = 0; i < gridSize; i++) { 46 | for (int j = 0; j < gridSize; j++) { 47 | // Translate origin's height/width 48 | samples[i * gridSize + j] = 49 | new Ray( 50 | origin.getStart(), 51 | origin.getDirection().translate(j * widthDelta, i * heightDelta)); 52 | } 53 | } 54 | return samples; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/test/java/me/kahlil/geometry/PointObjectTest.java: -------------------------------------------------------------------------------- 1 | package me.kahlil.geometry; 2 | 3 | import static com.google.common.truth.Truth.assertThat; 4 | import static com.google.common.truth.Truth8.assertThat; 5 | import static me.kahlil.geometry.Constants.EPSILON; 6 | import static me.kahlil.geometry.Constants.ORIGIN; 7 | 8 | import org.junit.Test; 9 | import org.junit.runner.RunWith; 10 | import org.junit.runners.JUnit4; 11 | 12 | /** 13 | * Unit tests for {@link PointObject}. 14 | */ 15 | @RunWith(JUnit4.class) 16 | public class PointObjectTest { 17 | 18 | @Test 19 | public void testTrivialIntersection() { 20 | PointObject pointObject = new PointObject(1.0, 0.0, 0.0); 21 | 22 | Ray shouldIntersect = new Ray(ORIGIN, new Vector(1.0, 0.0, 0.0)); 23 | assertThat(pointObject.intersectWith(shouldIntersect).isPresent()).isTrue(); 24 | } 25 | 26 | @Test 27 | public void testIntersectionJustMisses() { 28 | PointObject pointObject = new PointObject(10.0, 10.0, 0.0); 29 | 30 | Ray shouldHit = new Ray(ORIGIN, new Vector(1.0, 1.0, 0.0)); 31 | assertThat(pointObject.intersectWith(shouldHit)).isPresent(); 32 | 33 | Ray shouldMiss = new Ray(ORIGIN, new Vector(1.0, 1.0 + 10 * EPSILON, 0.0)); 34 | assertThat(pointObject.intersectWith(shouldMiss)).isEmpty(); 35 | 36 | shouldMiss = new Ray(ORIGIN, new Vector(1.0, 1.0 - 10 * EPSILON, 0.0)); 37 | assertThat(pointObject.intersectWith(shouldMiss)).isEmpty(); 38 | 39 | shouldMiss = new Ray(ORIGIN, new Vector(1.0 + 10 * EPSILON, 1.0, 0.0)); 40 | assertThat(pointObject.intersectWith(shouldMiss)).isEmpty(); 41 | 42 | shouldMiss = new Ray(ORIGIN, new Vector(1.0 - 10 * EPSILON, 1.0, 0.0)); 43 | assertThat(pointObject.intersectWith(shouldMiss)).isEmpty(); 44 | 45 | shouldMiss = new Ray(ORIGIN, new Vector(1.0, 1.0, 10 * EPSILON)); 46 | assertThat(pointObject.intersectWith(shouldMiss)).isEmpty(); 47 | 48 | shouldMiss = new Ray(ORIGIN, new Vector(1.0, 1.0, -10 * EPSILON)); 49 | assertThat(pointObject.intersectWith(shouldMiss)).isEmpty(); 50 | } 51 | 52 | @Test 53 | public void testNegativeDirection() { 54 | PointObject pointObject = new PointObject(1.0, 0.0, 0.0); 55 | 56 | Ray shouldMiss = new Ray(ORIGIN, new Vector(-1.0, 0.0, 0.0)); 57 | assertThat(pointObject.intersectWith(shouldMiss)).isEmpty(); 58 | } 59 | 60 | } -------------------------------------------------------------------------------- /src/main/java/me/kahlil/graphics/SimpleAntiAliaser.java: -------------------------------------------------------------------------------- 1 | package me.kahlil.graphics; 2 | 3 | import static me.kahlil.graphics.CoordinateMapper.getPixelHeightInCameraSpace; 4 | import static me.kahlil.graphics.CoordinateMapper.getPixelWidthInCameraSpace; 5 | 6 | import me.kahlil.geometry.Ray; 7 | import me.kahlil.scene.Camera; 8 | import me.kahlil.scene.Raster; 9 | 10 | /** 11 | * A simple anti-aliasing implementation of ray tracing that uses a given {@link AntiAliasingMethod} 12 | * to generate a set of rays to sample, and then averages their results together to produce one 13 | * final pixel color. 14 | * 15 | *

No adaptive anti-aliasing or more complicated combination logic is performed. 16 | */ 17 | public final class SimpleAntiAliaser extends RayTracer { 18 | 19 | private final RayTracer rayTracer; 20 | private final AntiAliasingMethod antiAliasingMethod; 21 | private final SamplingRadius samplingRadius; 22 | 23 | private final ThreadLocal numTraces = ThreadLocal.withInitial(() -> 0L); 24 | 25 | public SimpleAntiAliaser( 26 | Raster frame, Camera camera, RayTracer rayTracer, AntiAliasingMethod antiAliasingMethod) { 27 | super(frame, camera); 28 | this.samplingRadius = 29 | ImmutableSamplingRadius.builder() 30 | .setWidth(getPixelWidthInCameraSpace(frame, camera) * 0.5) 31 | .setHeight(getPixelHeightInCameraSpace(frame, camera) * 0.5) 32 | .build(); 33 | this.rayTracer = rayTracer; 34 | this.antiAliasingMethod = antiAliasingMethod; 35 | } 36 | 37 | @Override 38 | MutableColor traceRay(Ray ray) { 39 | Ray[] raysToSample = antiAliasingMethod.getRaysToSample(ray, samplingRadius); 40 | MutableColor[] results = new MutableColor[raysToSample.length]; 41 | 42 | // Trace all the sample rays and count the total number of rays traced. 43 | for (int i = 0; i < raysToSample.length; i++) { 44 | results[i] = rayTracer.traceRay(raysToSample[i]); 45 | } 46 | 47 | float weight = 1.0f / results.length; 48 | ColorComputation runningAverage = ColorComputation.modifyingInPlace(results[0]).scaleFloat(weight); 49 | for (int i = 1; i < results.length; i++) { 50 | runningAverage.add(ColorComputation.modifyingInPlace(results[i]).scaleFloat(weight).compute()); 51 | } 52 | return runningAverage.compute(); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /pom.xml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 4.0.0 6 | 7 | edu.brandeis.cs.155b.RayTracer 8 | RayTracer 9 | 1.0-SNAPSHOT 10 | 11 | 12 | 13 | junit 14 | junit 15 | 4.12 16 | test 17 | 18 | 19 | org.immutables 20 | value 21 | 2.7.3 22 | provided 23 | 24 | 25 | com.google.guava 26 | guava 27 | 27.0.1-jre 28 | 29 | 30 | com.google.truth 31 | truth 32 | 0.42 33 | test 34 | 35 | 36 | com.google.truth.extensions 37 | truth-java8-extension 38 | 0.42 39 | test 40 | 41 | 42 | pl.pragmatists 43 | JUnitParams 44 | 1.1.1 45 | test 46 | 47 | 48 | 49 | 50 | UTF-8 51 | 52 | 53 | 54 | 55 | 56 | org.apache.maven.plugins 57 | maven-compiler-plugin 58 | 3.3 59 | 60 | 11 61 | 11 62 | 63 | 64 | 65 | 66 | 67 | 68 | -------------------------------------------------------------------------------- /src/main/java/me/kahlil/config/Counters.java: -------------------------------------------------------------------------------- 1 | package me.kahlil.config; 2 | 3 | import java.util.concurrent.atomic.AtomicLong; 4 | 5 | /** 6 | * A collection of {@link ThreadLocal} counters used to count interesting things during the ray 7 | * tracing computation. 8 | */ 9 | public final class Counters { 10 | 11 | /** 12 | * Counter of number of primary rays cast into the scene (e.g. one per pixel). 13 | */ 14 | public static final AtomicLong NUM_PRIMARY_RAYS = new AtomicLong(); 15 | 16 | /** 17 | * Counter of number of triangles included in the scene. 18 | */ 19 | public static final AtomicLong NUM_TRIANGLES = new AtomicLong(); 20 | 21 | /** 22 | * Counter of total number of rays traced during the ray tracing algorithm. 23 | */ 24 | public static final AtomicLong NUM_TOTAL_RAYS = new AtomicLong(); 25 | 26 | /** 27 | * Counter of total number of ray-shape intersection tests computed during the ray tracing algorithm. 28 | */ 29 | public static final AtomicLong NUM_INTERSECTION_TESTS = new AtomicLong(); 30 | 31 | /** 32 | * Counter of total number of actual ray-shape intersections found during the ray tracing algorithm. 33 | */ 34 | public static final AtomicLong NUM_INTERSECTIONS = new AtomicLong(); 35 | 36 | /** 37 | * Counter of total number of bounding volume intersection tests. 38 | */ 39 | public static final AtomicLong NUM_BOUNDING_INTERSECTION_TESTS = new AtomicLong(); 40 | 41 | /** 42 | * Counter of total number of bounding volume intersections. 43 | */ 44 | public static final AtomicLong NUM_BOUNDING_INTERSECTIONS = new AtomicLong(); 45 | 46 | /** 47 | * Counter of total number of ray-triangle tests computed during the ray tracing algorithm. 48 | */ 49 | public static final AtomicLong NUM_TRIANGLE_TESTS = new AtomicLong(); 50 | 51 | /** 52 | * Counter of total number of ray-triangle intersections computed during the ray tracing algorithm. 53 | */ 54 | public static final AtomicLong NUM_TRIANGLE_INTERSECTIONS = new AtomicLong(); 55 | 56 | /** 57 | * Counter of the total number of polygons stored in the octree that overlap between cells. 58 | */ 59 | public static final AtomicLong NUM_OCTREE_INTERNAL_INSERTIONS = new AtomicLong(); 60 | 61 | /** 62 | * Counter of the total number of polygons stored in the leaves of the Octree. 63 | */ 64 | public static final AtomicLong NUM_OCTREE_CHILD_INSERTIONS = new AtomicLong(); 65 | } 66 | -------------------------------------------------------------------------------- /src/main/java/me/kahlil/geometry/Sphere.java: -------------------------------------------------------------------------------- 1 | package me.kahlil.geometry; 2 | 3 | import static me.kahlil.geometry.Constants.ORIGIN; 4 | 5 | import java.util.Optional; 6 | import me.kahlil.scene.Material; 7 | 8 | /** 9 | * A representation of a unit sphere in 3D space. 10 | * 11 | *

The unit sphere is centered at the origin with radius 1.0. Any transformations (rotate, scale, 12 | * translate) should be performed using {@link #transform}. 13 | */ 14 | public class Sphere extends Shape { 15 | 16 | private final Vector center; 17 | private final double radius; 18 | // Material of the outside of the sphere 19 | private final Material material; 20 | 21 | public Sphere(Material material) { 22 | this(ORIGIN, 1.0, material); 23 | } 24 | 25 | Sphere(Vector center, double radius, Material material) { 26 | this.center = center; 27 | this.radius = radius; 28 | this.material = material; 29 | } 30 | 31 | @Override 32 | public Optional internalIntersectInObjectSpace(Ray ray) { 33 | // coefficients for the quadratic equation we have to solve to find the intersection 34 | // ax^2 + bx + c = 0 35 | double a = Math.pow(ray.getDirection().magnitude(), 2); 36 | double b = ray.getDirection().scale(2).dot(ray.getStart().subtract(center)); 37 | double c = Math.pow(ray.getStart().subtract(center).magnitude(), 2) - Math.pow(radius, 2); 38 | 39 | double determinant = Math.pow(b, 2) - 4 * a * c; 40 | double timeOfFirstIntersection = -1; 41 | 42 | // Potentially one intersection 43 | if (-.0000000001 <= determinant && determinant <= .000000001) { 44 | timeOfFirstIntersection = -1 * b / (2 * a); 45 | } // Potentially two intersections 46 | else if (determinant > 0) { 47 | double t1 = (-1 * b - Math.sqrt(determinant)) / (2 * a); 48 | double t2 = (-1 * b + Math.sqrt(determinant)) / (2 * a); 49 | timeOfFirstIntersection = t1 > 0 && t2 > 0 ? t1 : t2; 50 | } 51 | 52 | if (timeOfFirstIntersection > 0) { 53 | Vector intersection = ray.atTime(timeOfFirstIntersection); 54 | Vector normal = intersection.subtract(center).normalize(); 55 | return Optional.of( 56 | ImmutableRayHit.builder() 57 | .setRay(ray) 58 | .setTime(timeOfFirstIntersection) 59 | .setNormal(normal) 60 | .setObject(this) 61 | .setMaterial(material) 62 | .build()); 63 | } else { 64 | return Optional.empty(); 65 | } 66 | } 67 | 68 | } 69 | -------------------------------------------------------------------------------- /src/main/java/me/kahlil/graphics/CoordinateMapper.java: -------------------------------------------------------------------------------- 1 | package me.kahlil.graphics; 2 | 3 | import static java.lang.Math.toRadians; 4 | 5 | import me.kahlil.scene.Camera; 6 | import me.kahlil.scene.Raster; 7 | 8 | final class CoordinateMapper { 9 | 10 | private CoordinateMapper() {} 11 | 12 | /** 13 | * Converts a coordinate expressed by the pixels (i, j) in the raster into an (x, y) coordinate in 14 | * the camera space of the scene. The resultant point is centered in the pixel, transformed with 15 | * image aspect ratio, and transformed to account for field of view (FOV). The math is described 16 | * in full at: 17 | * 18 | *

https://www.scratchapixel.com/lessons/3d-basic-rendering/ray-tracing-generating-camera-rays/generating-camera-rays 19 | */ 20 | static Point2D convertPixelToCameraSpaceCoordinates(Raster frame, Camera camera, int i, int j) { 21 | // Center and transform to Normalized Device Coordinates (i.e. (x, y) in range [0.0, 1.0]). 22 | double x = (j + 0.5) / frame.getWidthPx(); 23 | double y = (i + 0.5) / frame.getHeightPx(); 24 | 25 | // Transform to Screen coordinates (i.e. (x, y) ranges between [-1.0, 1.0]). 26 | x = 2 * x - 1; 27 | // Inverse Y because pixel coordinates use increasing j to denote 28 | // lower placement in frame. 29 | y = 1 - 2 * y; 30 | 31 | // Apply aspect ratio to X because of asymmetry in number of x and y pixels. 32 | double aspectRatio = 1.0 * frame.getWidthPx() / frame.getHeightPx(); 33 | x *= aspectRatio; 34 | 35 | // Apply Field of Vision (FOV) for zoomed in/out effect based on degrees of visiblity. 36 | double fieldOfVisionMultiplier = Math.tan(toRadians(camera.getFieldOfVisionDegrees() * 0.5)); 37 | x *= fieldOfVisionMultiplier; 38 | y *= fieldOfVisionMultiplier; 39 | 40 | return ImmutablePoint2D.builder().setX(x).setY(y).build(); 41 | } 42 | 43 | /** Returns the width of a single pixel in camera space. */ 44 | static double getPixelWidthInCameraSpace(Raster frame, Camera camera) { 45 | return Math.abs(convertPixelToCameraSpaceCoordinates(frame, camera, 0, 1).getX() 46 | - convertPixelToCameraSpaceCoordinates(frame, camera, 0, 0).getX()); 47 | } 48 | 49 | /** Returns the height of a single pixel in camera space. */ 50 | static double getPixelHeightInCameraSpace(Raster frame, Camera camera) { 51 | return Math.abs(convertPixelToCameraSpaceCoordinates(frame, camera, 1, 0).getY() 52 | - convertPixelToCameraSpaceCoordinates(frame, camera, 0, 0).getY()); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/test/java/me/kahlil/geometry/SphereTest.java: -------------------------------------------------------------------------------- 1 | package me.kahlil.geometry; 2 | 3 | import static com.google.common.truth.Truth.assertThat; 4 | import static com.google.common.truth.Truth8.assertThat; 5 | import static me.kahlil.geometry.Constants.EPSILON; 6 | import static me.kahlil.geometry.LinearTransformation.translate; 7 | import static me.kahlil.scene.Materials.DUMMY_MATERIAL; 8 | 9 | import java.util.Optional; 10 | import java.util.Random; 11 | import org.junit.Test; 12 | import org.junit.runner.RunWith; 13 | import org.junit.runners.JUnit4; 14 | 15 | /** Unit tests for {@link Sphere}. */ 16 | @RunWith(JUnit4.class) 17 | public class SphereTest { 18 | 19 | private static final Sphere unitSphere = new Sphere(DUMMY_MATERIAL); 20 | 21 | @Test 22 | public void testRayIntersectFromInside() { 23 | Ray r = new Ray(new Vector(0, 0, 0), new Vector(1, 1, 1)); 24 | Optional rayHit = unitSphere.intersectWith(r); 25 | assertThat(rayHit.isPresent()).isTrue(); 26 | assertThat(rayHit.get().getDistance()).isWithin(EPSILON).of(1); 27 | assertThat(rayHit.get().getIntersection()).isEqualTo(new Vector(1, 1, 1).normalize()); 28 | } 29 | 30 | @Test 31 | public void testRayIntersectOnceFromOutside() { 32 | Ray r = new Ray(new Vector(-1, 1, 0), new Vector(1, 0, 0)); 33 | Optional rayHit = unitSphere.intersectWith(r); 34 | assertThat(rayHit).isPresent(); 35 | assertThat(rayHit.get().getDistance()).isWithin(EPSILON).of(1); 36 | assertThat(rayHit.get().getIntersection()).isEqualTo(new Vector(0, 1, 0)); 37 | } 38 | 39 | @Test 40 | public void testRayIntersectTwiceFromOutside() { 41 | for (int i = 0; i < 100; ++i) { 42 | Vector p = getRandPointBiggerThan(1); 43 | Ray ray = new Ray(p, p.scale(-1)); 44 | Optional rh = unitSphere.intersectWith(ray); 45 | assertThat(rh).isPresent(); 46 | assertThat(rh.get().getDistance()).isLessThan(p.magnitude()); 47 | } 48 | } 49 | 50 | @Test 51 | public void testRayDoesNotIntersectSphere() { 52 | Ray r = new Ray(new Vector(0, -2, 0), new Vector(0, -1, 0)); 53 | Optional rh = unitSphere.intersectWith(r); 54 | assertThat(rh).isEmpty(); 55 | } 56 | 57 | @Test 58 | public void edgeTest() { 59 | Sphere sphere = new Sphere(DUMMY_MATERIAL).transform(translate(1.0, 0.0, -1.0)); 60 | 61 | Ray towardsMiddle = new Ray(new Vector(0, 0, 0), new Vector(1.0, 0.0, -1.0)); 62 | Ray insideEdge = new Ray( 63 | new Vector(0, 0, 0), 64 | new Vector(EPSILON, 0.0, -1.0)); 65 | Ray outsideEdge = new Ray( 66 | new Vector(0, 0, 0), 67 | new Vector(-1 * EPSILON, 0.0, -1.0)); 68 | 69 | assertThat(sphere.intersectWith(towardsMiddle)).isPresent(); 70 | // Check edges 71 | assertThat(sphere.intersectWith(insideEdge)).isPresent(); 72 | assertThat(sphere.intersectWith(outsideEdge)).isEmpty(); 73 | } 74 | 75 | private static Vector getRandPointBiggerThan(int i) { 76 | Random rand = new Random(); 77 | return new Vector(rand.nextInt(100) + i, rand.nextInt(100) + i, rand.nextInt(100) + i); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/test/java/me/kahlil/geometry/MatrixTest.java: -------------------------------------------------------------------------------- 1 | package me.kahlil.geometry; 2 | 3 | import static org.junit.Assert.*; 4 | 5 | import java.util.Random; 6 | import me.kahlil.geometry.Matrix; 7 | import me.kahlil.geometry.Vector; 8 | import org.junit.Before; 9 | import org.junit.Test; 10 | import org.junit.runner.RunWith; 11 | import org.junit.runners.JUnit4; 12 | 13 | /** Unit tests for {@link Matrix}. */ 14 | @RunWith(JUnit4.class) 15 | public class MatrixTest { 16 | 17 | private static final double DELTA = .0000001; 18 | private Matrix identity; 19 | private Matrix zero; 20 | private Random rand; 21 | 22 | @Before 23 | public void setup() { 24 | identity = new Matrix(new double[][] {{1, 0, 0, 0}, {0, 1, 0, 0}, {0, 0, 1, 0}, {0, 0, 0, 1}}); 25 | zero = new Matrix(new double[][] {{0, 0, 0, 0}, {0, 0, 0, 0}, {0, 0, 0, 0}, {0, 0, 0, 0}}); 26 | rand = new Random(); 27 | } 28 | 29 | @Test 30 | public void testGet() throws Exception { 31 | assertEquals(identity.get(0, 0), 1, DELTA); 32 | assertEquals(identity.get(0, 1), 0, DELTA); 33 | assertEquals(identity.get(1, 1), 1, DELTA); 34 | assertEquals(identity.get(2, 0), 0, DELTA); 35 | assertEquals(identity.get(2, 2), 1, DELTA); 36 | } 37 | 38 | @Test 39 | public void testGetRow() throws Exception { 40 | assertEquals(new Vector(1, 0, 0), identity.getRow(0)); 41 | assertEquals(new Vector(0, 1, 0), identity.getRow(1)); 42 | assertEquals(new Vector(0, 0, 1), identity.getRow(2)); 43 | } 44 | 45 | @Test 46 | public void testGetColumn() throws Exception { 47 | assertEquals(new Vector(1, 0, 0), identity.getColumn(0)); 48 | assertEquals(new Vector(0, 1, 0), identity.getColumn(1)); 49 | assertEquals(new Vector(0, 0, 1), identity.getColumn(2)); 50 | } 51 | 52 | @Test 53 | public void testVectorMultiplication() throws Exception { 54 | Vector vector = new Vector(1, 2, 3); 55 | assertEquals(identity.multiply(vector), vector); 56 | assertEquals(zero.multiply(vector), new Vector(0, 0, 0)); 57 | for (int i = 0; i < 100; ++i) { 58 | double a = rand.nextDouble() * rand.nextInt(100); 59 | double b = rand.nextDouble() * rand.nextInt(100); 60 | double c = rand.nextDouble() * rand.nextInt(100); 61 | Vector vec = new Vector(a, b, c); 62 | assertEquals(identity.multiply(vec), new Vector(a, b, c)); 63 | } 64 | } 65 | 66 | @Test 67 | public void testMatrixMultiplication() throws Exception { 68 | assertEquals(identity.multiply(identity), identity); 69 | assertEquals(identity.multiply(zero), zero); 70 | assertEquals(zero.multiply(identity), zero); 71 | } 72 | 73 | @Test 74 | public void testTranspose() { 75 | assertEquals(identity.transpose(), identity); 76 | assertEquals(zero.transpose(), zero); 77 | 78 | Matrix m = 79 | new Matrix( 80 | new double[][] { 81 | {1, 0, 1, 0}, 82 | {0, 1, 0, 1}, 83 | {2, 3, 0, 0}, 84 | {1, 5, 5, 5} 85 | }); 86 | Matrix transposed = 87 | new Matrix( 88 | new double[][] { 89 | {1, 0, 2, 1}, 90 | {0, 1, 3, 5}, 91 | {1, 0, 0, 5}, 92 | {0, 1, 0, 5} 93 | }); 94 | 95 | assertEquals(transposed, m.transpose()); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/test/java/me/kahlil/geometry/PolygonSphereTest.java: -------------------------------------------------------------------------------- 1 | package me.kahlil.geometry; 2 | 3 | import static com.google.common.truth.Truth.assertThat; 4 | import static com.google.common.truth.Truth8.assertThat; 5 | import static me.kahlil.geometry.Constants.EPSILON; 6 | import static me.kahlil.geometry.LinearTransformation.translate; 7 | import static me.kahlil.scene.Materials.DUMMY_MATERIAL; 8 | 9 | import java.util.Optional; 10 | import java.util.Random; 11 | import org.junit.Test; 12 | import org.junit.runner.RunWith; 13 | import org.junit.runners.JUnit4; 14 | 15 | /** Unit tests for {@link Sphere}. */ 16 | @RunWith(JUnit4.class) 17 | public class PolygonSphereTest { 18 | 19 | private static final PolygonSphere unitSphere = PolygonSphere.withSurfaceNormals(DUMMY_MATERIAL, 16); 20 | 21 | @Test 22 | public void testRayIntersectFromInside() { 23 | Ray r = new Ray(new Vector(0, 0, 0), new Vector(1, 1, 1)); 24 | Optional rayHit = unitSphere.intersectWith(r); 25 | assertThat(rayHit.isPresent()).isTrue(); 26 | assertThat(rayHit.get().getDistance()).isWithin(.01).of(1); 27 | Vector intersection = rayHit.get().getIntersection(); 28 | assertThat(intersection.getX()).isWithin(0.01).of(1 / Math.sqrt(3)); 29 | assertThat(intersection.getY()).isWithin(0.01).of(1 / Math.sqrt(3)); 30 | assertThat(intersection.getZ()).isWithin(0.01).of(1 / Math.sqrt(3)); 31 | } 32 | 33 | @Test 34 | public void testRayIntersectOnceFromOutside() { 35 | Ray r = new Ray(new Vector(-1, 1, 0), new Vector(1, 0, 0)); 36 | Optional rayHit = unitSphere.intersectWith(r); 37 | assertThat(rayHit).isPresent(); 38 | assertThat(rayHit.get().getDistance()).isWithin(EPSILON).of(1); 39 | assertThat(rayHit.get().getIntersection()).isEqualTo(new Vector(0, 1, 0)); 40 | } 41 | 42 | @Test 43 | public void testRayIntersectTwiceFromOutside() { 44 | for (int i = 0; i < 100; ++i) { 45 | Vector p = getRandPointBiggerThan(1); 46 | Ray ray = new Ray(p, p.scale(-1)); 47 | Optional rh = unitSphere.intersectWith(ray); 48 | assertThat(rh).isPresent(); 49 | assertThat(rh.get().getDistance()).isLessThan(p.magnitude()); 50 | } 51 | } 52 | 53 | @Test 54 | public void testRayDoesNotIntersectSphere() { 55 | Ray r = new Ray(new Vector(0, -2, 0), new Vector(0, -1, 0)); 56 | Optional rh = unitSphere.intersectWith(r); 57 | assertThat(rh).isEmpty(); 58 | } 59 | 60 | @Test 61 | public void edgeTest() { 62 | PolygonSphere sphere = unitSphere.transform(translate(1.0, 0.0, -1.0)); 63 | 64 | Ray towardsMiddle = new Ray(new Vector(0, 0, 0), new Vector(1.0, 0.0, -1.0)); 65 | Ray insideEdge = new Ray( 66 | new Vector(0, 0, 0), 67 | new Vector(EPSILON, 0.0, -1.0)); 68 | Ray outsideEdge = new Ray( 69 | new Vector(0, 0, 0), 70 | new Vector(-1 * EPSILON, 0.0, -1.0)); 71 | 72 | assertThat(sphere.intersectWith(towardsMiddle)).isPresent(); 73 | // Check edges 74 | assertThat(sphere.intersectWith(insideEdge)).isPresent(); 75 | assertThat(sphere.intersectWith(outsideEdge)).isEmpty(); 76 | } 77 | 78 | private static Vector getRandPointBiggerThan(int i) { 79 | Random rand = new Random(); 80 | return new Vector(rand.nextInt(100) + i, rand.nextInt(100) + i, rand.nextInt(100) + i); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/test/java/me/kahlil/geometry/BoundingBoxTest.java: -------------------------------------------------------------------------------- 1 | package me.kahlil.geometry; 2 | 3 | import static com.google.common.truth.Truth.assertThat; 4 | import static me.kahlil.geometry.Constants.EPSILON; 5 | 6 | import org.junit.Test; 7 | import org.junit.runner.RunWith; 8 | import org.junit.runners.JUnit4; 9 | 10 | /** 11 | * Unit tests for {@link BoundingBox}. 12 | */ 13 | @RunWith(JUnit4.class) 14 | public class BoundingBoxTest { 15 | 16 | private static final Vector RAY_ORIGIN = new Vector(0, 0, 1); 17 | 18 | // Coordinates for a simple square centered on the origin, translated back -1 along the z axis. 19 | private static final Vector BOTTOM_LEFT = new Vector(-1, -1, -1); 20 | private static final Vector BOTTOM_RIGHT = new Vector(1, -1, -1); 21 | private static final Vector TOP_RIGHT = new Vector(1, 1, -1); 22 | private static final Vector TOP_LEFT = new Vector(-1, 1, -1); 23 | 24 | private static final BoundingBox BOX = new BoundingBox(BOTTOM_LEFT, TOP_RIGHT.translate(0, 0, 1.0)); 25 | 26 | @Test 27 | public void basicCube_middleIntersectionIsCorrect() { 28 | Ray downZAxis = new Ray(RAY_ORIGIN, new Vector(0, 0, -1)); 29 | 30 | assertThat(BOX.intersectWithBoundingVolume(downZAxis)).isGreaterThan(0.0); 31 | } 32 | 33 | @Test 34 | public void basicCube_bottomLeftCorner_intersectionsAreCorrect() { 35 | Ray inside = new Ray(RAY_ORIGIN, BOTTOM_LEFT.translate(EPSILON, EPSILON)); 36 | Ray leftOf = new Ray(RAY_ORIGIN, BOTTOM_LEFT.translate(-1 * EPSILON, EPSILON)); 37 | Ray below = new Ray(RAY_ORIGIN, BOTTOM_LEFT.translate(EPSILON, -1 * EPSILON)); 38 | 39 | assertThat(BOX.intersectWithBoundingVolume(inside)).isGreaterThan(0.0); 40 | assertThat(BOX.intersectWithBoundingVolume(leftOf)).isLessThan(0.0); 41 | assertThat(BOX.intersectWithBoundingVolume(below)).isLessThan(0.0); 42 | } 43 | 44 | @Test 45 | public void basicCube_bottomRightCorner_intersectionsAreCorrect() { 46 | Ray inside = new Ray(RAY_ORIGIN, BOTTOM_RIGHT.translate(-1 * EPSILON, EPSILON)); 47 | Ray rightOf = new Ray(RAY_ORIGIN, BOTTOM_RIGHT.translate(EPSILON, EPSILON)); 48 | Ray below = new Ray(RAY_ORIGIN, BOTTOM_RIGHT.translate(-1 * EPSILON, -1 * EPSILON)); 49 | 50 | assertThat(BOX.intersectWithBoundingVolume(inside)).isGreaterThan(0.0); 51 | assertThat(BOX.intersectWithBoundingVolume(rightOf)).isLessThan(0.0); 52 | assertThat(BOX.intersectWithBoundingVolume(below)).isLessThan(0.0); 53 | } 54 | 55 | @Test 56 | public void basicCube_topRightCorner_intersectionsAreCorrect() { 57 | Ray inside = new Ray(RAY_ORIGIN, TOP_RIGHT.translate(-1 * EPSILON, -1 * EPSILON)); 58 | Ray rightOf = new Ray(RAY_ORIGIN, TOP_RIGHT.translate(EPSILON, -1 * EPSILON)); 59 | Ray above = new Ray(RAY_ORIGIN, TOP_RIGHT.translate(-1 * EPSILON, EPSILON)); 60 | 61 | assertThat(BOX.intersectWithBoundingVolume(inside)).isGreaterThan(0.0); 62 | assertThat(BOX.intersectWithBoundingVolume(rightOf)).isLessThan(0.0); 63 | assertThat(BOX.intersectWithBoundingVolume(above)).isLessThan(0.0); 64 | } 65 | 66 | @Test 67 | public void basicCube_topLeftCorner_intersectionsAreCorrect() { 68 | Ray inside = new Ray(RAY_ORIGIN, TOP_LEFT.translate(EPSILON, -1 * EPSILON)); 69 | Ray leftOf = new Ray(RAY_ORIGIN, TOP_LEFT.translate(-1, -1 * EPSILON)); 70 | Ray above = new Ray(RAY_ORIGIN, TOP_LEFT.translate(EPSILON, EPSILON)); 71 | 72 | assertThat(BOX.intersectWithBoundingVolume(inside)).isGreaterThan(0.0); 73 | assertThat(BOX.intersectWithBoundingVolume(leftOf)).isLessThan(0.0); 74 | assertThat(BOX.intersectWithBoundingVolume(above)).isLessThan(0.0); 75 | } 76 | 77 | } -------------------------------------------------------------------------------- /src/main/java/me/kahlil/geometry/Shape.java: -------------------------------------------------------------------------------- 1 | package me.kahlil.geometry; 2 | 3 | import static me.kahlil.config.Counters.NUM_INTERSECTIONS; 4 | import static me.kahlil.config.Counters.NUM_INTERSECTION_TESTS; 5 | 6 | import java.util.Optional; 7 | 8 | /** A representation of a 3D object in the scene. */ 9 | public abstract class Shape implements Cloneable, Intersectable { 10 | 11 | private LinearTransformation transformation = LinearTransformation.IDENTITY; 12 | 13 | /** Finds the intersection of the given ray with this potentially transformed object */ 14 | @Override 15 | public Optional intersectWith(Ray ray) { 16 | // We first transform the ray into object space for this given object before computing 17 | // intersections. 18 | Ray objectSpaceRay = 19 | new Ray( 20 | worldToObjectSpace().apply(ray.getStart()), 21 | worldToObjectSpace().apply(ray.getDirection())); 22 | 23 | Optional maybeObjectSpaceIntersection = intersectInObjectSpace(objectSpaceRay); 24 | if (maybeObjectSpaceIntersection.isEmpty()) { 25 | return Optional.empty(); 26 | } 27 | RayHit objectSpaceIntersection = maybeObjectSpaceIntersection.get(); 28 | Vector worldSpaceIntersectionPoint = 29 | objectToWorldSpace().apply(objectSpaceIntersection.getIntersection()); 30 | Vector worldSpaceNormal = normalsToWorldSpace().apply(objectSpaceIntersection.getNormal()); 31 | return Optional.of( 32 | ImmutableRayHit.builder() 33 | .setRay(ray) 34 | .setTime(ray.timeToPoint(worldSpaceIntersectionPoint)) 35 | .setNormal(worldSpaceNormal) 36 | .setObject(maybeObjectSpaceIntersection.get().getObject()) 37 | .setMaterial(maybeObjectSpaceIntersection.get().getMaterial()) 38 | .build()); 39 | } 40 | 41 | /** 42 | * Computes the {@link RayHit} with this object and the given ray which is specified in object by 43 | * having the inverse transformation of this object applied to it. The resulting RayHit should 44 | * also be returned in object space. 45 | */ 46 | final Optional intersectInObjectSpace(Ray ray) { 47 | NUM_INTERSECTION_TESTS.getAndIncrement(); 48 | Optional rayHit = internalIntersectInObjectSpace(ray); 49 | if (rayHit.isPresent()) { 50 | NUM_INTERSECTIONS.getAndIncrement(); 51 | } 52 | return rayHit; 53 | } 54 | 55 | abstract Optional internalIntersectInObjectSpace(Ray ray); 56 | 57 | /** Returns the object-to-world space transformation currently applied to this object. */ 58 | LinearTransformation getTransformation() { 59 | return this.transformation; 60 | } 61 | 62 | void setTransformation(LinearTransformation transformation) { 63 | this.transformation = transformation; 64 | } 65 | 66 | /** Transforms the object by the given linear transformation */ 67 | public V transform(LinearTransformation lt) { 68 | try { 69 | V cloned = (V) this.clone(); 70 | cloned.setTransformation(transformation.then(lt)); 71 | return cloned; 72 | } catch (CloneNotSupportedException e) { 73 | throw new RuntimeException(e); 74 | } 75 | } 76 | 77 | /** Returns the transformation from object space to world space. */ 78 | private LinearTransformation objectToWorldSpace() { 79 | return transformation; 80 | } 81 | 82 | /** Returns the transformation from world space to object space. */ 83 | private LinearTransformation worldToObjectSpace() { 84 | return transformation.inverse(); 85 | } 86 | 87 | /** Returns the transformation from normals in object space back to world space. */ 88 | private LinearTransformation normalsToWorldSpace() { 89 | return transformation.inverseTranspose(); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/test/java/me/kahlil/geometry/ExtentsTest.java: -------------------------------------------------------------------------------- 1 | package me.kahlil.geometry; 2 | 3 | import static com.google.common.truth.Truth.assertThat; 4 | import static me.kahlil.geometry.Constants.EPSILON; 5 | import static me.kahlil.geometry.ConvexPolygon.cube; 6 | import static me.kahlil.geometry.LinearTransformation.translate; 7 | import static me.kahlil.scene.Materials.DUMMY_MATERIAL; 8 | 9 | import org.junit.Test; 10 | import org.junit.runner.RunWith; 11 | import org.junit.runners.JUnit4; 12 | 13 | /** Unit tests for {@link BoundingBox}. */ 14 | @RunWith(JUnit4.class) 15 | public class ExtentsTest { 16 | 17 | private static final Vector RAY_ORIGIN = new Vector(0, 0, 1); 18 | 19 | // Coordinates for a simple square centered on the origin, translated back -1 along the z axis. 20 | private static final Vector BOTTOM_LEFT = new Vector(-1, -1, -1); 21 | private static final Vector BOTTOM_RIGHT = new Vector(1, -1, -1); 22 | private static final Vector TOP_RIGHT = new Vector(1, 1, -1); 23 | private static final Vector TOP_LEFT = new Vector(-1, 1, -1); 24 | 25 | private static final Extents BOX = 26 | Extents.fromPolygon(cube(DUMMY_MATERIAL).transform(translate(0, 0, -1))); 27 | 28 | @Test 29 | public void basicCube_middleIntersectionIsCorrect() { 30 | Ray downZAxis = new Ray(RAY_ORIGIN, new Vector(0, 0, -1)); 31 | 32 | assertThat(BOX.intersectWithBoundingVolume(downZAxis)).isGreaterThan(0.0); 33 | } 34 | 35 | @Test 36 | public void basicCube_bottomLeftCorner_intersectionsAreCorrect() { 37 | Ray inside = new Ray(RAY_ORIGIN, BOTTOM_LEFT.translate(EPSILON, EPSILON)); 38 | Ray leftOf = new Ray(RAY_ORIGIN, BOTTOM_LEFT.translate(-1 * EPSILON, EPSILON)); 39 | Ray below = new Ray(RAY_ORIGIN, BOTTOM_LEFT.translate(EPSILON, -1 * EPSILON)); 40 | 41 | assertThat(BOX.intersectWithBoundingVolume(inside)).isGreaterThan(0.0); 42 | assertThat(BOX.intersectWithBoundingVolume(leftOf)).isLessThan(0.0); 43 | assertThat(BOX.intersectWithBoundingVolume(below)).isLessThan(0.0); 44 | } 45 | 46 | @Test 47 | public void basicCube_bottomRightCorner_intersectionsAreCorrect() { 48 | Ray inside = new Ray(RAY_ORIGIN, BOTTOM_RIGHT.translate(-1 * EPSILON, EPSILON)); 49 | Ray rightOf = new Ray(RAY_ORIGIN, BOTTOM_RIGHT.translate(EPSILON, EPSILON)); 50 | Ray below = new Ray(RAY_ORIGIN, BOTTOM_RIGHT.translate(-1 * EPSILON, -1 * EPSILON)); 51 | 52 | assertThat(BOX.intersectWithBoundingVolume(inside)).isGreaterThan(0.0); 53 | assertThat(BOX.intersectWithBoundingVolume(rightOf)).isLessThan(0.0); 54 | assertThat(BOX.intersectWithBoundingVolume(below)).isLessThan(0.0); 55 | } 56 | 57 | @Test 58 | public void basicCube_topRightCorner_intersectionsAreCorrect() { 59 | Ray inside = new Ray(RAY_ORIGIN, TOP_RIGHT.translate(-1 * EPSILON, -1 * EPSILON)); 60 | Ray rightOf = new Ray(RAY_ORIGIN, TOP_RIGHT.translate(EPSILON, -1 * EPSILON)); 61 | Ray above = new Ray(RAY_ORIGIN, TOP_RIGHT.translate(-1 * EPSILON, EPSILON)); 62 | 63 | assertThat(BOX.intersectWithBoundingVolume(inside)).isGreaterThan(0.0); 64 | assertThat(BOX.intersectWithBoundingVolume(rightOf)).isLessThan(0.0); 65 | assertThat(BOX.intersectWithBoundingVolume(above)).isLessThan(0.0); 66 | } 67 | 68 | @Test 69 | public void basicCube_topLeftCorner_intersectionsAreCorrect() { 70 | Ray inside = new Ray(RAY_ORIGIN, TOP_LEFT.translate(EPSILON, -1 * EPSILON)); 71 | Ray leftOf = new Ray(RAY_ORIGIN, TOP_LEFT.translate(-1, -1 * EPSILON)); 72 | Ray above = new Ray(RAY_ORIGIN, TOP_LEFT.translate(EPSILON, EPSILON)); 73 | 74 | assertThat(BOX.intersectWithBoundingVolume(inside)).isGreaterThan(0.0); 75 | assertThat(BOX.intersectWithBoundingVolume(leftOf)).isLessThan(0.0); 76 | assertThat(BOX.intersectWithBoundingVolume(above)).isLessThan(0.0); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/main/java/me/kahlil/graphics/ReflectiveRayTracer.java: -------------------------------------------------------------------------------- 1 | package me.kahlil.graphics; 2 | 3 | import static me.kahlil.config.Counters.NUM_TOTAL_RAYS; 4 | import static me.kahlil.geometry.Constants.EPSILON; 5 | import static me.kahlil.graphics.RayIntersections.findFirstIntersection; 6 | 7 | import java.util.Optional; 8 | import me.kahlil.geometry.Ray; 9 | import me.kahlil.geometry.RayHit; 10 | import me.kahlil.geometry.Vector; 11 | import me.kahlil.scene.Camera; 12 | import me.kahlil.scene.Raster; 13 | import me.kahlil.scene.Scene; 14 | 15 | /** 16 | * Ray tracer that performs a simple implementation of reflection-based ray tracing (i.e. no 17 | * refraction or partial reflection). 18 | */ 19 | public class ReflectiveRayTracer extends RayTracer { 20 | 21 | private final Shader shader; 22 | private final Scene scene; 23 | private final int maxRayDepth; 24 | 25 | /** 26 | * Constructs a ReflectiveRayTracer with a given maxRayDepth, indicating the maximum number of 27 | * recursive rays that should be traced for reflections. 28 | */ 29 | public ReflectiveRayTracer( 30 | Shader shader, Scene scene, Raster raster, Camera camera, int maxRayDepth) { 31 | super(raster, camera); 32 | this.shader = shader; 33 | this.scene = scene; 34 | this.maxRayDepth = maxRayDepth; 35 | } 36 | 37 | @Override 38 | MutableColor traceRay(Ray ray) { 39 | return recursiveTraceRay(ray, 1); 40 | } 41 | 42 | private MutableColor recursiveTraceRay(Ray ray, int rayDepth) { 43 | NUM_TOTAL_RAYS.getAndIncrement(); 44 | if (rayDepth > maxRayDepth) { 45 | return scene.getBackgroundColor(); 46 | } 47 | Optional rayHit = findFirstIntersection(ray, scene); 48 | if (!rayHit.isPresent()) { 49 | return scene.getBackgroundColor(); 50 | } 51 | double reflectiveness = rayHit.get().getMaterial().getReflectiveness(); 52 | // If surface isn't reflective or we're already at max depth, simply return. 53 | if (reflectiveness < EPSILON || rayDepth == maxRayDepth) { 54 | return shader.shade(rayHit.get()); 55 | } 56 | 57 | MutableColor reflectedRayColor = 58 | ColorComputation.of(recursiveTraceRay(computeReflectionRay(rayHit.get()), rayDepth + 1)) 59 | // Reduce effect of reflection by 20% to mimic imperfect reflection. 60 | .scaleFloat(0.8f) 61 | .scaleFloat((float) reflectiveness) 62 | .compute(); 63 | 64 | // For purely reflective surfaces, simply return the reflected ray's color. 65 | if (Math.abs(reflectiveness - 1.0) < EPSILON) { 66 | return reflectedRayColor; 67 | } 68 | 69 | // For partially reflective surfaces, combine the reflected ray's color with the surface color. 70 | return ColorComputation.of(shader.shade(rayHit.get())) 71 | .scaleFloat(1.0f - (float) reflectiveness) 72 | .add(reflectedRayColor) 73 | .compute(); 74 | } 75 | 76 | /** 77 | * Math for computing the reflection ray R can be expressed with the following formula, given an 78 | * incident Ray I and a normal ray N at the point of intersection N: 79 | * 80 | *

R = I - 2 * (I dot N) * N 81 | * 82 | *

Sources: 83 | * https://www.scratchapixel.com/lessons/3d-basic-rendering/introduction-to-shading/reflection-refraction-fresnel 84 | * http://web.cse.ohio-state.edu/~shen.94/681/Site/Slides_files/reflection_refraction.pdf 85 | */ 86 | private static Ray computeReflectionRay(RayHit rayHit) { 87 | Vector incident = rayHit.getRay().getDirection(); 88 | Vector normal = rayHit.getNormal(); 89 | Vector reflection = incident.subtract(normal.scale(2 * incident.dot(normal))); 90 | Vector perturbedStartingPoint = rayHit.getIntersection().add(reflection.scale(EPSILON)); 91 | return new Ray(perturbedStartingPoint, reflection); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/test/java/me/kahlil/graphics/CoordinateMapperTest.java: -------------------------------------------------------------------------------- 1 | package me.kahlil.graphics; 2 | 3 | import static com.google.common.truth.Truth.assertThat; 4 | import static me.kahlil.geometry.Constants.EPSILON; 5 | import static me.kahlil.graphics.CoordinateMapper.convertPixelToCameraSpaceCoordinates; 6 | import static me.kahlil.graphics.CoordinateMapper.getPixelHeightInCameraSpace; 7 | import static me.kahlil.graphics.CoordinateMapper.getPixelWidthInCameraSpace; 8 | 9 | import me.kahlil.scene.Cameras; 10 | import me.kahlil.scene.Raster; 11 | import org.junit.Test; 12 | import org.junit.runner.RunWith; 13 | import org.junit.runners.JUnit4; 14 | 15 | @RunWith(JUnit4.class) 16 | public class CoordinateMapperTest { 17 | 18 | @Test 19 | public void firstPixelToCameraSpace() { 20 | Point2D inCameraSpace = 21 | convertPixelToCameraSpaceCoordinates(new Raster(20, 20), Cameras.NINETY_DEGREE_FOV, 0, 0); 22 | Point2D expected = ImmutablePoint2D.builder().setX(-0.95).setY(0.95).build(); 23 | assertThat(inCameraSpace.getX()).isWithin(EPSILON).of(expected.getX()); 24 | assertThat(inCameraSpace.getY()).isWithin(EPSILON).of(expected.getY()); 25 | } 26 | 27 | @Test 28 | public void firstPixelToCameraSpaceWithAspectRatioEffect() { 29 | Point2D inCameraSpace = 30 | convertPixelToCameraSpaceCoordinates(new Raster(40, 20), Cameras.NINETY_DEGREE_FOV, 0, 0); 31 | // Standard Camera uses narrower FOV, thus X, Y are more tightly bound. 32 | Point2D expected = 33 | ImmutablePoint2D.builder() 34 | // X is stretched by aspect ratio (width / height). 35 | .setX(-0.975 * 2.0) 36 | .setY(0.95) 37 | .build(); 38 | assertThat(inCameraSpace.getX()).isWithin(EPSILON).of(expected.getX()); 39 | assertThat(inCameraSpace.getY()).isWithin(EPSILON).of(expected.getY()); 40 | } 41 | 42 | @Test 43 | public void firstPixelToCameraSpaceWithFovEffect() { 44 | Point2D inCameraSpace = 45 | convertPixelToCameraSpaceCoordinates(new Raster(20, 20), Cameras.STANDARD_CAMERA, 0, 0); 46 | // Standard Camera uses narrower FOV, thus X, Y are more tightly bound. 47 | Point2D expected = 48 | ImmutablePoint2D.builder().setX(-0.5484827557301444).setY(0.5484827557301444).build(); 49 | assertThat(inCameraSpace.getX()).isWithin(EPSILON).of(expected.getX()); 50 | assertThat(inCameraSpace.getY()).isWithin(EPSILON).of(expected.getY()); 51 | } 52 | 53 | @Test 54 | public void pixelWidth() { 55 | assertThat(getPixelWidthInCameraSpace(new Raster(20, 20), Cameras.NINETY_DEGREE_FOV)) 56 | .isWithin(EPSILON) 57 | .of(0.1); 58 | } 59 | 60 | @Test 61 | public void pixelWidthNotAffectedByAspectRatio() { 62 | assertThat(getPixelWidthInCameraSpace(new Raster(40, 20), Cameras.NINETY_DEGREE_FOV)) 63 | // Aspect ratio effect mitigated by doubling number of x pixels. 64 | .isWithin(EPSILON) 65 | .of(0.1); 66 | } 67 | 68 | @Test 69 | public void pixelWidthWithFovEffect() { 70 | assertThat(getPixelWidthInCameraSpace(new Raster(20, 20), Cameras.STANDARD_CAMERA)) 71 | // Narrower FOV means a smaller pixel width. 72 | .isWithin(EPSILON) 73 | .of(0.05773502691896254); 74 | } 75 | 76 | @Test 77 | public void pixelHeight() { 78 | assertThat(getPixelHeightInCameraSpace(new Raster(20, 20), Cameras.NINETY_DEGREE_FOV)) 79 | .isWithin(EPSILON) 80 | .of(0.1); 81 | } 82 | 83 | @Test 84 | public void pixelHeightNotEffectedByAspectRatio() { 85 | assertThat(getPixelHeightInCameraSpace(new Raster(40, 20), Cameras.NINETY_DEGREE_FOV)) 86 | .isWithin(EPSILON) 87 | .of(0.1); 88 | } 89 | 90 | @Test 91 | public void pixelHeightWithFovEffect() { 92 | assertThat(getPixelHeightInCameraSpace(new Raster(20, 20), Cameras.STANDARD_CAMERA)) 93 | // Narrower FOV means a smaller pixel width. 94 | .isWithin(EPSILON) 95 | .of(0.05773502691896254); 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/main/java/me/kahlil/geometry/Matrix.java: -------------------------------------------------------------------------------- 1 | package me.kahlil.geometry; 2 | 3 | import static me.kahlil.geometry.Constants.EPSILON; 4 | 5 | import com.google.common.primitives.Doubles; 6 | import java.util.Arrays; 7 | import java.util.Objects; 8 | 9 | /** 10 | * A representation of a matrix used for the required linear algebra in the ray tracing algorithm. 11 | */ 12 | public class Matrix { 13 | 14 | private final double[][] entries; 15 | public static final Matrix IDENTITY = 16 | new Matrix(new double[][] {{1, 0, 0, 0}, {0, 1, 0, 0}, {0, 0, 1, 0}, {0, 0, 0, 1}}); 17 | public static final Matrix ZERO = 18 | new Matrix(new double[][] {{0, 0, 0, 0}, {0, 0, 0, 0}, {0, 0, 0, 0}, {0, 0, 0, 0}}); 19 | 20 | public Matrix(double[][] entries) { 21 | this.entries = entries; 22 | } 23 | 24 | public double get(int i, int j) { 25 | return entries[i][j]; 26 | } 27 | 28 | /** 29 | * Returns a vector representing the ith row of the matrix 30 | */ 31 | public Vector getRow(int i) { 32 | return new Vector(entries[i][0], entries[i][1], entries[i][2], entries[i][3]); 33 | } 34 | 35 | /** 36 | * Returns a vector representing the jth row of the matrix 37 | */ 38 | public Vector getColumn(int j) { 39 | return new Vector(entries[0][j], entries[1][j], entries[2][j], entries[3][j]); 40 | } 41 | 42 | /** 43 | * Returns the left-product with the vector. I.e. given this matrix A and vector V, return Av 44 | */ 45 | public Vector multiply(Vector vector) { 46 | if (getColumnCount() != 4) { 47 | throw new IllegalArgumentException(vector + " must have magnitude " + getColumnCount()); 48 | } 49 | return new Vector( 50 | getRow(0).dot4D(vector), 51 | getRow(1).dot4D(vector), 52 | getRow(2).dot4D(vector), 53 | getRow(3).dot4D(vector)); 54 | } 55 | 56 | /** 57 | * Left multiplies this matrix by the passed matrix. In other words if this matrix is A and other 58 | * is B, this computes AB 59 | */ 60 | public Matrix multiply(Matrix other) { 61 | if (getColumnCount() != other.getRowCount()) { 62 | throw new IllegalArgumentException(other + " must have " + getColumnCount() + " rows!"); 63 | } 64 | double[][] entries = new double[getRowCount()][other.getColumnCount()]; 65 | int numRows = getRowCount(); 66 | int numCols = getColumnCount(); 67 | for (int i = 0; i < numRows; ++i) { 68 | for (int j = 0; j < numCols; ++j) { 69 | entries[i][j] = getRow(i).dot4D(other.getColumn(j)); 70 | } 71 | } 72 | return new Matrix(entries); 73 | } 74 | 75 | /** 76 | * Returns the transpose of this matrix, i.e. the matrix formed by using every column of this 77 | * matrix as the rows of the returned matrix. 78 | */ 79 | public Matrix transpose() { 80 | int numRows = getRowCount(); 81 | int numCols = getColumnCount(); 82 | double[][] transposed = new double[numRows][numCols]; 83 | for (int i = 0; i < numRows; ++i) { 84 | for (int j = 0; j < numCols; ++j) { 85 | transposed[i][j] = entries[j][i]; 86 | } 87 | } 88 | return new Matrix(transposed); 89 | } 90 | 91 | public int getRowCount() { 92 | return entries.length; 93 | } 94 | 95 | public int getColumnCount() { 96 | return entries[0].length; 97 | } 98 | 99 | public String toString() { 100 | StringBuilder sb = new StringBuilder(); 101 | for (int i = 0; i < getRowCount(); ++i) { 102 | sb.append(getRow(i).toString()); 103 | } 104 | return sb.toString(); 105 | } 106 | 107 | @Override 108 | public boolean equals(Object o) { 109 | if (this == o) { 110 | return true; 111 | } 112 | if (o == null || getClass() != o.getClass()) { 113 | return false; 114 | } 115 | Matrix other = (Matrix) o; 116 | if (this.getRowCount() != other.getRowCount()) { 117 | return false; 118 | } 119 | for (int i = 0; i < this.getRowCount(); i++) { 120 | if (!this.getRow(i).equals(other.getRow(i))) { 121 | return false; 122 | } 123 | } 124 | return true; 125 | } 126 | 127 | @Override 128 | public int hashCode() { 129 | return Arrays.hashCode(entries); 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/test/java/me/kahlil/geometry/ConvexPolygonTest.java: -------------------------------------------------------------------------------- 1 | package me.kahlil.geometry; 2 | 3 | import static com.google.common.truth.Truth.assertThat; 4 | import static com.google.common.truth.Truth8.assertThat; 5 | import static me.kahlil.geometry.Constants.EPSILON; 6 | import static me.kahlil.geometry.Constants.ORIGIN; 7 | import static me.kahlil.geometry.LinearTransformation.translate; 8 | import static me.kahlil.scene.Materials.DUMMY_MATERIAL; 9 | 10 | import java.util.Optional; 11 | import org.junit.Test; 12 | import org.junit.runner.RunWith; 13 | import org.junit.runners.JUnit4; 14 | 15 | /** Unit tests for {@link ConvexPolygon}. */ 16 | @RunWith(JUnit4.class) 17 | public class ConvexPolygonTest { 18 | 19 | // Coordinates for a simple square centered on the origin, translated back -1 along the z axis. 20 | private static final Vector BOTTOM_LEFT = new Vector(-1, -1, -1); 21 | private static final Vector BOTTOM_RIGHT = new Vector(1, -1, -1); 22 | private static final Vector TOP_RIGHT = new Vector(1, 1, -1); 23 | private static final Vector TOP_LEFT = new Vector(-1, 1, -1); 24 | 25 | @Test 26 | public void basicCube_middleIntersectionIsCorrect() { 27 | ConvexPolygon polygon = ConvexPolygon.cube(DUMMY_MATERIAL).transform(translate(0, 0, -1)); 28 | 29 | Ray downZAxis = new Ray(ORIGIN, new Vector(0, 0, -1)); 30 | Optional hit = polygon.intersectWith(downZAxis); 31 | assertThat(hit).isPresent(); 32 | // Check that objects are preserved properly. 33 | assertThat(hit.get().getRay()).isEqualTo(downZAxis); 34 | assertThat(hit.get().getObject()).isInstanceOf(Triangle.class); 35 | // Verify intersection math. 36 | assertThat(hit.get().getTime()).isEqualTo(1.0); 37 | assertThat(hit.get().getNormal()).isEqualTo(new Vector(0, 0, 1)); 38 | assertThat(hit.get().getDistance()).isEqualTo(1.0); 39 | assertThat(hit.get().getIntersection()).isEqualTo(new Vector(0, 0, -1)); 40 | } 41 | 42 | @Test 43 | public void basicCube_bottomLeftCorner_intersectionsAreCorrect() { 44 | ConvexPolygon polygon = ConvexPolygon.cube(DUMMY_MATERIAL).transform(translate(0, 0, -1)); 45 | 46 | Ray inside = new Ray(ORIGIN, BOTTOM_LEFT.translate(EPSILON, EPSILON)); 47 | Ray leftOf = new Ray(ORIGIN, BOTTOM_LEFT.translate(-1 * EPSILON, EPSILON)); 48 | Ray below = new Ray(ORIGIN, BOTTOM_LEFT.translate(EPSILON, -1 * EPSILON)); 49 | 50 | assertThat(polygon.intersectWith(inside)).isPresent(); 51 | assertThat(polygon.intersectWith(leftOf)).isEmpty(); 52 | assertThat(polygon.intersectWith(below)).isEmpty(); 53 | } 54 | 55 | @Test 56 | public void basicCube_bottomRightCorner_intersectionsAreCorrect() { 57 | ConvexPolygon polygon = ConvexPolygon.cube(DUMMY_MATERIAL).transform(translate(0, 0, -1)); 58 | 59 | Ray inside = new Ray(ORIGIN, BOTTOM_RIGHT.translate(-1 * EPSILON, EPSILON)); 60 | Ray rightOf = new Ray(ORIGIN, BOTTOM_RIGHT.translate(EPSILON, EPSILON)); 61 | Ray below = new Ray(ORIGIN, BOTTOM_RIGHT.translate(-1 * EPSILON, -1 * EPSILON)); 62 | 63 | assertThat(polygon.intersectWith(inside)).isPresent(); 64 | assertThat(polygon.intersectWith(rightOf)).isEmpty(); 65 | assertThat(polygon.intersectWith(below)).isEmpty(); 66 | } 67 | 68 | @Test 69 | public void basicCube_topRightCorner_intersectionsAreCorrect() { 70 | ConvexPolygon polygon = ConvexPolygon.cube(DUMMY_MATERIAL).transform(translate(0, 0, -1)); 71 | Ray inside = new Ray(ORIGIN, TOP_RIGHT.translate(-1 * EPSILON, -1 * EPSILON)); 72 | Ray rightOf = new Ray(ORIGIN, TOP_RIGHT.translate(EPSILON, -1 * EPSILON)); 73 | Ray above = new Ray(ORIGIN, TOP_RIGHT.translate(-1 * EPSILON, EPSILON)); 74 | 75 | assertThat(polygon.intersectWith(inside)).isPresent(); 76 | assertThat(polygon.intersectWith(rightOf)).isEmpty(); 77 | assertThat(polygon.intersectWith(above)).isEmpty(); 78 | } 79 | 80 | @Test 81 | public void basicCube_topLeftCorner_intersectionsAreCorrect() { 82 | ConvexPolygon polygon = ConvexPolygon.cube(DUMMY_MATERIAL).transform(translate(0, 0, -1)); 83 | Ray inside = new Ray(ORIGIN, TOP_LEFT.translate(EPSILON, -1 * EPSILON)); 84 | Ray leftOf = new Ray(ORIGIN, TOP_LEFT.translate(-1, -1 * EPSILON)); 85 | Ray above = new Ray(ORIGIN, TOP_LEFT.translate(EPSILON, EPSILON)); 86 | 87 | assertThat(polygon.intersectWith(inside)).isPresent(); 88 | assertThat(polygon.intersectWith(leftOf)).isEmpty(); 89 | assertThat(polygon.intersectWith(above)).isEmpty(); 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/main/java/me/kahlil/graphics/RayTracerCoordinator.java: -------------------------------------------------------------------------------- 1 | package me.kahlil.graphics; 2 | 3 | import static com.google.common.collect.ImmutableList.toImmutableList; 4 | import static me.kahlil.config.Counters.NUM_BOUNDING_INTERSECTIONS; 5 | import static me.kahlil.config.Counters.NUM_BOUNDING_INTERSECTION_TESTS; 6 | import static me.kahlil.config.Counters.NUM_INTERSECTIONS; 7 | import static me.kahlil.config.Counters.NUM_OCTREE_INTERNAL_INSERTIONS; 8 | import static me.kahlil.config.Counters.NUM_OCTREE_CHILD_INSERTIONS; 9 | import static me.kahlil.config.Counters.NUM_PRIMARY_RAYS; 10 | import static me.kahlil.config.Counters.NUM_INTERSECTION_TESTS; 11 | import static me.kahlil.config.Counters.NUM_TOTAL_RAYS; 12 | import static me.kahlil.config.Counters.NUM_TRIANGLES; 13 | import static me.kahlil.config.Counters.NUM_TRIANGLE_INTERSECTIONS; 14 | import static me.kahlil.config.Counters.NUM_TRIANGLE_TESTS; 15 | import static me.kahlil.config.Parameters.NUM_THREADS; 16 | 17 | import com.google.common.collect.ImmutableList; 18 | import java.text.NumberFormat; 19 | import java.util.concurrent.ExecutionException; 20 | import java.util.concurrent.ExecutorService; 21 | import java.util.concurrent.Executors; 22 | import java.util.concurrent.Future; 23 | import java.util.stream.IntStream; 24 | import me.kahlil.scene.Camera; 25 | import me.kahlil.scene.Raster; 26 | import me.kahlil.scene.Scene; 27 | 28 | /** Coordinator for managing the ray tracer worker threads via a {@link ExecutorService}. */ 29 | public class RayTracerCoordinator { 30 | 31 | private static final NumberFormat numberFormat = NumberFormat.getNumberInstance(); 32 | 33 | private final ExecutorService executor; 34 | 35 | private final Raster raster; 36 | private final Camera camera; 37 | private final Scene scene; 38 | private final RayTracer rayTracer; 39 | 40 | public RayTracerCoordinator(Raster raster, Camera camera, Scene scene, RayTracer rayTracer) { 41 | this.raster = raster; 42 | this.camera = camera; 43 | this.scene = scene; 44 | this.rayTracer = rayTracer; 45 | this.executor = Executors.newFixedThreadPool(NUM_THREADS); 46 | } 47 | 48 | public Raster render() throws InterruptedException, ExecutionException { 49 | 50 | // Construct individual worker threads. 51 | ImmutableList rayTracerWorkers = 52 | IntStream.range(0, NUM_THREADS) 53 | .mapToObj(i -> new RayTracerWorker(rayTracer, raster, i, NUM_THREADS)) 54 | .collect(toImmutableList()); 55 | 56 | // Start all workers. 57 | ImmutableList> futures = 58 | rayTracerWorkers.stream().map(executor::submit).collect(toImmutableList()); 59 | 60 | // Wait for all workers to finish. 61 | for (Future future : futures) { 62 | future.get(); 63 | } 64 | 65 | // Kill executor now that work is done. 66 | executor.shutdown(); 67 | 68 | System.out.printf("# primary rays = %s\n", numberFormat.format(NUM_PRIMARY_RAYS.get())); 69 | System.out.printf("# total rays traced = %s\n", numberFormat.format(NUM_TOTAL_RAYS.get())); 70 | System.out.printf("# triangles = %s\n", numberFormat.format(NUM_TRIANGLES.get())); 71 | System.out.printf( 72 | "# ray-triangle intersection tests = %s\n", numberFormat.format(NUM_TRIANGLE_TESTS.get())); 73 | System.out.printf( 74 | "# ray-triangle actual intersections = %s (%s%%)\n", 75 | numberFormat.format(NUM_TRIANGLE_INTERSECTIONS.get()), 76 | numberFormat.format(100.0 * NUM_TRIANGLE_INTERSECTIONS.get() / NUM_TRIANGLE_TESTS.get())); 77 | System.out.printf( 78 | "# ray-bounding-volume intersection tests = %s\n", numberFormat.format(NUM_BOUNDING_INTERSECTION_TESTS.get())); 79 | System.out.printf( 80 | "# ray-bounding-volume actual intersections = %s (%s%%)\n", 81 | numberFormat.format(NUM_BOUNDING_INTERSECTIONS.get()), 82 | numberFormat.format(100.0 * NUM_BOUNDING_INTERSECTIONS.get() / NUM_BOUNDING_INTERSECTION_TESTS.get())); 83 | System.out.printf( 84 | "# ray-shape intersection tests = %s\n", numberFormat.format(NUM_INTERSECTION_TESTS.get())); 85 | System.out.printf( 86 | "# ray-shape actual intersections = %s (%s%%)\n", 87 | numberFormat.format(NUM_INTERSECTIONS.get()), 88 | numberFormat.format(100.0 * NUM_INTERSECTIONS.get() / NUM_INTERSECTION_TESTS.get())); 89 | System.out.printf( 90 | "# octree internal insertions = %s\n", numberFormat.format(NUM_OCTREE_INTERNAL_INSERTIONS.get())); 91 | System.out.printf( 92 | "# octree child insertions = %s\n", numberFormat.format(NUM_OCTREE_CHILD_INSERTIONS.get())); 93 | 94 | return raster; 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /src/test/java/me/kahlil/geometry/TriangleTest.java: -------------------------------------------------------------------------------- 1 | package me.kahlil.geometry; 2 | 3 | import static com.google.common.truth.Truth.assertThat; 4 | import static com.google.common.truth.Truth8.assertThat; 5 | import static me.kahlil.geometry.Constants.ORIGIN; 6 | import static me.kahlil.scene.Materials.DUMMY_MATERIAL; 7 | 8 | import java.util.Optional; 9 | import junitparams.JUnitParamsRunner; 10 | import junitparams.Parameters; 11 | import org.junit.Test; 12 | import org.junit.runner.RunWith; 13 | 14 | @RunWith(JUnitParamsRunner.class) 15 | public class TriangleTest { 16 | 17 | private static final Vector[] vertexes = new Vector[]{ 18 | new Vector(-1, -1, -1), new Vector(1, -1, -1), new Vector(0, 1, -1) 19 | }; 20 | 21 | // Triangle placed 1 unit backwards down the z axis and centered around it. 22 | private static final Triangle CENTERED_ON_NEGATIVE_Z_AXIS = 23 | Triangle.withSurfaceNormals( 24 | DUMMY_MATERIAL, vertexes); 25 | 26 | private static final Triangle WITH_VERTEX_NORMALS = 27 | Triangle.withVertexNormals( 28 | DUMMY_MATERIAL, vertexes, vertexes); 29 | 30 | // JUnitParamsRunner way of providing both triangles (with surface and vertex normals) to be 31 | // passed along to tests, since all should perform equivalently, with the exception of normal 32 | // computation. 33 | private Object[] provideAllTriangles() { 34 | return new Object[]{ 35 | CENTERED_ON_NEGATIVE_Z_AXIS, 36 | WITH_VERTEX_NORMALS 37 | }; 38 | } 39 | 40 | @Test 41 | @Parameters(method = "provideAllTriangles") 42 | public void intersectInObjectSpace_basicPositiveIntersection(Triangle triangle) { 43 | Ray downZAxis = new Ray(ORIGIN, new Vector(0, 0, -1)); 44 | 45 | Optional intersection = triangle.intersectInObjectSpace(downZAxis); 46 | assertThat(intersection).isPresent(); 47 | 48 | // Check references are stored correctly. 49 | assertThat(intersection.get().getRay()).isEqualTo(downZAxis); 50 | assertThat(intersection.get().getObject()).isEqualTo(triangle); 51 | 52 | // Check computation is correct. 53 | assertThat(intersection.get().getTime()).isEqualTo(1.0); 54 | } 55 | 56 | @Test 57 | public void intersectInObjectSpace_surfaceNormalIsCorrect() { 58 | Ray downZAxis = new Ray(ORIGIN, new Vector(0, 0, -1)); 59 | 60 | Optional intersection = CENTERED_ON_NEGATIVE_Z_AXIS.intersectInObjectSpace(downZAxis); 61 | assertThat(intersection).isPresent(); 62 | 63 | // Check surface normal is correct. 64 | assertThat(intersection.get().getNormal()).isEqualTo(new Vector(0, 0, 1)); 65 | } 66 | 67 | @Test 68 | @Parameters(method = "provideAllTriangles") 69 | public void intersectInObjectSpace_basicFalseIntersection(Triangle triangle) { 70 | Ray awayFromTriangle = new Ray(ORIGIN, new Vector(0, 0, 1)); 71 | 72 | assertThat(triangle.intersectInObjectSpace(awayFromTriangle)).isEmpty(); 73 | } 74 | 75 | @Test 76 | public void intersectInObjectSpace_parallelRayAndTriangleDoNotIntersect() { 77 | Ray downZAxis = new Ray(ORIGIN, new Vector(0, 0, -1)); 78 | 79 | Vector[] vertexes = {new Vector(-1, 0, -1), new Vector(0, 0, -2), new Vector(1, 0, -1)}; 80 | Triangle onXZPlane = 81 | Triangle.withSurfaceNormals( 82 | DUMMY_MATERIAL, 83 | vertexes); 84 | 85 | Triangle onXZPlane2 = 86 | Triangle.withVertexNormals( 87 | DUMMY_MATERIAL, vertexes, vertexes); 88 | 89 | assertThat(onXZPlane.intersectInObjectSpace(downZAxis)).isEmpty(); 90 | assertThat(onXZPlane2.intersectInObjectSpace(downZAxis)).isEmpty(); 91 | } 92 | 93 | @Test 94 | public void intersectInObjectSpace_almostParallelRayAndTriangleDoIntersect() { 95 | Ray downZAxis = new Ray(ORIGIN, new Vector(0, 0, -1)); 96 | 97 | Vector[] vertexes = {new Vector(-1, 0.1, -1), new Vector(0, -0.1, -2), new Vector(1, 0, -1)}; 98 | Triangle almostOnXzPlane = 99 | Triangle.withSurfaceNormals( 100 | DUMMY_MATERIAL, 101 | vertexes); 102 | 103 | Triangle almostOnXzPlane2 = 104 | Triangle.withVertexNormals(DUMMY_MATERIAL, vertexes, vertexes); 105 | 106 | assertThat(almostOnXzPlane.intersectInObjectSpace(downZAxis)).isPresent(); 107 | assertThat(almostOnXzPlane2.intersectInObjectSpace(downZAxis)).isPresent(); 108 | } 109 | 110 | @Test 111 | public void boundsComputedCorrectly() { 112 | Triangle t = Triangle.withSurfaceNormals( 113 | DUMMY_MATERIAL, new Vector(-5, 0, 2), new Vector(-3, 3, 3), new Vector(8, 2, 5)); 114 | 115 | assertThat(t.minBound()).isEqualTo(new Vector(-5, 0, 2)); 116 | assertThat(t.maxBound()).isEqualTo(new Vector(8, 3, 5)); 117 | } 118 | 119 | } 120 | -------------------------------------------------------------------------------- /src/test/java/me/kahlil/geometry/VectorTest.java: -------------------------------------------------------------------------------- 1 | package me.kahlil.geometry; 2 | 3 | import static org.junit.Assert.*; 4 | 5 | import java.util.Random; 6 | import me.kahlil.geometry.Vector; 7 | import org.junit.Test; 8 | import org.junit.runner.RunWith; 9 | import org.junit.runners.JUnit4; 10 | 11 | /** Unit tests for {@link Vector}. */ 12 | @RunWith(JUnit4.class) 13 | public class VectorTest { 14 | 15 | private static final double DELTA = .0001; 16 | private static final Vector ZERO_VEC = new Vector(0, 0, 0); 17 | 18 | @Test 19 | public void testConstructorsAndGetters() { 20 | Vector p0 = new Vector(0, 0, 0); 21 | assertEquals(p0, ZERO_VEC); 22 | 23 | Vector p1 = new Vector(1, 2, 3); 24 | assertEquals(p1.getX(), 1.0, DELTA); 25 | assertEquals(p1.getY(), 2.0, DELTA); 26 | assertEquals(p1.getZ(), 3.0, DELTA); 27 | 28 | Vector p2 = new Vector(1.0, 2.0, 3.0); 29 | assertEquals(p2.getX(), 1.0, DELTA); 30 | assertEquals(p2.getY(), 2.0, DELTA); 31 | assertEquals(p2.getZ(), 3.0, DELTA); 32 | } 33 | 34 | @Test 35 | public void testSubtract() { 36 | assertEquals(ZERO_VEC.subtract(ZERO_VEC), ZERO_VEC); 37 | 38 | Vector p1 = new Vector(1, 2, 3); 39 | assertEquals(p1.subtract(ZERO_VEC), p1); 40 | assertEquals(ZERO_VEC.subtract(p1), p1.scale(-1)); 41 | assertEquals(p1.subtract(p1), ZERO_VEC); 42 | 43 | Vector p2 = new Vector(-2, 4, -5); 44 | assertEquals(p1.subtract(p2), new Vector(3, -2, 8)); 45 | assertEquals(p2.subtract(p1), new Vector(-3, 2, -8)); 46 | } 47 | 48 | @Test 49 | public void testAdd() { 50 | assertEquals(ZERO_VEC.add(ZERO_VEC), ZERO_VEC); 51 | 52 | Vector p1 = new Vector(1, 2, 3); 53 | assertEquals(p1.add(ZERO_VEC), p1); 54 | assertEquals(ZERO_VEC.add(p1), p1); 55 | 56 | Vector p2 = new Vector(-4, 5, 2); 57 | assertEquals(p1.add(p2), new Vector(-3, 7, 5)); 58 | assertEquals(p1.add(p2).add(p2), new Vector(-7, 12, 7)); 59 | } 60 | 61 | @Test 62 | public void testScale() { 63 | assertEquals(ZERO_VEC.scale(0), ZERO_VEC); 64 | 65 | Vector p = new Vector(1, 2, 3); 66 | assertEquals(p.scale(1), p); 67 | assertEquals(p.scale(0), ZERO_VEC); 68 | 69 | assertEquals(p.scale(2), new Vector(2, 4, 6)); 70 | assertEquals(p.scale(1.5), new Vector(1.5, 3, 4.5)); 71 | assertEquals(p.scale(-3), new Vector(-3, -6, -9)); 72 | } 73 | 74 | @Test 75 | public void testDot() { 76 | assertEquals(ZERO_VEC.dot(ZERO_VEC), 0, DELTA); 77 | 78 | Vector p1 = new Vector(1, -2, 3); 79 | assertEquals(p1.dot(ZERO_VEC), 0, DELTA); 80 | 81 | Vector p2 = new Vector(-4, 4, 4); 82 | assertEquals(p1.dot(p2), 0, DELTA); 83 | 84 | Vector p3 = new Vector(1, 2, 2); 85 | assertEquals(p1.dot(p3), 3, DELTA); 86 | 87 | assertEquals(p2.dot(p3), 12, DELTA); 88 | } 89 | 90 | @Test 91 | public void testCross() { 92 | // Test with integer vectors 93 | for (int i = 0; i < 100; ++i) { 94 | Vector p1 = randomIntPoint(); 95 | Vector p2 = randomIntPoint(); 96 | Vector crossed = p1.cross(p2); 97 | // Check order switches sign 98 | assertEquals(crossed, p2.cross(p1).scale(-1)); 99 | // Check perpendicular 100 | assertEquals(crossed.dot(p1), 0, DELTA); 101 | assertEquals(crossed.dot(p2), 0, DELTA); 102 | } 103 | 104 | // Test with double vectors 105 | for (int i = 0; i < 100; ++i) { 106 | Vector p1 = randomDoublePoint(); 107 | Vector p2 = randomDoublePoint(); 108 | Vector crossed = p1.cross(p2); 109 | // Check order switches sign 110 | assertEquals(crossed, p2.cross(p1).scale(-1)); 111 | // Check perpendicular 112 | assertEquals(crossed.dot(p1), 0, DELTA); 113 | assertEquals(crossed.dot(p2), 0, DELTA); 114 | } 115 | } 116 | 117 | @Test 118 | public void testNormalize() { 119 | assertEquals( 120 | new Vector(2, 3, 4).normalize(), 121 | new Vector(2 / Math.sqrt(29), 3 / Math.sqrt(29), 4 / Math.sqrt(29))); 122 | for (int i = 0; i < 100; ++i) { 123 | assertEquals(randomIntPoint().normalize().magnitude(), 1, DELTA); 124 | assertEquals(randomDoublePoint().normalize().magnitude(), 1, DELTA); 125 | } 126 | } 127 | 128 | @Test 129 | public void testLength() { 130 | assertEquals(ZERO_VEC.magnitude(), 0, DELTA); 131 | 132 | Vector p1 = new Vector(2, 3, 4); 133 | assertEquals(p1.magnitude(), Math.sqrt(29), DELTA); 134 | } 135 | 136 | /** 137 | * Returns new point with integer components 138 | * 139 | * @return 140 | */ 141 | public Vector randomIntPoint() { 142 | Random rand = new Random(); 143 | return new Vector(rand.nextInt(100), rand.nextInt(100), rand.nextInt(100)); 144 | } 145 | 146 | /** 147 | * Returns new point with double components 148 | * 149 | * @return 150 | */ 151 | public Vector randomDoublePoint() { 152 | Random rand = new Random(); 153 | return new Vector(rand.nextDouble(), rand.nextDouble(), rand.nextDouble()); 154 | } 155 | } 156 | -------------------------------------------------------------------------------- /src/main/java/me/kahlil/graphics/PhongShading.java: -------------------------------------------------------------------------------- 1 | package me.kahlil.graphics; 2 | 3 | import static me.kahlil.graphics.RayIntersections.findAllIntersections; 4 | 5 | import com.google.common.annotations.VisibleForTesting; 6 | import com.google.common.collect.ImmutableList; 7 | import me.kahlil.geometry.LightSphere; 8 | import me.kahlil.geometry.Ray; 9 | import me.kahlil.geometry.RayHit; 10 | import me.kahlil.geometry.Vector; 11 | import me.kahlil.scene.Camera; 12 | import me.kahlil.scene.Material; 13 | import me.kahlil.scene.PointLight; 14 | import me.kahlil.scene.Scene; 15 | 16 | /** An implementation of the phong illumination model implementation of shading. */ 17 | public final class PhongShading implements Shader { 18 | 19 | private static final float SPECULAR_COEFFICIENT = 0.75f; 20 | private static final float DIFFUSE_COEFFICIENT = 0.5f; 21 | 22 | private Scene scene; 23 | private final Camera camera; 24 | private final boolean shadowsEnabled; 25 | 26 | public PhongShading(Scene scene, Camera camera, boolean shadowsEnabled) { 27 | this.scene = scene; 28 | this.camera = camera; 29 | this.shadowsEnabled = shadowsEnabled; 30 | } 31 | 32 | @Override 33 | public MutableColor shade(RayHit rayHit) { 34 | // Perform custom logic for LightSpheres since they are exceptional 35 | if (rayHit.getObject() instanceof LightSphere) { 36 | return shadeLightSphere(); 37 | } 38 | Material material = rayHit.getMaterial(); 39 | // Initialize color with ambient light 40 | MutableColor lighted = ColorComputation.of(scene.getAmbient()).multiply(material.getColor()).compute(); 41 | for (PointLight light : scene.getLights()) { 42 | // Check to see if shadow should be cast 43 | if (!shadowsEnabled || !isObjectBetweenLightAndPoint(light, rayHit.getIntersection())) { 44 | lighted = ColorComputation.modifyingInPlace(lighted) 45 | .add(phongIllumination(light, rayHit, camera.getLocation())) 46 | .compute(); 47 | } 48 | } 49 | return lighted; 50 | } 51 | 52 | 53 | /** 54 | * Returns the new color of a pixel given the color of the pixel that this light hits and the 55 | * diffuseCoefficient of that collision. 56 | */ 57 | private static MutableColor phongIllumination(PointLight light, RayHit rayHit, Vector cameraPosition) { 58 | double diffuse = diffuse(light, rayHit); 59 | double specular = specular(light, cameraPosition, rayHit); 60 | 61 | Material material = rayHit.getMaterial(); 62 | return ColorComputation.of(light.getColor()) 63 | .multiply(material.getColor()) 64 | .scaleFloat((float) diffuse) 65 | .scaleFloat(DIFFUSE_COEFFICIENT) 66 | .add(ColorComputation.of(light.getColor()) 67 | .scaleFloat((float) specular) 68 | .scaleFloat(SPECULAR_COEFFICIENT) 69 | .scaleFloat((float) material.getSpecularIntensity()) 70 | .compute()) 71 | .compute(); 72 | } 73 | 74 | /** 75 | * Returns the diffuse lighting value given a vector from the point on the object to the light 76 | * source and the normal vector to that point from the object. 77 | */ 78 | @VisibleForTesting 79 | static double diffuse(PointLight light, RayHit rayHit) { 80 | Vector intersection = rayHit.getIntersection(); 81 | Vector normal = rayHit.getNormal().normalize(); 82 | Vector lightVector = light.getLocation().subtract(intersection).normalize(); 83 | 84 | return Math.max(0, lightVector.dot(normal)); 85 | } 86 | 87 | /** Returns the specular light at a given RayHit with the given light and eye positions. */ 88 | @VisibleForTesting 89 | static double specular(PointLight light, Vector eyePos, RayHit rayHit) { 90 | Vector lightVec = light.getLocation().subtract(rayHit.getIntersection()).normalize(); 91 | Vector eyeVec = eyePos.subtract(rayHit.getIntersection()).normalize(); 92 | Vector normal = rayHit.getNormal(); 93 | Vector lProjectedOntoN = normal.scale(lightVec.dot(normal)); 94 | Vector lProjectedOntoPlane = lightVec.subtract(lProjectedOntoN); 95 | Vector reflectedLight = lightVec.subtract(lProjectedOntoPlane.scale(2)).normalize(); 96 | return Math.pow( 97 | Math.max(reflectedLight.dot(eyeVec), 0), 98 | rayHit.getMaterial().getHardness()); 99 | } 100 | 101 | private static MutableColor shadeLightSphere() { 102 | return Colors.WHITE; 103 | } 104 | 105 | /** Returns true iff there is an object in the scene between the light and the given point. */ 106 | private boolean isObjectBetweenLightAndPoint(PointLight l, Vector point) { 107 | Vector shadowVec = l.getLocation().subtract(point); 108 | ImmutableList allIntersections = 109 | findAllIntersections(new Ray(point.add(shadowVec.scale(.0001)), shadowVec), scene); 110 | return allIntersections 111 | .stream() 112 | .filter(rayHit -> !(rayHit.getObject() instanceof LightSphere)) 113 | .map(RayHit::getDistance) 114 | .anyMatch(distance -> distance < shadowVec.magnitude()); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/main/java/me/kahlil/geometry/PolygonSphere.java: -------------------------------------------------------------------------------- 1 | package me.kahlil.geometry; 2 | 3 | import java.util.Optional; 4 | import me.kahlil.scene.Material; 5 | 6 | /** 7 | * A procedurally generated representation of a sphere represented by polygons with radius 1. 8 | * 9 | * https://www.scratchapixel.com/lessons/3d-basic-rendering/ray-tracing-polygon-mesh/Ray-Tracing%20a%20Polygon%20Mesh-part-1 10 | */ 11 | public class PolygonSphere extends Shape { 12 | 13 | private final Material material; 14 | private final int numDivisions; 15 | private final ConvexPolygon polygon; 16 | private final boolean useVertexNormals; 17 | 18 | private PolygonSphere(Material material, int numDivisions, boolean useVertexNormals) { 19 | this.material = material; 20 | this.numDivisions = numDivisions; 21 | this.useVertexNormals = useVertexNormals; 22 | this.polygon = computePolygonSpecification(numDivisions); 23 | } 24 | 25 | public static PolygonSphere withSurfaceNormals(Material material, int numDivisions) { 26 | return new PolygonSphere(material, numDivisions, false); 27 | } 28 | 29 | public static PolygonSphere withVertexNormals(Material material, int numDivisions) { 30 | return new PolygonSphere(material, numDivisions, true); 31 | } 32 | 33 | @Override 34 | Optional internalIntersectInObjectSpace(Ray ray) { 35 | return polygon.intersectInObjectSpace(ray); 36 | } 37 | 38 | /** 39 | * Generates polygon representation of sphere by following code example over at: 40 | * https://www.scratchapixel.com/lessons/3d-basic-rendering/ray-tracing-polygon-mesh/Ray-Tracing%20a%20Polygon%20Mesh-part-1 41 | */ 42 | private ConvexPolygon computePolygonSpecification(int numDivisions) { 43 | int numVertexes = (numDivisions - 1) * numDivisions + 2; 44 | 45 | Vector[] vertexes = new Vector[numVertexes]; 46 | Vector[] normals = new Vector[numVertexes]; 47 | Vector[] barycentricCoordinates = new Vector[numVertexes]; 48 | 49 | generateVertexes(vertexes, normals, barycentricCoordinates); 50 | 51 | int numPolygons = numDivisions * numDivisions; 52 | int[] faces = new int[numPolygons]; 53 | int[] vertexIndexes = new int[(6 + (numDivisions - 1) * 4) * numDivisions]; 54 | generateConnectivity(faces, vertexIndexes); 55 | 56 | return useVertexNormals 57 | ? ConvexPolygon.withVertexNormals(material, vertexes, vertexes, faces, vertexIndexes) 58 | : ConvexPolygon.withSurfaceNormals(material, vertexes, faces, vertexIndexes); 59 | } 60 | 61 | /** 62 | * Generates the connectivity polygon data to populate the faces and vertex indexes arrays. 63 | */ 64 | private void generateConnectivity(int[] faces, int[] vertexIndexes) { 65 | int vid = 1; 66 | int numV = 0; 67 | int l = 0; 68 | int k = 0; 69 | 70 | for (int i = 0; i < numDivisions; i++) { 71 | for (int j = 0; j < numDivisions; j++) { 72 | if (i == 0) { 73 | faces[k++] = 3; 74 | vertexIndexes[l] = 0; 75 | vertexIndexes[l + 1] = j + vid; 76 | vertexIndexes[l + 2] = (j == (numDivisions - 1)) ? vid : j + vid + 1; 77 | l += 3; 78 | } 79 | else if (i == (numDivisions - 1)) { 80 | faces[k++] = 3; 81 | vertexIndexes[l] = j + vid + 1 - numDivisions; 82 | vertexIndexes[l + 1] = vid + 1; 83 | vertexIndexes[l + 2] = (j == (numDivisions - 1)) ? vid + 1 - numDivisions : j + vid + 2 - numDivisions; 84 | l += 3; 85 | } 86 | else { 87 | faces[k++] = 4; 88 | vertexIndexes[l] = j + vid + 1 - numDivisions; 89 | vertexIndexes[l + 1] = j + vid + 1; 90 | vertexIndexes[l + 2] = (j == (numDivisions - 1)) ? vid + 1 : j + vid + 2; 91 | vertexIndexes[l + 3] = (j == (numDivisions - 1)) ? vid + 1 - numDivisions : j + vid + 2 - numDivisions; 92 | l += 4; 93 | } 94 | numV++; 95 | } 96 | vid = numV; 97 | } 98 | } 99 | 100 | /** 101 | * Generates vertexes, normals, and barycentric coordinates for the sphere and stores them in 102 | * the passed arrays. 103 | */ 104 | private void generateVertexes(Vector[] vertexes, Vector[] normals, 105 | Vector[] barycentricCoordinates) { 106 | double u = -1 * Math.PI / 2; 107 | double v; 108 | double du = Math.PI / numDivisions; 109 | double dv = 2 * Math.PI / numDivisions; 110 | 111 | vertexes[0] = new Vector(0, -1.0, 0); 112 | normals[0] = vertexes[0]; 113 | 114 | int k = 1; 115 | for (int i = 0; i < numDivisions - 1; i++) { 116 | u += du; 117 | v = -1 * Math.PI; 118 | for (int j = 0; j < numDivisions; j++) { 119 | double x = Math.cos(u) * Math.cos(v); 120 | double y = Math.sin(u); 121 | double z = Math.cos(u) * Math.sin(v); 122 | 123 | vertexes[k] = new Vector(x, y, z); 124 | normals[k] = vertexes[k]; 125 | 126 | barycentricCoordinates[k] = new Vector(u / Math.PI + 0.5, v * 0.5 / Math.PI + 0.5); 127 | v += dv; 128 | k++; 129 | } 130 | } 131 | vertexes[k] = new Vector(0, 1,0); 132 | normals[k] = vertexes[k]; 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/main/java/me/kahlil/geometry/Vector.java: -------------------------------------------------------------------------------- 1 | package me.kahlil.geometry; 2 | 3 | 4 | import static me.kahlil.geometry.Constants.EPSILON; 5 | 6 | import me.kahlil.graphics.Point2D; 7 | 8 | /** A triple of doubles that represents a point or a vector in 3 dimensional space. */ 9 | public class Vector { 10 | 11 | // Coordinates of vector in 3D space 12 | private final double x; 13 | private final double y; 14 | private final double z; 15 | 16 | // 4th-dimensional coordinate used for matrix transforms 17 | private final double w; 18 | 19 | public Vector(double x, double y) { 20 | this(x, y, 0.0); 21 | } 22 | 23 | public Vector(double x, double y, double z) { 24 | this(x, y, z, 0); 25 | } 26 | 27 | public Vector(double x, double y, double z, double w) { 28 | this.x = x; 29 | this.y = y; 30 | this.z = z; 31 | this.w = w; 32 | } 33 | 34 | public Vector(Point2D inCameraSpace) { 35 | this(inCameraSpace.getX(), inCameraSpace.getY(), 0.0); 36 | } 37 | 38 | /** 39 | * return the vector obtained by subtracting q from this point 40 | * 41 | * @param q 42 | * @return 43 | */ 44 | public Vector subtract(Vector q) { 45 | return this.add(q.scale(-1)); 46 | } 47 | 48 | /** 49 | * return the point obtained by adding q to this point 50 | * 51 | * @param q 52 | * @return 53 | */ 54 | public Vector add(Vector q) { 55 | return new Vector( 56 | getX() + q.getX(), getY() + q.getY(), getZ() + q.getZ(), Math.min(w + q.w, 1)); 57 | } 58 | 59 | public Vector translate(double x, double y) { 60 | return this.translate(x, y, 0.0); 61 | } 62 | 63 | /** 64 | * Translates this 3-entry vector by the specified units 65 | * 66 | * @param x 67 | * @param y 68 | * @param z 69 | * @return 70 | */ 71 | public Vector translate(double x, double y, double z) { 72 | return new Vector(getX() + x, getY() + y, getZ() + z, w); 73 | } 74 | 75 | /** 76 | * return the point obtained by scaling this point by a 77 | * 78 | * @param a 79 | * @return 80 | */ 81 | public Vector scale(double a) { 82 | return new Vector(getX() * a, getY() * a, getZ() * a, w); 83 | } 84 | 85 | /** 86 | * return the dot product of this point and q, ignoring the 4th component 87 | * 88 | * @param q 89 | * @return 90 | */ 91 | public double dot(Vector q) { 92 | return getX() * q.getX() + getY() * q.getY() + getZ() * q.getZ(); 93 | } 94 | 95 | /** 96 | * Returns the dot product of this vector and q, taking the 4th component into account 97 | * 98 | * @param q 99 | * @return 100 | */ 101 | public double dot4D(Vector q) { 102 | return getX() * q.getX() + getY() * q.getY() + getZ() * q.getZ() + w * q.w; 103 | } 104 | 105 | /** 106 | * return the cross product of this point and q 107 | * 108 | * @param q 109 | * @return 110 | */ 111 | public Vector cross(Vector q) { 112 | return new Vector( 113 | getY() * q.getZ() - getZ() * q.getY(), 114 | getZ() * q.getX() - getX() * q.getZ(), 115 | getX() * q.getY() - getY() * q.getX()); 116 | } 117 | 118 | public Vector average(Vector other) { 119 | return new Vector( 120 | (getX() + other.getX()) * 0.5, 121 | (getY() + other.getY()) * 0.5, 122 | (getZ() + other.getZ()) * 0.5); 123 | } 124 | 125 | /** 126 | * return the normalization of this vector 127 | * 128 | * @return 129 | */ 130 | public Vector normalize() { 131 | double length = this.magnitude(); 132 | return new Vector(getX() / length, getY() / length, getZ() / length, w); 133 | } 134 | 135 | /** 136 | * return the magnitude of this vector 137 | * 138 | * @return 139 | */ 140 | public double magnitude() { 141 | return Math.sqrt(Math.pow(x, 2) + Math.pow(y, 2) + Math.pow(z, 2)); 142 | } 143 | 144 | /** 145 | * Returns x coordinate of point 146 | * 147 | * @return 148 | */ 149 | public double getX() { 150 | return x; 151 | } 152 | 153 | /** 154 | * Returns y coordinate of point 155 | * 156 | * @return 157 | */ 158 | public double getY() { 159 | return y; 160 | } 161 | 162 | public double getComponent(int i) { 163 | if (i == 0) { 164 | return x; 165 | } 166 | if (i == 1) { 167 | return y; 168 | } 169 | if (i == 2) { 170 | return z; 171 | } 172 | throw new IllegalArgumentException(String.format("Can only index into vector [0, 1, 2]. Found %d.", i)); 173 | } 174 | 175 | /** 176 | * Returns z coordinate of point 177 | * 178 | * @return 179 | */ 180 | public double getZ() { 181 | return z; 182 | } 183 | 184 | @Override 185 | public String toString() { 186 | return String.format("(%.2f, %.2f, %.2f, %.2f)", x, y, z, w); 187 | } 188 | 189 | @Override 190 | public boolean equals(Object o) { 191 | if (this == o) return true; 192 | if (o == null || getClass() != o.getClass()) return false; 193 | 194 | Vector vector = (Vector) o; 195 | 196 | if (Math.abs(vector.x - x) > EPSILON) return false; 197 | if (Math.abs(vector.y - y) > EPSILON) return false; 198 | return Math.abs(vector.z - z) < EPSILON; 199 | } 200 | 201 | @Override 202 | public int hashCode() { 203 | int result; 204 | long temp; 205 | temp = Double.doubleToLongBits(x); 206 | result = (int) (temp ^ (temp >>> 32)); 207 | temp = Double.doubleToLongBits(y); 208 | result = 31 * result + (int) (temp ^ (temp >>> 32)); 209 | temp = Double.doubleToLongBits(z); 210 | result = 31 * result + (int) (temp ^ (temp >>> 32)); 211 | return result; 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /src/main/java/me/kahlil/geometry/Triangle.java: -------------------------------------------------------------------------------- 1 | package me.kahlil.geometry; 2 | 3 | import static com.google.common.base.Preconditions.checkArgument; 4 | import static java.lang.Double.NEGATIVE_INFINITY; 5 | import static java.lang.Double.POSITIVE_INFINITY; 6 | import static java.lang.Math.abs; 7 | import static me.kahlil.config.Counters.NUM_TRIANGLE_INTERSECTIONS; 8 | import static me.kahlil.config.Counters.NUM_TRIANGLE_TESTS; 9 | import static me.kahlil.geometry.Constants.EPSILON; 10 | 11 | import java.util.Arrays; 12 | import java.util.Optional; 13 | import me.kahlil.scene.Material; 14 | 15 | public class Triangle extends Shape implements Polygon { 16 | 17 | private final Material material; 18 | 19 | // Array of size-3 containing the vertexes of the triangle. 20 | private final Vector[] vertexes; 21 | 22 | // Array of size 3 containing the vertex normals of the triangle. 23 | private final Vector[] vertexNormals; 24 | 25 | private final Vector minBound; 26 | private final Vector maxBound; 27 | 28 | private Triangle( 29 | Material material, 30 | Vector[] vertexes, 31 | Vector[] vertexNormals) { 32 | checkArgument(vertexes.length == 3, "A triangle must have 3 vertexes. Found: %s", Arrays.toString(vertexes)); 33 | checkArgument(vertexNormals.length == 0 || vertexNormals.length == 3, "A triangle must have no vertex normals or 3 0 surface normals. Found: %s", Arrays.toString(vertexes)); 34 | this.material = material; 35 | this.vertexes = vertexes; 36 | this.vertexNormals = vertexNormals; 37 | 38 | double[][] minMaxBounds = computeMinMaxBounds(); 39 | this.minBound = new Vector(minMaxBounds[0][0], minMaxBounds[0][1], minMaxBounds[0][2]); 40 | this.maxBound = new Vector(minMaxBounds[1][0], minMaxBounds[1][1], minMaxBounds[1][2]); 41 | } 42 | 43 | public static Triangle withSurfaceNormals( 44 | Material material, 45 | Vector vertex0, 46 | Vector vertex1, 47 | Vector vertex2) { 48 | return withSurfaceNormals(material, new Vector[]{vertex0, vertex1, vertex2}); 49 | } 50 | 51 | public static Triangle withSurfaceNormals( 52 | Material material, 53 | Vector[] vertexes) { 54 | return new Triangle(material, vertexes, new Vector[]{}); 55 | } 56 | 57 | public static Triangle withVertexNormals( 58 | Material material, 59 | Vector[] vertexes, 60 | Vector[] normals) { 61 | return new Triangle(material, vertexes, normals); 62 | } 63 | 64 | public static Triangle equilateralTriangle(Material material) { 65 | return Triangle.withSurfaceNormals( 66 | material, 67 | new Vector(1, 0, 0), 68 | new Vector(0, 1, 0), 69 | new Vector(0, 0, 1)); 70 | } 71 | 72 | /** 73 | * Perform Moller-Trumbore alogorithm for efficient ray-triangle intersection, described at: 74 | * https://www.scratchapixel.com/lessons/3d-basic-rendering/ray-tracing-rendering-a-triangle/moller-trumbore-ray-triangle-intersection 75 | */ 76 | @Override 77 | Optional internalIntersectInObjectSpace(Ray ray) { 78 | NUM_TRIANGLE_TESTS.getAndIncrement(); 79 | 80 | Vector p0p1 = vertexes[1].subtract(vertexes[0]); 81 | Vector p0p2 = vertexes[2].subtract(vertexes[0]); 82 | 83 | Vector pVec = ray.getDirection().cross(p0p2); 84 | double determinant = p0p1.dot(pVec); 85 | 86 | // Ray and triangle are parallel if determinant is too close to zero. 87 | if (abs(determinant) < EPSILON) { 88 | return Optional.empty(); 89 | } 90 | 91 | double inverseDeterminant = 1 / determinant; 92 | 93 | // Compute barycentric coordinates. 94 | Vector tVec = ray.getStart().subtract(vertexes[0]); 95 | Vector qVec = tVec.cross(p0p1); 96 | 97 | double u = tVec.dot(pVec) * inverseDeterminant; 98 | if (u < 0 || u > 1) { return Optional.empty(); } 99 | 100 | double v = ray.getDirection().dot(qVec) * inverseDeterminant; 101 | if (v < 0 || u + v > 1) { return Optional.empty(); } 102 | 103 | double t = p0p2.dot(qVec) * inverseDeterminant; 104 | if (t < 0) { 105 | return Optional.empty(); 106 | } 107 | 108 | Vector normal = vertexNormals.length == 3 109 | ? interpolateNormals(vertexNormals, u, v) 110 | : p0p1.cross(p0p2).normalize(); 111 | 112 | NUM_TRIANGLE_INTERSECTIONS.getAndIncrement(); 113 | 114 | return Optional.of(ImmutableRayHit.builder() 115 | .setObject(this) 116 | .setTime(t) 117 | .setNormal(normal) 118 | .setMaterial(material) 119 | .setRay(ray) 120 | .build()); 121 | } 122 | 123 | @Override 124 | public Triangle[] getTriangles() { 125 | return new Triangle[]{this}; 126 | } 127 | 128 | @Override 129 | public Vector minBound() { 130 | return this.minBound; 131 | } 132 | 133 | @Override 134 | public Vector maxBound() { 135 | return this.maxBound; 136 | } 137 | 138 | @Override 139 | public String toString() { 140 | return String.format("Triangle[%s %s %s]", vertexes[0], vertexes[1], vertexes[2]); 141 | } 142 | 143 | public Vector[] getVertexes() { 144 | return this.vertexes; 145 | } 146 | 147 | private static Vector interpolateNormals(Vector[] normals, double u, double v) { 148 | double w = 1 - u - v; 149 | checkArgument(0 <= w && w <= 1); 150 | 151 | return normals[0].scale(w).add(normals[1].scale(u)).add(normals[2].scale(v)); 152 | } 153 | 154 | 155 | private double[][] computeMinMaxBounds() { 156 | double[] minXyz = new double[3]; 157 | Arrays.fill(minXyz, POSITIVE_INFINITY); 158 | double[] maxXyz = new double[3]; 159 | Arrays.fill(maxXyz, NEGATIVE_INFINITY); 160 | 161 | for (Vector vertex : vertexes) { 162 | for (int i = 0; i < 3; i++) { 163 | if (vertex.getComponent(i) < minXyz[i]) { 164 | minXyz[i] = vertex.getComponent(i); 165 | } 166 | if (vertex.getComponent(i) > maxXyz[i]) { 167 | maxXyz[i] = vertex.getComponent(i); 168 | } 169 | } 170 | } 171 | return new double[][]{minXyz, maxXyz}; 172 | } 173 | 174 | @Override 175 | public boolean equals(Object o) { 176 | if (this == o) { 177 | return true; 178 | } 179 | if (o == null || getClass() != o.getClass()) { 180 | return false; 181 | } 182 | Triangle triangle = (Triangle) o; 183 | return Arrays.equals(vertexes, triangle.vertexes) && 184 | Arrays.equals(vertexNormals, triangle.vertexNormals); 185 | } 186 | 187 | @Override 188 | public int hashCode() { 189 | int result = Arrays.hashCode(vertexes); 190 | result = 31 * result + Arrays.hashCode(vertexNormals); 191 | return result; 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /src/test/java/me/kahlil/geometry/ShapeTest.java: -------------------------------------------------------------------------------- 1 | package me.kahlil.geometry; 2 | 3 | import static com.google.common.truth.Truth.assertThat; 4 | import static com.google.common.truth.Truth8.assertThat; 5 | import static me.kahlil.graphics.Colors.BLACK; 6 | import static me.kahlil.graphics.Colors.WHITE; 7 | import static me.kahlil.geometry.Constants.EPSILON; 8 | import static me.kahlil.geometry.Constants.ORIGIN; 9 | import static me.kahlil.geometry.LinearTransformation.IDENTITY; 10 | import static me.kahlil.geometry.LinearTransformation.rotateAboutXAxis; 11 | import static me.kahlil.geometry.LinearTransformation.rotateAboutZAxis; 12 | import static me.kahlil.geometry.LinearTransformation.scale; 13 | import static me.kahlil.geometry.LinearTransformation.translate; 14 | import static me.kahlil.scene.Cameras.STANDARD_CAMERA; 15 | import static me.kahlil.scene.Materials.DUMMY_MATERIAL; 16 | 17 | import me.kahlil.graphics.MutableColor; 18 | import java.util.Arrays; 19 | import java.util.Optional; 20 | import me.kahlil.graphics.PhongShading; 21 | import me.kahlil.scene.ImmutablePointLight; 22 | import me.kahlil.scene.ImmutableScene; 23 | import me.kahlil.scene.Scene; 24 | import org.junit.Test; 25 | import org.junit.runner.RunWith; 26 | import org.junit.runners.JUnit4; 27 | 28 | @RunWith(JUnit4.class) 29 | public class ShapeTest { 30 | 31 | @Test 32 | public void transformMethod() { 33 | PointObject beforeTransformation = new PointObject(0, 0, 0); 34 | assertThat(beforeTransformation.getTransformation()).isEqualTo(IDENTITY); 35 | 36 | PointObject afterTransformation = beforeTransformation.transform(translate(1.0, 1.0, 1.0)); 37 | 38 | assertThat(afterTransformation.getTransformation().getMatrix()) 39 | .isEqualTo(translate(1.0, 1.0, 1.0).getMatrix()); 40 | 41 | afterTransformation = beforeTransformation.transform(rotateAboutZAxis(90)); 42 | assertThat(afterTransformation.getTransformation().getMatrix()) 43 | .isEqualTo(rotateAboutZAxis(90).getMatrix()); 44 | 45 | afterTransformation = 46 | beforeTransformation.transform(translate(1.0, 1.0, 1.0)).transform(rotateAboutZAxis(90)); 47 | assertThat(afterTransformation.getTransformation().getMatrix()) 48 | .isEqualTo(translate(1.0, 1.0, 1.0).then(rotateAboutZAxis(90)).getMatrix()); 49 | } 50 | 51 | @Test 52 | public void translatedIntersection() { 53 | PointObject beforeTranslation = new PointObject(0, 0, -1); 54 | PointObject afterTranslation = beforeTranslation.transform(translate(0.0, -1.0, 0.0)); 55 | Ray towardsOriginal = new Ray(ORIGIN, new Vector(0, 0, -1)); 56 | Ray towardsTranslated = new Ray(ORIGIN, new Vector(0, -1, -1)); 57 | 58 | assertThat(beforeTranslation.intersectWith(towardsOriginal)).isPresent(); 59 | assertThat(beforeTranslation.intersectWith(towardsTranslated)).isEmpty(); 60 | 61 | assertThat(afterTranslation.intersectWith(towardsOriginal)).isEmpty(); 62 | assertThat(afterTranslation.intersectWith(towardsTranslated)).isPresent(); 63 | } 64 | 65 | @Test 66 | public void rotatedAboutZAxisIntersection() { 67 | PointObject beforeRotation = new PointObject(1.0, 0.0, 0.0); 68 | PointObject afterRotation = beforeRotation.transform(rotateAboutZAxis(90)); 69 | Ray towardsOriginal = new Ray(ORIGIN, new Vector(1.0, 0, 0.0)); 70 | Ray towardsTranslated = new Ray(ORIGIN, new Vector(0, 1.0, 0.0)); 71 | 72 | assertThat(beforeRotation.intersectWith(towardsOriginal)).isPresent(); 73 | assertThat(beforeRotation.intersectWith(towardsTranslated)).isEmpty(); 74 | 75 | assertThat(afterRotation.intersectWith(towardsOriginal)).isEmpty(); 76 | assertThat(afterRotation.intersectWith(towardsTranslated)).isPresent(); 77 | } 78 | 79 | @Test 80 | public void sphereRotationDoesNotAffectRayNormal() { 81 | Sphere notRotated = new Sphere(DUMMY_MATERIAL).transform(translate(0.0, 0.0, -2.0)); 82 | 83 | Sphere rotated = 84 | new Sphere(DUMMY_MATERIAL) 85 | .transform(rotateAboutXAxis(90)) 86 | .transform(translate(0.0, 0.0, -2.0)); 87 | 88 | Ray towardsSphere = new Ray(new Vector(0, 0, 0), new Vector(0, 0, -1.0)); 89 | 90 | // Alter object field, since it will be different. 91 | Optional maybeNotRotatedHit = notRotated.intersectWith(towardsSphere); 92 | Optional maybeRotatedHit = rotated.intersectWith(towardsSphere); 93 | assertThat(maybeNotRotatedHit).isPresent(); 94 | assertThat(maybeRotatedHit).isPresent(); 95 | 96 | RayHit expectedRayHit = 97 | ImmutableRayHit.builder().from(maybeNotRotatedHit.get()).setObject(rotated).build(); 98 | assertThat(maybeRotatedHit.get()).isEqualTo(expectedRayHit); 99 | } 100 | 101 | @Test 102 | public void sphereRotationDoesNotAffectShading() { 103 | Sphere notRotated = new Sphere(DUMMY_MATERIAL).transform(translate(0.0, 0.0, -2.0)); 104 | 105 | Sphere rotated = 106 | new Sphere(DUMMY_MATERIAL) 107 | .transform(rotateAboutXAxis(90)) 108 | .transform(translate(0.0, 0.0, -2.0)); 109 | 110 | Ray towardsSphere = new Ray(new Vector(0, 0, 0), new Vector(-0.25, -0.50, -1.0)); 111 | 112 | // Compute Phong shading for both and compare. 113 | MutableColor expectedColor = 114 | new PhongShading(simpleScene(notRotated), STANDARD_CAMERA, true) 115 | .shade(notRotated.intersectWith(towardsSphere).get()); 116 | MutableColor actualColor = 117 | new PhongShading(simpleScene(rotated), STANDARD_CAMERA, true) 118 | .shade(rotated.intersectWith(towardsSphere).get()); 119 | assertThat(actualColor).isEqualTo(expectedColor); 120 | } 121 | 122 | @Test 123 | public void sphereScaling() { 124 | Sphere scaled = 125 | new Sphere(DUMMY_MATERIAL) 126 | .transform(translate(1.0, 0.0, -1.0)) 127 | .transform(scale(0.1)); 128 | 129 | Ray towardsMiddle = new Ray(new Vector(0, 0, 0), new Vector(1.0, 0.0, -1.0)); 130 | Ray insideEdge = new Ray( 131 | new Vector(0, 0, 0), 132 | new Vector(EPSILON, 0.0, -1.0)); 133 | Ray outsideEdge = new Ray( 134 | new Vector(0, 0, 0), 135 | new Vector(-1 * EPSILON, 0.0, -1.0)); 136 | 137 | assertThat(scaled.intersectWith(towardsMiddle)).isPresent(); 138 | // Check edges of scaled sphere 139 | assertThat(scaled.intersectWith(insideEdge)).isPresent(); 140 | assertThat(scaled.intersectWith(outsideEdge)).isEmpty(); 141 | } 142 | 143 | private static Scene simpleScene(Shape... shapes) { 144 | return ImmutableScene.builder() 145 | .setAmbient(BLACK) 146 | .setBackgroundColor(BLACK) 147 | .addLights( 148 | ImmutablePointLight.builder().setLocation(new Vector(0, 2, 5)).setColor(WHITE).build()) 149 | .addAllShapes(Arrays.asList(shapes)) 150 | .build(); 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /src/main/java/me/kahlil/geometry/LinearTransformation.java: -------------------------------------------------------------------------------- 1 | package me.kahlil.geometry; 2 | 3 | import static java.lang.Math.cos; 4 | import static java.lang.Math.sin; 5 | import static java.lang.Math.toRadians; 6 | 7 | import java.util.Objects; 8 | import java.util.function.UnaryOperator; 9 | 10 | /** Representation of a given linear transformation (i.e. translate, scale, rotate). */ 11 | public class LinearTransformation implements UnaryOperator { 12 | 13 | // Identity map 14 | static final LinearTransformation IDENTITY = 15 | new LinearTransformation(Matrix.IDENTITY, Matrix.IDENTITY); 16 | 17 | private Matrix matrix; 18 | private Matrix inverse; 19 | 20 | private LinearTransformation(Matrix matrix, Matrix inverse) { 21 | this.matrix = matrix; 22 | this.inverse = inverse; 23 | } 24 | 25 | /** Factory method to construct matrices that are orthogonal (i.e. inverse(M) = transpose(M)). */ 26 | private static LinearTransformation transformationForOrthogonalMatrix(Matrix transform) { 27 | return new LinearTransformation(transform, transform.transpose()); 28 | } 29 | 30 | /** 31 | * Returns a matrix that will translates a vector x units in the x-direction, y units in the 32 | * y-direction, and z units in the z-direction 33 | */ 34 | public static LinearTransformation translate(double x, double y, double z) { 35 | return new LinearTransformation( 36 | new Matrix( 37 | new double[][] { 38 | {1, 0, 0, x}, 39 | {0, 1, 0, y}, 40 | {0, 0, 1, z}, 41 | {0, 0, 0, 1} 42 | }), 43 | new Matrix( 44 | new double[][] { 45 | {1, 0, 0, -1 * x}, 46 | {0, 1, 0, -1 * y}, 47 | {0, 0, 1, -1 * z}, 48 | {0, 0, 0, 1} 49 | })); 50 | } 51 | 52 | /** 53 | * Returns the translating transformation with each of v's components as each translation factor. 54 | */ 55 | public static LinearTransformation translate(Vector v) { 56 | return translate(v.getX(), v.getY(), v.getZ()); 57 | } 58 | 59 | /** 60 | * Uniformly scales (x, y, z) components by factor. 61 | */ 62 | public static LinearTransformation scale(double factor) { 63 | return scale(factor, factor, factor); 64 | } 65 | 66 | /** Returns the scaling transformation with each of v's components as each scaling factor. */ 67 | public static LinearTransformation scale(Vector v) { 68 | return scale(v.getX(), v.getY(), v.getZ()); 69 | } 70 | 71 | /** 72 | * Returns a matrix that scales a vector (v1, v2, v3) to (v1 * xFactor, v2 * yFactor, v3 * 73 | * zFactor). 74 | */ 75 | public static LinearTransformation scale(double xFactor, double yFactor, double zFactor) { 76 | return new LinearTransformation( 77 | new Matrix( 78 | new double[][] { 79 | {xFactor, 0, 0, 0}, 80 | {0, yFactor, 0, 0}, 81 | {0, 0, zFactor, 0}, 82 | {0, 0, 0, 1} 83 | }), 84 | new Matrix( 85 | new double[][] { 86 | {1 / xFactor, 0, 0, 0}, 87 | {0, 1 / yFactor, 0, 0}, 88 | {0, 0, 1 / zFactor, 0}, 89 | {0, 0, 0, 1} 90 | })); 91 | } 92 | 93 | /** Returns the rotation of the Vector v about the x axis counterclockwise by theta degrees. */ 94 | public static LinearTransformation rotateAboutXAxis(double degrees) { 95 | // Convert theta from degrees to radians. 96 | degrees = toRadians(degrees); 97 | Matrix rotateAboutX = 98 | new Matrix( 99 | new double[][] { 100 | {1, 0, 0, 0}, 101 | {0, cos(degrees), -1.0 * sin(degrees), 0}, 102 | {0, sin(degrees), cos(degrees), 0}, 103 | {0, 0, 0, 1} 104 | }); 105 | return transformationForOrthogonalMatrix(rotateAboutX); 106 | } 107 | 108 | /** Returns the rotation of the Vector v about the x axis counterclockwise by theta degrees. */ 109 | public static LinearTransformation rotateAboutYAxis(double degrees) { 110 | // Convert theta from degrees to radians. 111 | degrees = toRadians(degrees); 112 | Matrix rotateAboutY = 113 | new Matrix( 114 | new double[][] { 115 | {cos(degrees), 0, sin(degrees), 0}, 116 | {0, 1.0, 0, 0}, 117 | {-1.0 * sin(degrees), 0, cos(degrees), 0}, 118 | {0, 0, 0, 1} 119 | }); 120 | return transformationForOrthogonalMatrix(rotateAboutY); 121 | } 122 | 123 | /** Returns the rotation of the Vector v about the x axis counterclockwise by theta degrees. */ 124 | public static LinearTransformation rotateAboutZAxis(double degrees) { 125 | // Convert theta from degrees to radians. 126 | degrees = toRadians(degrees); 127 | Matrix rotateAboutZ = 128 | new Matrix( 129 | new double[][] { 130 | {cos(degrees), -1.0 * sin(degrees), 0, 0}, 131 | {sin(degrees), cos(degrees), 0, 0}, 132 | {0, 0, 1, 0}, 133 | {0, 0, 0, 1} 134 | }); 135 | return transformationForOrthogonalMatrix(rotateAboutZ); 136 | } 137 | 138 | /** Applies this linear transformation to the given vector. */ 139 | @Override 140 | public Vector apply(Vector vector) { 141 | return matrix.multiply(vector); 142 | } 143 | 144 | /** 145 | * Returns the linear transformation that represents applying the current linear transformation, 146 | * followed by applying lt. 147 | */ 148 | public LinearTransformation then(LinearTransformation lt) { 149 | return new LinearTransformation( 150 | lt.matrix.multiply(this.matrix), this.inverse.multiply(lt.inverse)); 151 | } 152 | 153 | /** Returns the inverse linear transformation. */ 154 | public LinearTransformation inverse() { 155 | return new LinearTransformation(inverse, matrix); 156 | } 157 | 158 | /** Returns the transpose matrix of the inverse linear transformation */ 159 | public LinearTransformation inverseTranspose() { 160 | return new LinearTransformation(inverse.transpose(), matrix.transpose()); 161 | } 162 | 163 | @Override 164 | public boolean equals(Object o) { 165 | if (this == o) { 166 | return true; 167 | } 168 | if (o == null || getClass() != o.getClass()) { 169 | return false; 170 | } 171 | LinearTransformation that = (LinearTransformation) o; 172 | return Objects.equals(matrix, that.matrix) && Objects.equals(inverse, that.inverse); 173 | } 174 | 175 | Matrix getMatrix() { 176 | return this.matrix; 177 | } 178 | 179 | @Override 180 | public int hashCode() { 181 | return Objects.hash(matrix, inverse); 182 | } 183 | 184 | public String toString() { 185 | return String.format("Linear transformation represented by matrix:\n%s", matrix.toString()); 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /src/main/java/me/kahlil/demos/Demo.java: -------------------------------------------------------------------------------- 1 | package me.kahlil.demos; 2 | 3 | import static me.kahlil.config.Parameters.IMAGES_DEMO_PNG_PATH; 4 | import static me.kahlil.config.Parameters.IMAGE_SIZE; 5 | import static me.kahlil.config.Parameters.MAX_RAY_DEPTH; 6 | import static me.kahlil.config.Parameters.NUM_ANTI_ALIASING_SAMPLES; 7 | import static me.kahlil.config.Parameters.SHADOWS_ENABLED; 8 | import static me.kahlil.geometry.ConvexPolygon.cube; 9 | import static me.kahlil.geometry.LinearTransformation.rotateAboutYAxis; 10 | import static me.kahlil.geometry.LinearTransformation.rotateAboutZAxis; 11 | import static me.kahlil.geometry.LinearTransformation.scale; 12 | import static me.kahlil.geometry.LinearTransformation.translate; 13 | import static me.kahlil.geometry.Triangle.equilateralTriangle; 14 | import static me.kahlil.graphics.Colors.BLUE; 15 | import static me.kahlil.graphics.Colors.CYAN; 16 | import static me.kahlil.graphics.Colors.GREEN; 17 | import static me.kahlil.graphics.Colors.MAGENTA; 18 | import static me.kahlil.scene.Cameras.STANDARD_CAMERA; 19 | import static me.kahlil.scene.Materials.REFLECTIVE; 20 | import static me.kahlil.scene.Materials.glossy; 21 | import static me.kahlil.scene.Materials.shiny; 22 | 23 | import com.google.common.collect.ImmutableList; 24 | import java.awt.image.BufferedImage; 25 | import java.io.File; 26 | import java.io.IOException; 27 | import java.util.List; 28 | import java.util.concurrent.ExecutionException; 29 | import javax.imageio.ImageIO; 30 | import me.kahlil.geometry.Plane; 31 | import me.kahlil.geometry.PolygonSphere; 32 | import me.kahlil.geometry.Shape; 33 | import me.kahlil.geometry.Sphere; 34 | import me.kahlil.geometry.Vector; 35 | import me.kahlil.graphics.MutableColor; 36 | import me.kahlil.graphics.PhongShading; 37 | import me.kahlil.graphics.RandomAntiAliasingMethod; 38 | import me.kahlil.graphics.RayTracer; 39 | import me.kahlil.graphics.RayTracerCoordinator; 40 | import me.kahlil.graphics.ReflectiveRayTracer; 41 | import me.kahlil.graphics.SimpleAntiAliaser; 42 | import me.kahlil.scene.Camera; 43 | import me.kahlil.scene.ImmutablePointLight; 44 | import me.kahlil.scene.ImmutableScene; 45 | import me.kahlil.scene.PointLight; 46 | import me.kahlil.scene.Raster; 47 | import me.kahlil.scene.Scene; 48 | 49 | /** A second demo of the ray tracer. */ 50 | public class Demo { 51 | 52 | public static void main(String[] args) throws InterruptedException, ExecutionException { 53 | 54 | Raster raster = new Raster(IMAGE_SIZE, IMAGE_SIZE); 55 | 56 | ImmutableList shapes = 57 | ImmutableList.of( 58 | PolygonSphere.withVertexNormals(glossy().setColor(GREEN).build(), 100).transform(translate(2, 0, -7)), 59 | PolygonSphere.withSurfaceNormals(glossy().setColor(BLUE).build(), 100).transform(translate(-2, 0, -7)), 60 | // new Sphere(glossy().setColor(RED).build()).transform(translate(2, 0, -7)), 61 | new Sphere(shiny().setColor(GREEN).build()).transform(translate(-4, 0, -10)), 62 | new Sphere(glossy().setColor(BLUE).build()).transform(translate(-2, 0, -15)), 63 | equilateralTriangle(shiny().setColor(CYAN).build()) 64 | .transform(scale(3.0).then(translate(2, 4, -15))), 65 | equilateralTriangle(shiny().setColor(CYAN).build()) 66 | .transform(scale(3.0).then(rotateAboutZAxis(90)).then(translate(2, 4, -15))), 67 | equilateralTriangle(shiny().setColor(CYAN).build()) 68 | .transform(scale(3.0).then(rotateAboutZAxis(180)).then(translate(2, 4, -15))), 69 | equilateralTriangle(shiny().setColor(CYAN).build()) 70 | .transform(scale(3.0).then(rotateAboutZAxis(270)).then(translate(2, 4, -15))), 71 | cube(glossy().setColor(MAGENTA).build()) 72 | .transform(scale(5.0).then(rotateAboutYAxis(30)).then(translate(1, 5, -20))), 73 | new Sphere(REFLECTIVE) 74 | .transform(translate(0, 0, -10)), 75 | new Sphere(shiny().setColor(new MutableColor(1.0f, 0.0f, 1.0f)).build()) 76 | .transform(translate(0, 2, 1)), 77 | new Plane( 78 | new Vector(0, -1, 0), 79 | new Vector(0, 1.0, 0.0), 80 | glossy().setColor(new MutableColor(72, 136, 168)).build())); 81 | 82 | // Lights in scene 83 | List lights = 84 | ImmutableList.of( 85 | ImmutablePointLight.builder() 86 | .setLocation(new Vector(3, 3, 0)) 87 | .setColor(new MutableColor(115, 115, 115)) 88 | .build(), 89 | ImmutablePointLight.builder() 90 | .setLocation(new Vector(-6, 5, 0)) 91 | .setColor(new MutableColor(200, 200, 200)) 92 | .build()); 93 | 94 | // Whole scene 95 | Scene scene = 96 | ImmutableScene.builder() 97 | .setShapes(shapes) 98 | .setLights(lights) 99 | .setBackgroundColor(new MutableColor(.25f, .25f, .25f)) 100 | .setAmbient(new MutableColor((float) .15, (float) .15, (float) .15)) 101 | .build(); 102 | 103 | Camera camera = STANDARD_CAMERA; 104 | 105 | RayTracer rayTracer = 106 | new SimpleAntiAliaser( 107 | raster, 108 | camera, 109 | new ReflectiveRayTracer( 110 | new PhongShading(scene, camera, SHADOWS_ENABLED), scene, raster, camera, MAX_RAY_DEPTH), 111 | new RandomAntiAliasingMethod(NUM_ANTI_ALIASING_SAMPLES)); 112 | // RayTracer rayTracer = new SimpleRayTracer( 113 | //// new NoShading(), 114 | // new PhongShading(scene, camera, false), 115 | // scene, 116 | // raster, 117 | // camera); 118 | // RayTracer rayTracer = new ReflectiveRayTracer( 119 | // new PhongShading(scene, camera, shadowsEnabled), 120 | // scene, 121 | // raster, 122 | // camera, 123 | // 4); 124 | 125 | RayTracerCoordinator rt = new RayTracerCoordinator(raster, camera, scene, rayTracer); 126 | 127 | long start = System.currentTimeMillis(); 128 | Raster rendered = rt.render(); 129 | long end = System.currentTimeMillis(); 130 | 131 | System.out.println("Rendering took " + (end - start) + " ms"); 132 | 133 | start = System.currentTimeMillis(); 134 | paintToJpeg(IMAGES_DEMO_PNG_PATH, rendered); 135 | // paintToJFrame(rendered); 136 | end = System.currentTimeMillis(); 137 | System.out.println("Painting took " + (end - start) + " ms"); 138 | } 139 | 140 | private static void paintToJpeg(String fileName, Raster rendered) { 141 | int height = rendered.getHeightPx(); 142 | int width = rendered.getWidthPx(); 143 | BufferedImage theImage = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB); 144 | for (int i = 0; i < height; i++) { 145 | for (int j = 0; j < width; j++) { 146 | int value = rendered.getPixel(i, j).toColor().getRGB(); 147 | theImage.setRGB(j, i, value); 148 | } 149 | } 150 | File outputFile = new File(fileName); 151 | try { 152 | for (int i = 0; outputFile.exists(); i++) { 153 | outputFile = 154 | new File( 155 | String.format("%s-%d.png", fileName.substring(0, fileName.indexOf(".png")), i)); 156 | } 157 | outputFile.createNewFile(); 158 | ImageIO.write(theImage, "png", outputFile); 159 | System.out.printf("Created %s\n", outputFile.toString()); 160 | } catch (IOException e) { 161 | throw new RuntimeException(e); 162 | } 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /src/main/java/me/kahlil/geometry/Extents.java: -------------------------------------------------------------------------------- 1 | package me.kahlil.geometry; 2 | 3 | import static com.google.common.base.Preconditions.checkState; 4 | import static java.lang.Double.NEGATIVE_INFINITY; 5 | import static java.lang.Double.POSITIVE_INFINITY; 6 | import static java.lang.Math.max; 7 | import static java.lang.Math.min; 8 | import static java.lang.Math.sqrt; 9 | import static me.kahlil.config.Counters.NUM_BOUNDING_INTERSECTIONS; 10 | import static me.kahlil.config.Counters.NUM_BOUNDING_INTERSECTION_TESTS; 11 | import static me.kahlil.geometry.Constants.EPSILON; 12 | 13 | import java.util.Arrays; 14 | import java.util.Optional; 15 | 16 | /** 17 | * Bounding volume for containing any amount of polygons. 18 | * 19 | *

Each polygon is bounded by intersecting 7 planes. From: 20 | * 21 | *

https://www.scratchapixel.com/lessons/advanced-rendering/introduction-acceleration-structure/bounding-volume-hierarchy-BVH-part1 22 | */ 23 | public class Extents implements BoundingVolume, Intersectable { 24 | 25 | private static final Extents EMPTY = new Extents(new double[] {}, new double[] {}); 26 | 27 | private static final double A = sqrt(3) / 3; 28 | private static final double B = -1.0 * A; 29 | // 7 plane-set normals pre-defined in the referenced docs. 30 | private static final Vector[] PLANE_SET_NORMALS = 31 | new Vector[] { 32 | new Vector(1, 0, 0), 33 | new Vector(0, 1, 0), 34 | new Vector(0, 0, 1), 35 | new Vector(A, A, A), 36 | new Vector(B, A, A), 37 | new Vector(B, B, A), 38 | new Vector(A, B, A) 39 | }; 40 | 41 | private final Triangle[] triangles; 42 | // Records min/max d_near and d_far of each ray intersection with the planes (d is from the plane 43 | // equation Ax + By + Cz = d 44 | private final double[] dNear; 45 | private final double[] dFar; 46 | 47 | public static Extents fromPolygon(Polygon polygon) { 48 | return Extents.fromTriangles(polygon.getTriangles()); 49 | } 50 | 51 | public static Extents fromTriangles(Triangle[] triangles) { 52 | double[][] dNearAndDFar = computeDNearAndDFar(triangles); 53 | return new Extents(triangles, dNearAndDFar[0], dNearAndDFar[1]); 54 | } 55 | 56 | public static Extents empty() { 57 | return EMPTY; 58 | } 59 | 60 | private Extents(double[] dNear, double[] dFar) { 61 | this(new Triangle[] {}, dNear, dFar); 62 | } 63 | 64 | private Extents(Triangle[] triangles, double[] dNear, double[] dFar) { 65 | this.triangles = triangles; 66 | this.dNear = dNear; 67 | this.dFar = dFar; 68 | } 69 | 70 | /** 71 | * Returns if the ray intersects the bounding volume. 72 | * 73 | *

This should not be called for union'd extents. 74 | */ 75 | @Override 76 | public Optional intersectWith(Ray ray) { 77 | checkState( 78 | triangles.length > 0, 79 | "intersectWith(ray) should only be called for Extents that store references to Triangles. Did you accidentally call this on the result of two unioned extents?\n", 80 | this); 81 | return findClosestIntersection(ray); 82 | } 83 | 84 | /** 85 | * Returns whether or not the ray intersects the bounding volume, and with a {@link RayHit} 86 | * describing the intersection with the original bounded shape. 87 | */ 88 | @Override 89 | public double intersectWithBoundingVolume(Ray ray) { 90 | NUM_BOUNDING_INTERSECTION_TESTS.getAndIncrement(); 91 | double timeNearMax = NEGATIVE_INFINITY; 92 | double timeFarMin = POSITIVE_INFINITY; 93 | for (int i = 0; i < PLANE_SET_NORMALS.length; i++) { 94 | double numerator = PLANE_SET_NORMALS[i].dot(ray.getStart()); 95 | double denominator = PLANE_SET_NORMALS[i].dot(ray.getDirection()); 96 | 97 | // Ray and plane are parallel, so we say they don't intersect. 98 | if (Math.abs(denominator) < EPSILON) { 99 | continue; 100 | } 101 | 102 | double timeNear = (dNear[i] - numerator) / denominator; 103 | double timeFar = (dFar[i] - numerator) / denominator; 104 | 105 | double actualTimeNear = min(timeNear, timeFar); 106 | timeFarMin = min(timeFarMin, max(timeNear, timeFar)); 107 | if (actualTimeNear > timeNearMax) { 108 | timeNearMax = actualTimeNear; 109 | } 110 | 111 | if (timeNearMax > timeFarMin) { 112 | return -1; 113 | } 114 | } 115 | NUM_BOUNDING_INTERSECTIONS.getAndIncrement(); 116 | return timeNearMax; 117 | } 118 | 119 | /** Returns an {@link Extents} bounding the union of the two volumes. */ 120 | public Extents union(Extents other) { 121 | if (isEmpty()) { 122 | return other; 123 | } 124 | if (other.isEmpty()) { 125 | return this; 126 | } 127 | double[] dNear = new double[PLANE_SET_NORMALS.length]; 128 | double[] dFar = new double[PLANE_SET_NORMALS.length]; 129 | for (int i = 0; i < PLANE_SET_NORMALS.length; i++) { 130 | dNear[i] = Math.min(this.dNear[i], other.dNear[i]); 131 | dFar[i] = Math.max(this.dFar[i], other.dFar[i]); 132 | } 133 | return new Extents(dNear, dFar); 134 | } 135 | 136 | /** Returns if this an empty {@link Extents}. */ 137 | public boolean isEmpty() { 138 | return dNear.length == 0 || dFar.length == 0; 139 | } 140 | 141 | /** 142 | * Returns the closest {@link RayHit} from testing intersections of all triangles captured within 143 | * this extent. 144 | */ 145 | private Optional findClosestIntersection(Ray ray) { 146 | double minTime = Integer.MAX_VALUE; 147 | Optional closestHit = Optional.empty(); 148 | for (Triangle triangle : triangles) { 149 | Optional rayHit = triangle.intersectInObjectSpace(ray); 150 | if (rayHit.isPresent()) { 151 | double time = rayHit.get().getTime(); 152 | if (time < minTime) { 153 | minTime = time; 154 | closestHit = rayHit; 155 | } 156 | } 157 | } 158 | return closestHit; 159 | } 160 | 161 | /** 162 | * Returns the d-near and d-far computation necessary to represent the extents. The first element 163 | * in the returned array is d-near and the second is d-far. 164 | */ 165 | private static double[][] computeDNearAndDFar(Triangle[] triangles) { 166 | double[] dNear = new double[PLANE_SET_NORMALS.length]; 167 | Arrays.fill(dNear, POSITIVE_INFINITY); 168 | double[] dFar = new double[PLANE_SET_NORMALS.length]; 169 | Arrays.fill(dFar, NEGATIVE_INFINITY); 170 | 171 | for (int i = 0; i < PLANE_SET_NORMALS.length; i++) { 172 | Vector planeNormal = PLANE_SET_NORMALS[i]; 173 | for (Triangle triangle : triangles) { 174 | for (Vector vertex : triangle.getVertexes()) { 175 | double d = vertex.dot(planeNormal); 176 | if (d < dNear[i]) { 177 | dNear[i] = d; 178 | } 179 | if (d > dFar[i]) { 180 | dFar[i] = d; 181 | } 182 | } 183 | } 184 | } 185 | return new double[][] {dNear, dFar}; 186 | } 187 | 188 | @Override 189 | public String toString() { 190 | return "Extents{" 191 | + "triangles=" 192 | + Arrays.toString(triangles) 193 | + ", dNear=" 194 | + Arrays.toString(dNear) 195 | + ", dFar=" 196 | + Arrays.toString(dFar) 197 | + '}'; 198 | } 199 | 200 | @Override 201 | public boolean equals(Object o) { 202 | if (this == o) { 203 | return true; 204 | } 205 | if (o == null || getClass() != o.getClass()) { 206 | return false; 207 | } 208 | Extents extents = (Extents) o; 209 | return Arrays.equals(dNear, extents.dNear) && Arrays.equals(dFar, extents.dFar); 210 | } 211 | 212 | @Override 213 | public int hashCode() { 214 | int result = Arrays.hashCode(dNear); 215 | result = 31 * result + Arrays.hashCode(dFar); 216 | return result; 217 | } 218 | } 219 | -------------------------------------------------------------------------------- /src/test/java/me/kahlil/geometry/LinearTransformationTest.java: -------------------------------------------------------------------------------- 1 | package me.kahlil.geometry; 2 | 3 | import static com.google.common.truth.Truth.assertThat; 4 | import static junit.framework.TestCase.assertEquals; 5 | import static me.kahlil.geometry.Constants.ORIGIN; 6 | import static me.kahlil.geometry.LinearTransformation.IDENTITY; 7 | import static me.kahlil.geometry.LinearTransformation.rotateAboutXAxis; 8 | import static me.kahlil.geometry.LinearTransformation.rotateAboutYAxis; 9 | import static me.kahlil.geometry.LinearTransformation.rotateAboutZAxis; 10 | import static me.kahlil.geometry.LinearTransformation.scale; 11 | import static me.kahlil.geometry.LinearTransformation.translate; 12 | 13 | import com.google.common.collect.ImmutableList; 14 | import java.util.Random; 15 | import org.junit.Test; 16 | import org.junit.runner.RunWith; 17 | import org.junit.runners.JUnit4; 18 | 19 | /** Unit tests for {@link LinearTransformation}. */ 20 | @RunWith(JUnit4.class) 21 | public class LinearTransformationTest { 22 | 23 | private static Vector ZERO_VEC = new Vector(0, 0, 0, 0); 24 | private static Random rand = new Random(); 25 | 26 | ImmutableList ALL_TRANSFORMATIONS = 27 | ImmutableList.of( 28 | translate(1.0, -2.0, 3.0), 29 | scale(1.0, -2.0, 3.0), 30 | rotateAboutXAxis(90), 31 | rotateAboutYAxis(90), 32 | rotateAboutZAxis(90)); 33 | 34 | @Test 35 | public void simpleTranslations() { 36 | assertEquals(ZERO_VEC, LinearTransformation.translate(0, 0, 0).apply(ZERO_VEC)); 37 | assertEquals(LinearTransformation.translate(1, 1, 1).apply(ZERO_VEC), ZERO_VEC); 38 | assertEquals( 39 | new Vector(1, 1, 1, 1), 40 | LinearTransformation.translate(1, 1, 1).apply(new Vector(0, 0, 0, 1))); 41 | 42 | for (int i = 0; i < 100; ++i) { 43 | double a = rand.nextDouble(); 44 | double b = rand.nextDouble(); 45 | double c = rand.nextDouble(); 46 | 47 | double a2 = rand.nextDouble(); 48 | double b2 = rand.nextDouble(); 49 | double c2 = rand.nextDouble(); 50 | 51 | Vector initial = new Vector(a, b, c, 1); 52 | Vector translate = new Vector(a2, b2, c2); 53 | LinearTransformation lt = LinearTransformation.translate(translate); 54 | 55 | assertEquals(new Vector(a + a2, b + b2, c + c2), lt.apply(initial)); 56 | assertEquals(new Vector(1, 1, 1), lt.apply(new Vector(1, 1, 1, 0))); 57 | } 58 | } 59 | 60 | @Test 61 | public void simpleScaling() { 62 | assertEquals( 63 | ZERO_VEC, scale(rand.nextDouble(), rand.nextDouble(), rand.nextDouble()).apply(ZERO_VEC)); 64 | for (int i = 0; i < 100; ++i) { 65 | double a = rand.nextDouble(); 66 | double b = rand.nextDouble(); 67 | double c = rand.nextDouble(); 68 | 69 | double a2 = rand.nextDouble(); 70 | double b2 = rand.nextDouble(); 71 | double c2 = rand.nextDouble(); 72 | 73 | Vector v1 = new Vector(a, b, c, 1); 74 | Vector v2 = new Vector(a, b, c, 0); 75 | Vector args = new Vector(a2, b2, c2); 76 | LinearTransformation lt = scale(args); 77 | 78 | assertEquals(new Vector(a * a2, b * b2, c * c2), lt.apply(v1)); 79 | assertEquals(new Vector(a * a2, b * b2, c * c2), lt.apply(v2)); 80 | } 81 | } 82 | 83 | @Test 84 | public void transformationsCompose() { 85 | for (int i = 0; i < 100; ++i) { 86 | Vector untransformed = new Vector(i, -1.0 * (i + 1), i + 2, 1); 87 | Vector scaleBy = new Vector(i / 100.0, -1.0 * (i + 1) / 100.0, (i + 2) / 100.0); 88 | Vector translateBy = new Vector(-1.0 * i, 2 * i, -3.0 * i); 89 | LinearTransformation translateThenScale = 90 | translate(translateBy).then(LinearTransformation.scale(scaleBy)); 91 | 92 | assertThat(translateThenScale.apply(untransformed)) 93 | .isEqualTo( 94 | new Vector( 95 | (untransformed.getX() + translateBy.getX()) * scaleBy.getX(), 96 | (untransformed.getY() + translateBy.getY()) * scaleBy.getY(), 97 | (untransformed.getZ() + translateBy.getZ()) * scaleBy.getZ())); 98 | } 99 | } 100 | 101 | @Test 102 | public void transformationComposedWithInverseIsIdentity() { 103 | ALL_TRANSFORMATIONS.forEach( 104 | transformation -> 105 | assertThat(transformation.then(transformation.inverse())).isEqualTo(IDENTITY)); 106 | } 107 | 108 | @Test 109 | public void composedTransformationComposedWithInverseIsIdentity() { 110 | for (LinearTransformation transformation1 : ALL_TRANSFORMATIONS) { 111 | for (LinearTransformation transformation2 : ALL_TRANSFORMATIONS) { 112 | LinearTransformation composition = transformation1.then(transformation2); 113 | assertThat(composition.then(composition.inverse())).isEqualTo(IDENTITY); 114 | } 115 | } 116 | } 117 | 118 | @Test 119 | public void rotationAboutXAxis() { 120 | Vector vector = new Vector(0, 1, 0); 121 | 122 | assertThat(rotateAboutXAxis(90).apply(vector)).isEqualTo(new Vector(0, 0.0, 1.0)); 123 | assertThat(rotateAboutXAxis(180).apply(vector)).isEqualTo(new Vector(0, -1.0, 0)); 124 | assertThat(rotateAboutXAxis(270).apply(vector)).isEqualTo(new Vector(0, 0.0, -1.0)); 125 | assertThat(rotateAboutXAxis(360).apply(vector)).isEqualTo(vector); 126 | 127 | assertThat(rotateAboutXAxis(450)).isEqualTo(rotateAboutXAxis(90)); 128 | } 129 | 130 | @Test 131 | public void rotationAboutYAxis() { 132 | Vector vector = new Vector(1, 0, 0); 133 | 134 | assertThat(rotateAboutYAxis(90).apply(vector)).isEqualTo(new Vector(0, 0.0, -1.0)); 135 | assertThat(rotateAboutYAxis(180).apply(vector)).isEqualTo(new Vector(-1.0, 0.0, 0)); 136 | assertThat(rotateAboutYAxis(270).apply(vector)).isEqualTo(new Vector(0, 0.0, 1.0)); 137 | assertThat(rotateAboutYAxis(360).apply(vector)).isEqualTo(vector); 138 | 139 | assertThat(rotateAboutYAxis(450)).isEqualTo(rotateAboutYAxis(90)); 140 | } 141 | 142 | @Test 143 | public void rotationAboutZAxis() { 144 | Vector vector = new Vector(1, 0, 0); 145 | 146 | assertThat(rotateAboutZAxis(90).apply(vector)).isEqualTo(new Vector(0, 1.0, 0.0)); 147 | assertThat(rotateAboutZAxis(180).apply(vector)).isEqualTo(new Vector(-1.0, 0.0, 0)); 148 | assertThat(rotateAboutZAxis(270).apply(vector)).isEqualTo(new Vector(0, -1.0, 0.0)); 149 | assertThat(rotateAboutZAxis(360).apply(vector)).isEqualTo(vector); 150 | 151 | assertThat(rotateAboutZAxis(450)).isEqualTo(rotateAboutZAxis(90)); 152 | } 153 | 154 | @Test 155 | public void rotationOfPointAtOriginDoesNothing() { 156 | assertThat(rotateAboutXAxis(90).apply(ORIGIN)).isEqualTo(ORIGIN); 157 | assertThat(rotateAboutYAxis(90).apply(ORIGIN)).isEqualTo(ORIGIN); 158 | assertThat(rotateAboutZAxis(90).apply(ORIGIN)).isEqualTo(ORIGIN); 159 | 160 | // Check homogeneous coordinates as well. 161 | assertThat(rotateAboutXAxis(90).apply(new Vector(0, 0, 0, 1.0))).isEqualTo(ORIGIN); 162 | assertThat(rotateAboutYAxis(90).apply(new Vector(0, 0, 0, 1.0))).isEqualTo(ORIGIN); 163 | assertThat(rotateAboutZAxis(90).apply(new Vector(0, 0, 0, 1.0))).isEqualTo(ORIGIN); 164 | } 165 | 166 | @Test 167 | public void rotatedAtOriginThenTranslatedIsSameAsTranslated() { 168 | Vector translated = translate(1.0, -2.0, 3.0).apply(ORIGIN); 169 | Vector rotatedThenTranslated = 170 | rotateAboutXAxis(90).then(translate(1.0, -2.0, 3.0)).apply(ORIGIN); 171 | assertThat(rotatedThenTranslated).isEqualTo(translated); 172 | } 173 | 174 | @Test 175 | public void negativeRotationIsSameAsInverse() { 176 | assertThat(rotateAboutXAxis(90).inverse()).isEqualTo(rotateAboutXAxis(-90)); 177 | assertThat(rotateAboutYAxis(90).inverse()).isEqualTo(rotateAboutYAxis(-90)); 178 | assertThat(rotateAboutZAxis(90).inverse()).isEqualTo(rotateAboutZAxis(-90)); 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /src/main/java/me/kahlil/geometry/ConvexPolygon.java: -------------------------------------------------------------------------------- 1 | package me.kahlil.geometry; 2 | 3 | import static com.google.common.base.Preconditions.checkArgument; 4 | import static me.kahlil.config.Counters.NUM_TRIANGLES; 5 | import static me.kahlil.config.Parameters.OCTREE_ENABLED; 6 | import static me.kahlil.config.Parameters.OCTREE_MAX_DEPTH; 7 | import static me.kahlil.config.Parameters.OCTREE_MAX_SHAPES_PER_LEAF; 8 | 9 | import java.util.Arrays; 10 | import java.util.Optional; 11 | import me.kahlil.octree.BoundsHelper; 12 | import me.kahlil.octree.Octree; 13 | import me.kahlil.scene.Material; 14 | 15 | /** Shape representing a convex polygon. */ 16 | public class ConvexPolygon extends Shape implements Polygon { 17 | 18 | private final Triangle[] triangles; 19 | private final Octree octree; 20 | 21 | // Min/max (x, y, z) that the ConvexPolygon occupies for forming a bounding volume. 22 | private final Vector minBound; 23 | private final Vector maxBound; 24 | 25 | private ConvexPolygon( 26 | Material material, 27 | Vector[] vertexes, 28 | Vector[] vertexNormals, 29 | int[] faces, 30 | int[] vertexIndexes) { 31 | checkArgument( 32 | vertexes.length >= 3, 33 | "A polygon must have at least 3 vertices. Found: %d", 34 | Arrays.toString(vertexes)); 35 | checkArgument(faces.length > 0, "A convex polygon must have at least one face."); 36 | checkArgument( 37 | vertexIndexes.length > 0, "A convex polygon must have at least one vertex index."); 38 | 39 | this.triangles = 40 | convertVertexesToTriangles(material, vertexes, vertexNormals, faces, vertexIndexes); 41 | NUM_TRIANGLES.getAndAdd(triangles.length); 42 | 43 | Vector[] minMaxBounds = BoundsHelper.computeGlobalMinAndMax(triangles); 44 | this.minBound = minMaxBounds[0]; 45 | this.maxBound = minMaxBounds[1]; 46 | 47 | this.octree = 48 | new Octree(getTriangles(), OCTREE_MAX_SHAPES_PER_LEAF, OCTREE_MAX_DEPTH); 49 | } 50 | 51 | public static ConvexPolygon withSurfaceNormals( 52 | Material material, Vector[] vertexes, int[] faces, int[] vertexIndexes) { 53 | return new ConvexPolygon(material, vertexes, new Vector[] {}, faces, vertexIndexes); 54 | } 55 | 56 | public static ConvexPolygon withVertexNormals( 57 | Material material, 58 | Vector[] vertexes, 59 | Vector[] vertexNormals, 60 | int[] faces, 61 | int[] vertexIndexes) { 62 | checkArgument( 63 | vertexNormals.length == vertexes.length, 64 | "A polygon with vertex normals must have the same number of vertexes as normals. Instead, found vertexes=%s, normals=%s", 65 | vertexes, 66 | vertexNormals); 67 | return new ConvexPolygon(material, vertexes, vertexNormals, faces, vertexIndexes); 68 | } 69 | 70 | public static ConvexPolygon cube(Material material) { 71 | return ConvexPolygon.withSurfaceNormals( 72 | material, 73 | new Vector[] { 74 | new Vector(-1, -1, 0), 75 | new Vector(1, -1, 0), 76 | new Vector(1, 1, 0), 77 | new Vector(-1, 1, 0), 78 | new Vector(-1, -1, -1), 79 | new Vector(1, -1, -1), 80 | new Vector(1, 1, -1), 81 | new Vector(-1, 1, -1), 82 | }, 83 | new int[] {4, 4, 4, 4, 4, 4}, 84 | new int[] { 85 | 0, 1, 2, 3, // front face 86 | 3, 2, 6, 7, // top face 87 | 0, 4, 5, 1, // bottom face 88 | 0, 3, 7, 4, // left face 89 | 1, 5, 6, 2, // right face 90 | 4, 5, 6, 7 // back face 91 | }); 92 | } 93 | 94 | @Override 95 | Optional internalIntersectInObjectSpace(Ray ray) { 96 | double minTime = Integer.MAX_VALUE; 97 | Optional closestHit = Optional.empty(); 98 | if (OCTREE_ENABLED) { 99 | return octree.intersectWith(ray); 100 | } 101 | for (Triangle triangle : triangles) { 102 | Optional rayHit = triangle.intersectInObjectSpace(ray); 103 | if (rayHit.isPresent()) { 104 | double time = rayHit.get().getTime(); 105 | if (time < minTime) { 106 | minTime = time; 107 | closestHit = rayHit; 108 | } 109 | } 110 | } 111 | return closestHit; 112 | } 113 | 114 | @Override 115 | public Triangle[] getTriangles() { 116 | return this.triangles; 117 | } 118 | 119 | @Override 120 | public Vector minBound() { 121 | return minBound; 122 | } 123 | 124 | @Override 125 | public Vector maxBound() { 126 | return maxBound; 127 | } 128 | 129 | /** 130 | * Converts the given set of vertices into triangles using the simple algorithm described at: 131 | * https://www.scratchapixel.com/lessons/3d-basic-rendering/ray-tracing-polygon-mesh/polygon-to-triangle-mesh 132 | */ 133 | private Triangle[] convertVertexesToTriangles( 134 | Material material, 135 | Vector[] vertexes, 136 | Vector[] vertexNormals, 137 | int[] faces, 138 | int[] vertexIndexes) { 139 | int vertexIndex = 0; 140 | int trianglesIndex = 0; 141 | int numTriangles = computeNumTriangles(faces); 142 | Triangle[] triangles = new Triangle[numTriangles]; 143 | for (int faceIndex = 0; faceIndex < faces.length; faceIndex++) { 144 | convertFaceToTriangles( 145 | triangles, 146 | material, 147 | vertexes, 148 | vertexNormals, 149 | trianglesIndex, 150 | faces[faceIndex], 151 | vertexIndexes, 152 | vertexIndex); 153 | vertexIndex += faces[faceIndex]; 154 | trianglesIndex += faces[faceIndex] - 2; 155 | } 156 | 157 | return triangles; 158 | } 159 | 160 | private static int computeNumTriangles(int[] faces) { 161 | int sum = 0; 162 | for (int i : faces) { 163 | checkArgument(i >= 3, "A face must have at least 3 vertices. Found: %d", i); 164 | sum += i - 2; 165 | } 166 | return sum; 167 | } 168 | 169 | /** 170 | * Converts the given geometric specification of a face (e.g. it's number of vertexes and the 171 | * vertex index array) into the triangles that can be drawn to represent it. 172 | * 173 | *

For efficiency, it mutates the triangle array input. 174 | */ 175 | private void convertFaceToTriangles( 176 | Triangle[] triangles, 177 | Material material, 178 | Vector[] vertexes, 179 | Vector[] vertexNormals, 180 | int trianglesIndex, 181 | int numVertexes, 182 | int[] vertexIndexes, 183 | int startingVertexIndex) { 184 | for (int i = 0; i < numVertexes - 2; i++) { 185 | int firstVertexIndex = vertexIndexes[startingVertexIndex]; 186 | int secondVertexIndex = vertexIndexes[startingVertexIndex + i + 1]; 187 | int thirdVertexIndex = vertexIndexes[startingVertexIndex + i + 2]; 188 | triangles[trianglesIndex + i] = 189 | constructTriangle( 190 | material, 191 | vertexes, 192 | vertexNormals, 193 | firstVertexIndex, 194 | secondVertexIndex, 195 | thirdVertexIndex); 196 | } 197 | } 198 | 199 | /** 200 | * Constructs a triangle with the given parameters, determining to use vertex normals or surface 201 | * normals based on the size of the {@code vertexNormals} array. 202 | */ 203 | private Triangle constructTriangle( 204 | Material material, 205 | Vector[] vertexes, 206 | Vector[] vertexNormals, 207 | int firstVertexIndex, 208 | int secondVertexIndex, 209 | int thirdVertexIndex) { 210 | Triangle triangle; 211 | Vector[] triangleVertexes = { 212 | vertexes[firstVertexIndex], vertexes[secondVertexIndex], vertexes[thirdVertexIndex] 213 | }; 214 | if (vertexNormals.length == 0) { 215 | triangle = Triangle.withSurfaceNormals(material, triangleVertexes); 216 | } else { 217 | triangle = 218 | Triangle.withVertexNormals( 219 | material, 220 | triangleVertexes, 221 | new Vector[] { 222 | vertexNormals[firstVertexIndex], 223 | vertexNormals[secondVertexIndex], 224 | vertexNormals[thirdVertexIndex] 225 | }); 226 | } 227 | return triangle; 228 | } 229 | } 230 | -------------------------------------------------------------------------------- /src/test/java/me/kahlil/octree/OctreeTest.java: -------------------------------------------------------------------------------- 1 | package me.kahlil.octree; 2 | 3 | import static com.google.common.truth.Truth.assertThat; 4 | import static me.kahlil.octree.BoundsHelper.computeGlobalMinAndMax; 5 | import static me.kahlil.scene.Materials.DUMMY_MATERIAL; 6 | 7 | import com.google.common.collect.ImmutableList; 8 | import me.kahlil.geometry.Extents; 9 | import me.kahlil.geometry.Triangle; 10 | import me.kahlil.geometry.Vector; 11 | import org.junit.Test; 12 | import org.junit.runner.RunWith; 13 | import org.junit.runners.JUnit4; 14 | 15 | /** Unit tests for {@link Octree}. */ 16 | @RunWith(JUnit4.class) 17 | public class OctreeTest { 18 | 19 | private static final Triangle IN_FIRST_QUADRANT = Triangle.withSurfaceNormals( 20 | DUMMY_MATERIAL, 21 | new Vector(0.5, 0.5, 0.5), 22 | new Vector(0.75, 0.75, 0.75), 23 | new Vector(1, 1, 1)); 24 | private static final Triangle IN_SECOND_QUADRANT = Triangle.withSurfaceNormals( 25 | DUMMY_MATERIAL, 26 | new Vector(-0.5, 0.5, 0.5), 27 | new Vector(-0.75, 0.75, 0.75), 28 | new Vector(-1, 1, 1)); 29 | private static final Triangle IN_THIRD_QUADRANT_BACK = Triangle.withSurfaceNormals( 30 | DUMMY_MATERIAL, 31 | new Vector(-0.5, -0.5, -0.5), 32 | new Vector(-0.75, -0.75, -0.75), 33 | new Vector(-1, -1, -1)); 34 | private static final Triangle OVERLAPPING_FIRST_AND_SECOND = Triangle.withSurfaceNormals( 35 | DUMMY_MATERIAL, 36 | new Vector(-0.5, -0.5, 0.5), 37 | new Vector(0.5, 0.5, 1.0), 38 | new Vector(0.0, 0.0, 1.0)); 39 | 40 | @Test 41 | public void minMaxBoundsComputedCorrectly() { 42 | Triangle[] triangles = 43 | new Triangle[] { 44 | Triangle.withSurfaceNormals( 45 | DUMMY_MATERIAL, new Vector(2, 0, 0), new Vector(1, 2, 1), new Vector(1, 1, 2)), 46 | Triangle.withSurfaceNormals( 47 | DUMMY_MATERIAL, new Vector(0, 0, -2), new Vector(-1, -2, -1), new Vector(-2, -1, -1)) 48 | }; 49 | 50 | assertThat(computeGlobalMinAndMax(triangles)) 51 | .asList() 52 | .containsExactly(new Vector(-2, -2, -2), new Vector(2, 2, 2)); 53 | } 54 | 55 | @Test 56 | public void singleNodeOctree_creationParamtersAreCorrect() { 57 | Triangle[] triangles = { 58 | Triangle.withSurfaceNormals( 59 | DUMMY_MATERIAL, new Vector(0, 0, 0), new Vector(1, 1, 1), new Vector(2, 2, 2)) 60 | }; 61 | Octree tree = new Octree<>(triangles, 2, 2); 62 | 63 | assertThat(tree.maxDepth).isEqualTo(2); 64 | assertThat(tree.maxObjectsPerLeaf).isEqualTo(2); 65 | 66 | assertThat(tree.root.maxDepth).isEqualTo(2); 67 | assertThat(tree.root.maxObjectsPerLeaf).isEqualTo(2); 68 | assertThat(tree.root.children).isEqualTo(new OctreeNode[8]); 69 | assertThat(tree.root.allPolygons).isEqualTo(triangles); 70 | assertThat(tree.root.depth).isEqualTo(0); 71 | 72 | assertThat(tree.root.min).isEqualTo(new Vector(0, 0, 0)); 73 | assertThat(tree.root.max).isEqualTo(new Vector(2, 2, 2)); 74 | } 75 | 76 | @Test 77 | public void octreeWithThreeNodes_commonFieldsSetCorrectly() { 78 | Triangle[] triangles = {IN_FIRST_QUADRANT, IN_SECOND_QUADRANT, IN_THIRD_QUADRANT_BACK}; 79 | Octree tree = new Octree<>(triangles, 2, 2); 80 | 81 | assertThat(tree.root.isLeafNode).isFalse(); 82 | assertThat(tree.root.max).isEqualTo(new Vector(1, 1, 1)); 83 | assertThat(tree.root.min).isEqualTo(new Vector(-1, -1, -1)); 84 | assertThat(tree.root.allPolygons).isEqualTo(triangles); 85 | assertThat(tree.root.depth).isEqualTo(0); 86 | // (+x, +y, +z) 87 | assertThat(tree.root.children[0]).isNotNull(); 88 | // (-x, +y, +z) 89 | assertThat(tree.root.children[4]).isNotNull(); 90 | // (-x, -y, -z) 91 | assertThat(tree.root.children[7]).isNotNull(); 92 | 93 | OctreeNode firstQuadrantNode = tree.root.children[0]; 94 | OctreeNode secondQuadrantNode = tree.root.children[4]; 95 | OctreeNode thirdQuadrantNode = tree.root.children[7]; 96 | 97 | // Common assertions over all children nodes. 98 | for (OctreeNode node : 99 | ImmutableList.of(firstQuadrantNode, secondQuadrantNode, thirdQuadrantNode)) { 100 | assertThat(node.depth).isEqualTo(1); 101 | assertThat(node.isLeafNode).isTrue(); 102 | assertThat(node.maxObjectsPerLeaf).isEqualTo(2); 103 | assertThat(node.maxDepth).isEqualTo(2); 104 | assertThat(node.children).isEqualTo(new OctreeNode[8]); 105 | assertThat(node.allPolygons).isEqualTo(triangles); 106 | } 107 | } 108 | 109 | @Test 110 | public void octreeWithThreeNodes_quadrantSpecificFieldsSetCorrectly() { 111 | Triangle[] triangles = {IN_FIRST_QUADRANT, IN_SECOND_QUADRANT, IN_THIRD_QUADRANT_BACK}; 112 | Octree tree = new Octree<>(triangles, 2, 2); 113 | 114 | OctreeNode firstQuadrantNode = tree.root.children[0]; 115 | OctreeNode secondQuadrantNode = tree.root.children[4]; 116 | OctreeNode thirdQuadrantNode = tree.root.children[7]; 117 | 118 | assertThat(firstQuadrantNode.boundPolygons).hasSize(1); 119 | assertThat(triangles[firstQuadrantNode.boundPolygons.get(0)]).isEqualTo(IN_FIRST_QUADRANT); 120 | assertThat(firstQuadrantNode.min).isEqualTo(new Vector(0, 0, 0)); 121 | assertThat(firstQuadrantNode.max).isEqualTo(new Vector(1, 1, 1)); 122 | 123 | assertThat(secondQuadrantNode.boundPolygons).hasSize(1); 124 | assertThat(triangles[secondQuadrantNode.boundPolygons.get(0)]).isEqualTo(IN_SECOND_QUADRANT); 125 | assertThat(secondQuadrantNode.min).isEqualTo(new Vector(-1, 0, 0)); 126 | assertThat(secondQuadrantNode.max).isEqualTo(new Vector(0, 1, 1)); 127 | 128 | assertThat(thirdQuadrantNode.boundPolygons).hasSize(1); 129 | assertThat(triangles[thirdQuadrantNode.boundPolygons.get(0)]).isEqualTo(IN_THIRD_QUADRANT_BACK); 130 | assertThat(thirdQuadrantNode.min).isEqualTo(new Vector(-1, -1, -1)); 131 | assertThat(thirdQuadrantNode.max).isEqualTo(new Vector(0, 0, 0)); 132 | } 133 | 134 | @Test 135 | public void octreeWithFourNodes_oneOverlapping_fieldsCorrect() { 136 | Triangle[] triangles = {IN_FIRST_QUADRANT, IN_SECOND_QUADRANT, IN_THIRD_QUADRANT_BACK, OVERLAPPING_FIRST_AND_SECOND}; 137 | Octree tree = new Octree<>(triangles, 2, 2); 138 | 139 | assertThat(tree.root.isLeafNode).isFalse(); 140 | assertThat(tree.root.boundPolygons).hasSize(1); 141 | assertThat(triangles[tree.root.boundPolygons.get(0)]).isEqualTo(OVERLAPPING_FIRST_AND_SECOND); 142 | } 143 | 144 | @Test 145 | public void octreeWithThreeNodes_extentsAreCorrect() { 146 | Triangle[] triangles = {IN_FIRST_QUADRANT, IN_SECOND_QUADRANT, IN_THIRD_QUADRANT_BACK}; 147 | Octree tree = new Octree<>(triangles, 2, 2); 148 | 149 | OctreeNode firstQuadrantNode = tree.root.children[0]; 150 | OctreeNode secondQuadrantNode = tree.root.children[4]; 151 | OctreeNode thirdQuadrantNode = tree.root.children[7]; 152 | 153 | assertThat(tree.extents).isEqualTo(tree.root.totalExtents); 154 | assertThat(tree.root.currExtents.isEmpty()).isTrue(); 155 | assertThat(tree.extents).isEqualTo(Extents.fromTriangles(triangles)); 156 | 157 | assertThat(firstQuadrantNode.totalExtents).isEqualTo(firstQuadrantNode.currExtents); 158 | assertThat(firstQuadrantNode.totalExtents).isEqualTo(Extents.fromTriangles(new Triangle[]{IN_FIRST_QUADRANT})); 159 | 160 | assertThat(secondQuadrantNode.totalExtents).isEqualTo(secondQuadrantNode.currExtents); 161 | assertThat(secondQuadrantNode.totalExtents).isEqualTo(Extents.fromTriangles(new Triangle[]{IN_SECOND_QUADRANT})); 162 | 163 | assertThat(thirdQuadrantNode.totalExtents).isEqualTo(thirdQuadrantNode.currExtents); 164 | assertThat(thirdQuadrantNode.totalExtents).isEqualTo(Extents.fromTriangles(new Triangle[]{IN_THIRD_QUADRANT_BACK})); 165 | } 166 | 167 | @Test 168 | public void octreeWithFourNodes_oneOverlapping_extentsAreCorrect() { 169 | Triangle[] triangles = {IN_FIRST_QUADRANT, IN_SECOND_QUADRANT, IN_THIRD_QUADRANT_BACK, OVERLAPPING_FIRST_AND_SECOND}; 170 | Octree tree = new Octree<>(triangles, 2, 2); 171 | 172 | assertThat(tree.extents).isEqualTo(tree.root.totalExtents); 173 | assertThat(tree.root.currExtents.isEmpty()).isFalse(); 174 | assertThat(tree.root.currExtents).isEqualTo(Extents.fromTriangles(new Triangle[]{OVERLAPPING_FIRST_AND_SECOND})); 175 | assertThat(tree.extents).isEqualTo(Extents.fromTriangles(triangles)); 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /src/main/java/me/kahlil/octree/OctreeNode.java: -------------------------------------------------------------------------------- 1 | package me.kahlil.octree; 2 | 3 | import static com.google.common.base.Preconditions.checkState; 4 | import static java.util.Comparator.comparingDouble; 5 | import static me.kahlil.config.Counters.NUM_OCTREE_CHILD_INSERTIONS; 6 | import static me.kahlil.config.Counters.NUM_OCTREE_INTERNAL_INSERTIONS; 7 | 8 | import com.google.common.annotations.VisibleForTesting; 9 | import com.google.common.collect.ImmutableList; 10 | import java.util.ArrayList; 11 | import java.util.Arrays; 12 | import java.util.List; 13 | import java.util.Objects; 14 | import java.util.Optional; 15 | import java.util.PriorityQueue; 16 | import me.kahlil.geometry.Extents; 17 | import me.kahlil.geometry.Intersectable; 18 | import me.kahlil.geometry.Polygon; 19 | import me.kahlil.geometry.Ray; 20 | import me.kahlil.geometry.RayHit; 21 | import me.kahlil.geometry.Triangle; 22 | import me.kahlil.geometry.Vector; 23 | 24 | /** A representation of a single node within an Octree. */ 25 | final class OctreeNode implements Intersectable { 26 | 27 | // Array containing all original shapes stored in the octree. This way, each node need only 28 | // maintain indexes to shapes within the array. 29 | @VisibleForTesting T[] allPolygons; 30 | final int depth; 31 | 32 | final int maxObjectsPerLeaf; 33 | final int maxDepth; 34 | final Vector min; 35 | final Vector max; 36 | 37 | boolean isLeafNode = true; 38 | 39 | // This is a List, even though it's fixed size, because Java Arrays don't handle generic 40 | // type parameters well. 41 | @VisibleForTesting final OctreeNode[] children = new OctreeNode[8]; 42 | 43 | // This is a list, rather than an array, because it is dynamically sized. 44 | final List boundPolygons; 45 | // Extents that bound the polygons stored in this node. Only present for nodes with triangles. 46 | Extents currExtents; 47 | // Extents that bound the polygons in this node and its children. 48 | Extents totalExtents; 49 | 50 | OctreeNode( 51 | T[] allPolygons, int maxObjectsPerLeaf, int maxDepth, Vector min, Vector max, int depth) { 52 | this.maxObjectsPerLeaf = maxObjectsPerLeaf; 53 | this.maxDepth = maxDepth; 54 | this.min = min; 55 | this.max = max; 56 | this.allPolygons = allPolygons; 57 | this.depth = depth; 58 | this.boundPolygons = new ArrayList<>(maxDepth); 59 | } 60 | 61 | @Override 62 | public Optional intersectWith(Ray ray) { 63 | // Return empty if ray does not intersect with net extents at all for this node. 64 | if (totalExtents.intersectWithBoundingVolume(ray) < 0) { 65 | return Optional.empty(); 66 | } 67 | // Otherwise, see if this node stores any local polygons we need to check against. 68 | // This will be true for both leaf nodes and internal nodes which store polygons. 69 | Optional closest = Optional.empty(); 70 | if (!boundPolygons.isEmpty()) { 71 | closest = currExtents.intersectWith(ray); 72 | } 73 | // Finally, intersections of all children. But, do so according in the order of closest-children 74 | // first by computing the intersection distance to each child extents, as the closer bounding 75 | // distances will be more likely to contain the correct triangle. 76 | double[] childExtentsIntersections = intersectWithChildExtents(ray); 77 | int numIntersections = 0; 78 | for (double t : childExtentsIntersections) { 79 | if (t > 0) { 80 | numIntersections++; 81 | } 82 | } 83 | if (numIntersections == 0) { 84 | return closest; 85 | } 86 | PriorityQueue childrenQueue = new PriorityQueue<>( 87 | numIntersections, 88 | comparingDouble(childIndex -> childExtentsIntersections[childIndex])); 89 | 90 | for (int i = 0; i < children.length; i++) { 91 | if (children[i] != null) { 92 | childrenQueue.add(i); 93 | } 94 | } 95 | 96 | while (!childrenQueue.isEmpty()) { 97 | int childIndex = childrenQueue.remove(); 98 | Optional shapeHit = children[childIndex].intersectWith(ray); 99 | closest = pickHitWithLowestTime(closest, shapeHit); 100 | if (closest.isPresent() && !childrenQueue.isEmpty() && closest.get().getTime() < childExtentsIntersections[childrenQueue.peek()]) { 101 | return closest; 102 | } 103 | } 104 | return closest; 105 | } 106 | 107 | /** 108 | * Returns a double[] where the value at index i is the time of intersection with the child at 109 | * index i in children. 110 | */ 111 | private double[] intersectWithChildExtents(Ray ray) { 112 | double[] childExtentsIntersections = new double[children.length]; 113 | for (int i = 0; i < children.length; i++) { 114 | if (children[i] == null) { 115 | continue; 116 | } 117 | childExtentsIntersections[i] = children[i].intersectWithExtents(ray); 118 | } 119 | return childExtentsIntersections; 120 | } 121 | 122 | double intersectWithExtents(Ray ray) { 123 | return totalExtents.intersectWithBoundingVolume(ray); 124 | } 125 | 126 | /** 127 | * Returns the ray hit with the lowest time if both are present, the ray hit that is present if 128 | * one is empty, or empty if both are empty. 129 | */ 130 | private Optional pickHitWithLowestTime(Optional hit1, Optional hit2) { 131 | if (hit1.isEmpty()) { 132 | return hit2; 133 | } 134 | if (hit2.isEmpty()) { 135 | return hit1; 136 | } 137 | return hit1.get().getTime() < hit2.get().getTime() ? hit1 : hit2; 138 | } 139 | 140 | /** 141 | * Performs an insertion into this {@link OctreeNode}, recursively inserting into children nodes 142 | * if necessary. 143 | */ 144 | void insert(int shapeIndex) { 145 | checkInBounds(allPolygons[shapeIndex]); 146 | 147 | // If this is an internal node, simply insert the shape into the correct child. 148 | if (!isLeafNode) { 149 | insertIntoCorrectChild(shapeIndex); 150 | return; 151 | } 152 | 153 | // Otherwise, insert the shape into this node. 154 | boundPolygons.add(shapeIndex); 155 | 156 | // If the node has now grown too large, and the octree is not yet too deep, reassign all of the 157 | // current node's shapes to the proper children. 158 | if (boundPolygons.size() > maxObjectsPerLeaf && depth < maxDepth) { 159 | // Keep track of this, because the insertion logic may re-insert at the end of this same 160 | // list if a particular shape spans multiple child cells. 161 | int initialSize = boundPolygons.size(); 162 | for (int i = initialSize - 1; i >= 0; i--) { 163 | insertIntoCorrectChild(boundPolygons.remove(i)); 164 | } 165 | isLeafNode = false; 166 | } 167 | } 168 | 169 | /** 170 | * Performs a bottom-up traversal of the {@link Octree} to initialize the extents at each node. 171 | * 172 | *

The {@link Extents} of a leaf node are simply the bounding extent of the shapes it holds. 173 | * The extents of an internal node are the union of the extents of its leaf nodes, along with any 174 | * shapes it holds. 175 | */ 176 | Extents computeExtents() { 177 | // Check if value has already been computed and memoized. 178 | if (totalExtents != null && currExtents != null) { 179 | return totalExtents; 180 | } 181 | 182 | // Otherwise, recompute by first checking the shapes bound within this node. 183 | currExtents = Extents.empty(); 184 | Triangle[] allBoundTriangles = getAllBoundTriangles(); 185 | if (allBoundTriangles.length > 0) { 186 | currExtents = Extents.fromTriangles(allBoundTriangles); 187 | } 188 | 189 | totalExtents = currExtents; 190 | // Then, union that extent with any present children. 191 | if (!isLeafNode) { 192 | for (OctreeNode child : children) { 193 | if (child == null) { 194 | continue; 195 | } 196 | totalExtents = totalExtents.union(child.computeExtents()); 197 | } 198 | } 199 | 200 | // Finally, memoize the result so we need not do this again. 201 | return totalExtents; 202 | } 203 | 204 | /** Inserts the given shape index into the correct child, based on its index. */ 205 | private void insertIntoCorrectChild(int shapeIndex) { 206 | int childIndex = computeChildIndex(allPolygons[shapeIndex].minBound()); 207 | 208 | // Check if shape spans multiple child cells. 209 | if (childIndex != computeChildIndex(allPolygons[shapeIndex].maxBound())) { 210 | // If so, store it in this internal node and return. 211 | NUM_OCTREE_INTERNAL_INSERTIONS.getAndIncrement(); 212 | boundPolygons.add(shapeIndex); 213 | return; 214 | } 215 | 216 | NUM_OCTREE_CHILD_INSERTIONS.getAndIncrement(); 217 | 218 | // Otherwise, first check if the correct child exists. 219 | if (children[childIndex] == null) { 220 | Vector min = getMinBoundForChild(childIndex); 221 | Vector max = getMaxBoundForChild(childIndex); 222 | 223 | children[childIndex] = 224 | new OctreeNode(allPolygons, maxObjectsPerLeaf, maxDepth, min, max, depth + 1); 225 | } 226 | 227 | // Then, recursively insert into the child. 228 | children[childIndex].insert(shapeIndex); 229 | } 230 | 231 | /** Computes min bounds for the child cell index. */ 232 | private Vector getMinBoundForChild(int childIndex) { 233 | Vector centroid = min.average(max); 234 | return new Vector( 235 | isLeftCell(childIndex) ? min.getX() : centroid.getX(), 236 | isBottomCell(childIndex) ? min.getY() : centroid.getY(), 237 | isBackCell(childIndex) ? min.getZ() : centroid.getZ()); 238 | } 239 | 240 | /** Computes max bounds for the child cell index. */ 241 | private Vector getMaxBoundForChild(int childIndex) { 242 | Vector centroid = min.average(max); 243 | return new Vector( 244 | isLeftCell(childIndex) ? centroid.getX() : max.getX(), 245 | isBottomCell(childIndex) ? centroid.getY() : max.getY(), 246 | isBackCell(childIndex) ? centroid.getZ() : max.getZ()); 247 | } 248 | 249 | /** 250 | * Use funky bitwise math to compute the child index of the point. 251 | * 252 | *

Form of index is comparable to how the linux `chmod` command works, e.g. `chmod 755 ...`. 253 | */ 254 | private int computeChildIndex(Vector point) { 255 | Vector nodeCentroid = min.average(max); 256 | 257 | boolean isLeftCell = point.getX() <= nodeCentroid.getX(); 258 | boolean isBottomCell = point.getY() <= nodeCentroid.getY(); 259 | boolean isBackCell = point.getZ() <= nodeCentroid.getZ(); 260 | 261 | return (isLeftCell ? 4 : 0) | (isBottomCell ? 2 : 0) | (isBackCell ? 1 : 0); 262 | } 263 | 264 | /** Uses funky bitwise math to check if child cell represents negtive X values. */ 265 | private boolean isLeftCell(int childIndex) { 266 | // Check if third bit is set. 267 | return (childIndex & 4) == 4; 268 | } 269 | 270 | /** Uses funky bitwise math to check if child cell represents negative Y values. */ 271 | private boolean isBottomCell(int childIndex) { 272 | // Check if second bit is set. 273 | return (childIndex & 2) == 2; 274 | } 275 | 276 | /** Uses funky bitwise math to check if child cell represents negative Z values. */ 277 | private boolean isBackCell(int childIndex) { 278 | // Check if first bit is set. 279 | return (childIndex & 1) == 1; 280 | } 281 | 282 | /** Asserts that the given shape is within bounds of this node. */ 283 | private void checkInBounds(T shape) { 284 | checkState( 285 | shapeIsInBounds(shape), 286 | "Shape has bounds outside of this octree node. shape_bounds= octree_node_bounds=%s", 287 | ImmutableList.of(min, max), 288 | ImmutableList.of(shape.minBound(), shape.maxBound())); 289 | } 290 | 291 | /** Returns whether or not the given shape is within the bounds defined by this cell. */ 292 | private boolean shapeIsInBounds(T shape) { 293 | for (int i = 0; i < 3; i++) { 294 | if (shape.minBound().getComponent(i) < min.getComponent(i)) { 295 | return false; 296 | } 297 | if (shape.maxBound().getComponent(i) > max.getComponent(i)) { 298 | return false; 299 | } 300 | } 301 | return true; 302 | } 303 | 304 | /** Returns all triangles bound within the polygons contained in the current node. */ 305 | private Triangle[] getAllBoundTriangles() { 306 | if (boundPolygons.isEmpty()) { 307 | return new Triangle[] {}; 308 | } 309 | Triangle[][] allTriangles = new Triangle[boundPolygons.size()][]; 310 | for (int i = 0; i < boundPolygons.size(); i++) { 311 | allTriangles[i] = allPolygons[boundPolygons.get(i)].getTriangles(); 312 | } 313 | return concatenate(allTriangles); 314 | } 315 | 316 | @Override 317 | public boolean equals(Object o) { 318 | if (this == o) { 319 | return true; 320 | } 321 | if (o == null || getClass() != o.getClass()) { 322 | return false; 323 | } 324 | OctreeNode that = (OctreeNode) o; 325 | return depth == that.depth && 326 | isLeafNode == that.isLeafNode && 327 | min.equals(that.min) && 328 | max.equals(that.max) && 329 | Arrays.equals(children, that.children) && 330 | Objects.equals(boundPolygons, that.boundPolygons) && 331 | Objects.equals(currExtents, that.currExtents) && 332 | Objects.equals(totalExtents, that.totalExtents); 333 | } 334 | 335 | @Override 336 | public int hashCode() { 337 | int result = Objects 338 | .hash(depth, min, max, isLeafNode, boundPolygons, currExtents, totalExtents); 339 | result = 31 * result + Arrays.hashCode(children); 340 | return result; 341 | } 342 | 343 | /** 344 | * Concatenates multiple arrays. 345 | * 346 | *

From https://stackoverflow.com/questions/80476/how-can-i-concatenate-two-arrays-in-java 347 | */ 348 | private static T[] concatenate(T[]... arrays) 349 | { 350 | int finalLength = 0; 351 | for (T[] array : arrays) { 352 | finalLength += array.length; 353 | } 354 | 355 | T[] dest = null; 356 | int destPos = 0; 357 | 358 | for (T[] array : arrays) 359 | { 360 | if (dest == null) { 361 | dest = Arrays.copyOf(array, finalLength); 362 | destPos = array.length; 363 | } else { 364 | System.arraycopy(array, 0, dest, destPos, array.length); 365 | destPos += array.length; 366 | } 367 | } 368 | return dest; 369 | } 370 | } 371 | --------------------------------------------------------------------------------