Compare commits
27 Commits
2d2f5090ea
...
18b9a52404
Author | SHA1 | Date | |
---|---|---|---|
18b9a52404 | |||
b40cc15a9f | |||
87a7fbfcff | |||
1080711229 | |||
d89d15f1a4 | |||
94231f6a5b | |||
82b38d4501 | |||
088263b344 | |||
ca769c56b2 | |||
a41b14da48 | |||
2e5659a04a | |||
927f63adf0 | |||
cf0ed6f13f | |||
0f122e2062 | |||
8e77662b33 | |||
d2ca6922ef | |||
9d204f6aa4 | |||
af3dc8dac7 | |||
672dc6af8b | |||
1113b91077 | |||
7757b3d573 | |||
5e52db65d4 | |||
e3a8b08381 | |||
14fd1d73fc | |||
590054a046 | |||
7875befa94 | |||
028d19b118 |
@ -1,7 +1,115 @@
|
|||||||
package eu.jonahbauer.raytracing;
|
package eu.jonahbauer.raytracing;
|
||||||
|
|
||||||
|
import eu.jonahbauer.raytracing.material.DielectricMaterial;
|
||||||
|
import eu.jonahbauer.raytracing.material.LambertianMaterial;
|
||||||
|
import eu.jonahbauer.raytracing.material.Material;
|
||||||
|
import eu.jonahbauer.raytracing.material.MetallicMaterial;
|
||||||
|
import eu.jonahbauer.raytracing.math.Vec3;
|
||||||
|
import eu.jonahbauer.raytracing.render.*;
|
||||||
|
import eu.jonahbauer.raytracing.render.Color;
|
||||||
|
import eu.jonahbauer.raytracing.render.canvas.BufferedImageCanvas;
|
||||||
|
import eu.jonahbauer.raytracing.scene.Hittable;
|
||||||
|
import eu.jonahbauer.raytracing.scene.Scene;
|
||||||
|
import eu.jonahbauer.raytracing.scene.Sphere;
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
|
||||||
|
import javax.swing.*;
|
||||||
|
import java.awt.*;
|
||||||
|
import java.awt.event.WindowAdapter;
|
||||||
|
import java.awt.event.WindowEvent;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
|
||||||
public class Main {
|
public class Main {
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) throws IOException {
|
||||||
System.out.println("Hello world!");
|
var scene = getScene();
|
||||||
|
var camera = Camera.builder()
|
||||||
|
// .withImage(1920, 1080)
|
||||||
|
.withImage(800, 450)
|
||||||
|
.withPosition(new Vec3(13, 2, 3))
|
||||||
|
.withTarget(new Vec3(0, 0, 0))
|
||||||
|
.withSamplesPerPixel(100)
|
||||||
|
.withMaxDepth(10)
|
||||||
|
.withFieldOfView(Math.toRadians(20))
|
||||||
|
.withBlurAngle(Math.toRadians(0.6))
|
||||||
|
.withFocusDistance(10.0)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
var canvas = new BufferedImageCanvas(camera.width(), camera.height());
|
||||||
|
preview(canvas);
|
||||||
|
|
||||||
|
camera.render(scene, canvas, Camera.RenderMode.SAMPLES);
|
||||||
|
ImageFormat.PNG.write(canvas, Path.of("scene-" + System.currentTimeMillis() + ".png"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Thread preview(@NotNull BufferedImageCanvas canvas) {
|
||||||
|
var frame = new JFrame();
|
||||||
|
frame.setSize(canvas.width(), canvas.height());
|
||||||
|
frame.setDefaultCloseOperation(JFrame.DISPOSE_ON_CLOSE);
|
||||||
|
frame.setContentPane(new JPanel() {
|
||||||
|
@Override
|
||||||
|
protected void paintComponent(Graphics g) {
|
||||||
|
g.drawImage(canvas.getImage(), 0, 0, null);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
frame.setResizable(false);
|
||||||
|
frame.setVisible(true);
|
||||||
|
|
||||||
|
var update = Thread.ofVirtual().start(() -> {
|
||||||
|
while (!Thread.interrupted()) {
|
||||||
|
try {
|
||||||
|
Thread.sleep(1000);
|
||||||
|
} catch (InterruptedException ex) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
frame.repaint();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
frame.addWindowListener(new WindowAdapter() {
|
||||||
|
@Override
|
||||||
|
public void windowClosing(WindowEvent e) {
|
||||||
|
update.interrupt();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return update;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static @NotNull Scene getScene() {
|
||||||
|
var objects = new ArrayList<Hittable>();
|
||||||
|
objects.add(new Sphere(new Vec3(0, -1000, 0), 1000, new LambertianMaterial(new Color(0.5, 0.5, 0.5))));
|
||||||
|
|
||||||
|
for (int a = -11; a < 11; a++) {
|
||||||
|
for (int b = -11; b < 11; b++) {
|
||||||
|
var center = new Vec3(a + 0.9 * Math.random(), 0.2, b + 0.9 * Math.random());
|
||||||
|
if (Vec3.distance(center, new Vec3(4, 0.2, 0)) <= 0.9) continue;
|
||||||
|
|
||||||
|
Material material;
|
||||||
|
var rnd = Math.random();
|
||||||
|
if (rnd < 0.8) {
|
||||||
|
// diffuse
|
||||||
|
var albedo = Color.multiply(Color.random(), Color.random());
|
||||||
|
material = new LambertianMaterial(albedo);
|
||||||
|
} else if (rnd < 0.95) {
|
||||||
|
// metal
|
||||||
|
var albedo = Color.random(0.5, 1.0);
|
||||||
|
var fuzz = Math.random() * 0.5;
|
||||||
|
material = new MetallicMaterial(albedo, fuzz);
|
||||||
|
} else {
|
||||||
|
// glass
|
||||||
|
material = new DielectricMaterial(1.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
objects.add(new Sphere(center, 0.2, material));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
objects.add(new Sphere(new Vec3(0, 1, 0), 1.0, new DielectricMaterial(1.5)));
|
||||||
|
objects.add(new Sphere(new Vec3(-4, 1, 0), 1.0, new LambertianMaterial(new Color(0.4, 0.2, 0.1))));
|
||||||
|
objects.add(new Sphere(new Vec3(4, 1, 0), 1.0, new MetallicMaterial(new Color(0.7, 0.6, 0.5))));
|
||||||
|
|
||||||
|
return new Scene(objects);
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -0,0 +1,32 @@
|
|||||||
|
package eu.jonahbauer.raytracing.material;
|
||||||
|
|
||||||
|
import eu.jonahbauer.raytracing.math.Ray;
|
||||||
|
import eu.jonahbauer.raytracing.math.Vec3;
|
||||||
|
import eu.jonahbauer.raytracing.render.Color;
|
||||||
|
import eu.jonahbauer.raytracing.scene.HitResult;
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public record DielectricMaterial(double refractionIndex) implements Material {
|
||||||
|
@Override
|
||||||
|
public @NotNull Optional<ScatterResult> scatter(@NotNull Ray ray, @NotNull HitResult hit) {
|
||||||
|
var ri = hit.frontFace() ? (1 / refractionIndex) : refractionIndex;
|
||||||
|
|
||||||
|
var cosTheta = Math.min(- ray.direction().unit().times(hit.normal()), 1.0);
|
||||||
|
var reflectance = reflectance(cosTheta);
|
||||||
|
var reflect = reflectance > Math.random();
|
||||||
|
|
||||||
|
var newDirection = (reflect ? Optional.<Vec3>empty() : Vec3.refract(ray.direction(), hit.normal(), ri))
|
||||||
|
.orElseGet(() -> Vec3.reflect(ray.direction(), hit.normal()));
|
||||||
|
|
||||||
|
return Optional.of(new ScatterResult(new Ray(hit.position(), newDirection), Color.WHITE));
|
||||||
|
}
|
||||||
|
|
||||||
|
private double reflectance(double cos) {
|
||||||
|
// use schlick's approximation for reflectance
|
||||||
|
var r0 = (1 - refractionIndex) / (1 + refractionIndex);
|
||||||
|
r0 = r0 * r0;
|
||||||
|
return r0 + (1 - r0) * (1 - cos) * (1 - cos) * (1 - cos) * (1 - cos) * (1 - cos);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,25 @@
|
|||||||
|
package eu.jonahbauer.raytracing.material;
|
||||||
|
|
||||||
|
import eu.jonahbauer.raytracing.math.Ray;
|
||||||
|
import eu.jonahbauer.raytracing.math.Vec3;
|
||||||
|
import eu.jonahbauer.raytracing.render.Color;
|
||||||
|
import eu.jonahbauer.raytracing.scene.HitResult;
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public record LambertianMaterial(@NotNull Color albedo) implements Material {
|
||||||
|
public LambertianMaterial {
|
||||||
|
Objects.requireNonNull(albedo, "albedo");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public @NotNull Optional<ScatterResult> scatter(@NotNull Ray ray, @NotNull HitResult hit) {
|
||||||
|
var newDirection = hit.normal().plus(Vec3.random(true));
|
||||||
|
if (newDirection.isNearZero()) newDirection = hit.normal();
|
||||||
|
|
||||||
|
var scattered = new Ray(hit.position(), newDirection);
|
||||||
|
return Optional.of(new ScatterResult(scattered, albedo));
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,21 @@
|
|||||||
|
package eu.jonahbauer.raytracing.material;
|
||||||
|
|
||||||
|
import eu.jonahbauer.raytracing.math.Ray;
|
||||||
|
import eu.jonahbauer.raytracing.render.Color;
|
||||||
|
import eu.jonahbauer.raytracing.scene.HitResult;
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public interface Material {
|
||||||
|
|
||||||
|
@NotNull Optional<ScatterResult> scatter(@NotNull Ray ray, @NotNull HitResult hit);
|
||||||
|
|
||||||
|
record ScatterResult(@NotNull Ray ray, @NotNull Color attenuation) {
|
||||||
|
public ScatterResult {
|
||||||
|
Objects.requireNonNull(ray, "ray");
|
||||||
|
Objects.requireNonNull(attenuation, "attenuation");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,31 @@
|
|||||||
|
package eu.jonahbauer.raytracing.material;
|
||||||
|
|
||||||
|
import eu.jonahbauer.raytracing.math.Ray;
|
||||||
|
import eu.jonahbauer.raytracing.math.Vec3;
|
||||||
|
import eu.jonahbauer.raytracing.render.Color;
|
||||||
|
import eu.jonahbauer.raytracing.scene.HitResult;
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public record MetallicMaterial(@NotNull Color albedo, double fuzz) implements Material {
|
||||||
|
|
||||||
|
public MetallicMaterial(@NotNull Color albedo) {
|
||||||
|
this(albedo, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
public MetallicMaterial {
|
||||||
|
Objects.requireNonNull(albedo, "albedo");
|
||||||
|
if (fuzz < 0 || !Double.isFinite(fuzz)) throw new IllegalArgumentException("fuzz must be non-negative");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public @NotNull Optional<ScatterResult> scatter(@NotNull Ray ray, @NotNull HitResult hit) {
|
||||||
|
var newDirection = Vec3.reflect(ray.direction(), hit.normal());
|
||||||
|
if (fuzz > 0) {
|
||||||
|
newDirection = newDirection.unit().plus(Vec3.random(true).times(fuzz));
|
||||||
|
}
|
||||||
|
return Optional.of(new ScatterResult(new Ray(hit.position(), newDirection), albedo));
|
||||||
|
}
|
||||||
|
}
|
19
src/main/java/eu/jonahbauer/raytracing/math/Range.java
Normal file
19
src/main/java/eu/jonahbauer/raytracing/math/Range.java
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
package eu.jonahbauer.raytracing.math;
|
||||||
|
|
||||||
|
public record Range(double min, double max) {
|
||||||
|
public static final Range EMPTY = new Range(Double.POSITIVE_INFINITY, Double.NEGATIVE_INFINITY);
|
||||||
|
public static final Range UNIVERSE = new Range(Double.NEGATIVE_INFINITY, Double.POSITIVE_INFINITY);
|
||||||
|
public static final Range NON_NEGATIVE = new Range(0, Double.POSITIVE_INFINITY);
|
||||||
|
|
||||||
|
public Range {
|
||||||
|
if (Double.isNaN(min) || Double.isNaN(max)) throw new IllegalArgumentException("min and max must not be NaN");
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean contains(double value) {
|
||||||
|
return min <= value && value <= max;
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean surrounds(double value) {
|
||||||
|
return min < value && value < max;
|
||||||
|
}
|
||||||
|
}
|
21
src/main/java/eu/jonahbauer/raytracing/math/Ray.java
Normal file
21
src/main/java/eu/jonahbauer/raytracing/math/Ray.java
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
package eu.jonahbauer.raytracing.math;
|
||||||
|
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
public record Ray(@NotNull Vec3 origin, @NotNull Vec3 direction) {
|
||||||
|
public Ray {
|
||||||
|
Objects.requireNonNull(origin, "origin");
|
||||||
|
Objects.requireNonNull(direction, "direction");
|
||||||
|
}
|
||||||
|
|
||||||
|
public @NotNull Vec3 at(double t) {
|
||||||
|
if (t < 0) throw new IllegalArgumentException("t must not be negative");
|
||||||
|
return new Vec3(
|
||||||
|
origin().x() + t * direction.x(),
|
||||||
|
origin().y() + t * direction.y(),
|
||||||
|
origin().z() + t * direction.z()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
117
src/main/java/eu/jonahbauer/raytracing/math/Vec3.java
Normal file
117
src/main/java/eu/jonahbauer/raytracing/math/Vec3.java
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
package eu.jonahbauer.raytracing.math;
|
||||||
|
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public record Vec3(double x, double y, double z) {
|
||||||
|
public static final Vec3 ZERO = new Vec3(0, 0, 0);
|
||||||
|
public static final Vec3 UNIT_X = new Vec3(1, 0, 0);
|
||||||
|
public static final Vec3 UNIT_Y = new Vec3(0, 1, 0);
|
||||||
|
public static final Vec3 UNIT_Z = new Vec3(0, 0, 1);
|
||||||
|
|
||||||
|
public Vec3 {
|
||||||
|
if (!Double.isFinite(x) || !Double.isFinite(y) || !Double.isFinite(z)) {
|
||||||
|
throw new IllegalArgumentException("x, y and z must be finite");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static @NotNull Vec3 random() {
|
||||||
|
return random(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static @NotNull Vec3 random(boolean unit) {
|
||||||
|
var random = new Vec3(
|
||||||
|
2 * Math.random() - 1,
|
||||||
|
2 * Math.random() - 1,
|
||||||
|
2 * Math.random() - 1
|
||||||
|
);
|
||||||
|
return unit ? random.unit() : random;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static @NotNull Vec3 reflect(@NotNull Vec3 vec, @NotNull Vec3 normal) {
|
||||||
|
return vec.minus(normal.times(2 * normal.times(vec)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static @NotNull Optional<Vec3> refract(@NotNull Vec3 vec, @NotNull Vec3 normal, double ri) {
|
||||||
|
vec = vec.unit();
|
||||||
|
var cosTheta = Math.min(- vec.times(normal), 1.0);
|
||||||
|
var sinTheta = Math.sqrt(1 - cosTheta * cosTheta);
|
||||||
|
if (ri * sinTheta > 1) return Optional.empty();
|
||||||
|
|
||||||
|
var rOutPerp = vec.plus(normal.times(cosTheta)).times(ri);
|
||||||
|
var rOutParallel = normal.times(- Math.sqrt(Math.abs(1 - rOutPerp.squared())));
|
||||||
|
return Optional.of(rOutPerp.plus(rOutParallel));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static @NotNull Vec3 rotate(@NotNull Vec3 vec, @NotNull Vec3 axis, double angle) {
|
||||||
|
Vec3 vxp = axis.cross(vec);
|
||||||
|
Vec3 vxvxp = axis.cross(vxp);
|
||||||
|
return vec.plus(vxp.times(Math.sin(angle))).plus(vxvxp.times(1 - Math.cos(angle)));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static double distance(@NotNull Vec3 a, @NotNull Vec3 b) {
|
||||||
|
return a.minus(b).length();
|
||||||
|
}
|
||||||
|
|
||||||
|
public @NotNull Vec3 plus(@NotNull Vec3 b) {
|
||||||
|
return new Vec3(this.x + b.x, this.y + b.y, this.z + b.z);
|
||||||
|
}
|
||||||
|
|
||||||
|
public @NotNull Vec3 minus(@NotNull Vec3 b) {
|
||||||
|
return new Vec3(this.x - b.x, this.y - b.y, this.z - b.z);
|
||||||
|
}
|
||||||
|
|
||||||
|
public double times(@NotNull Vec3 b) {
|
||||||
|
return this.x * b.x + this.y * b.y + this.z * b.z;
|
||||||
|
}
|
||||||
|
|
||||||
|
public @NotNull Vec3 times(double b) {
|
||||||
|
return new Vec3(this.x * b, this.y * b, this.z * b);
|
||||||
|
}
|
||||||
|
|
||||||
|
public @NotNull Vec3 neg() {
|
||||||
|
return new Vec3(-x, -y, -z);
|
||||||
|
}
|
||||||
|
|
||||||
|
public @NotNull Vec3 cross(@NotNull Vec3 b) {
|
||||||
|
return new Vec3(
|
||||||
|
this.y() * b.z() - b.y() * this.z(),
|
||||||
|
this.z() * b.x() - b.z() * this.x(),
|
||||||
|
this.x() * b.y() - b.x() * this.y()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public @NotNull Vec3 div(double b) {
|
||||||
|
return new Vec3(this.x / b, this.y / b, this.z / b);
|
||||||
|
}
|
||||||
|
|
||||||
|
public double squared() {
|
||||||
|
return this.x * this.x + this.y * this.y + this.z * this.z;
|
||||||
|
}
|
||||||
|
|
||||||
|
public double length() {
|
||||||
|
return Math.sqrt(squared());
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isNearZero() {
|
||||||
|
var s = 1e-8;
|
||||||
|
return Math.abs(x) < s && Math.abs(y) < s && Math.abs(z) < s;
|
||||||
|
}
|
||||||
|
|
||||||
|
public @NotNull Vec3 unit() {
|
||||||
|
return div(length());
|
||||||
|
}
|
||||||
|
|
||||||
|
public @NotNull Vec3 withX(double x) {
|
||||||
|
return new Vec3(x, y, z);
|
||||||
|
}
|
||||||
|
|
||||||
|
public @NotNull Vec3 withY(double y) {
|
||||||
|
return new Vec3(x, y, z);
|
||||||
|
}
|
||||||
|
|
||||||
|
public @NotNull Vec3 withZ(double z) {
|
||||||
|
return new Vec3(x, y, z);
|
||||||
|
}
|
||||||
|
}
|
312
src/main/java/eu/jonahbauer/raytracing/render/Camera.java
Normal file
312
src/main/java/eu/jonahbauer/raytracing/render/Camera.java
Normal file
@ -0,0 +1,312 @@
|
|||||||
|
package eu.jonahbauer.raytracing.render;
|
||||||
|
|
||||||
|
import eu.jonahbauer.raytracing.math.Range;
|
||||||
|
import eu.jonahbauer.raytracing.math.Ray;
|
||||||
|
import eu.jonahbauer.raytracing.math.Vec3;
|
||||||
|
import eu.jonahbauer.raytracing.render.canvas.Canvas;
|
||||||
|
import eu.jonahbauer.raytracing.render.canvas.Image;
|
||||||
|
import eu.jonahbauer.raytracing.scene.Scene;
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
import org.jetbrains.annotations.Nullable;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
import java.util.function.Function;
|
||||||
|
import java.util.stream.IntStream;
|
||||||
|
import java.util.stream.LongStream;
|
||||||
|
|
||||||
|
public final class Camera {
|
||||||
|
// image size
|
||||||
|
private final int width;
|
||||||
|
private final int height;
|
||||||
|
|
||||||
|
// camera position and orientation
|
||||||
|
private final @NotNull Vec3 origin;
|
||||||
|
|
||||||
|
// rendering
|
||||||
|
private final int samplesPerPixel;
|
||||||
|
private final int maxDepth;
|
||||||
|
private final double gamma;
|
||||||
|
private final double blurRadius;
|
||||||
|
|
||||||
|
// internal properties
|
||||||
|
private final @NotNull Vec3 u;
|
||||||
|
private final @NotNull Vec3 v;
|
||||||
|
|
||||||
|
private final @NotNull Vec3 pixelU;
|
||||||
|
private final @NotNull Vec3 pixelV;
|
||||||
|
private final @NotNull Vec3 pixel00;
|
||||||
|
|
||||||
|
public static @NotNull Builder builder() {
|
||||||
|
return new Builder();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Camera() {
|
||||||
|
this(new Builder());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Camera(@NotNull Builder builder) {
|
||||||
|
this.width = builder.imageWidth;
|
||||||
|
this.height = builder.imageHeight;
|
||||||
|
|
||||||
|
var viewportHeight = 2 * Math.tan(0.5 * builder.fov) * builder.focusDistance;
|
||||||
|
var viewportWidth = viewportHeight * ((double) width / height);
|
||||||
|
|
||||||
|
this.origin = builder.position;
|
||||||
|
var direction = (builder.direction == null ? builder.target.minus(builder.position).unit() : builder.direction);
|
||||||
|
|
||||||
|
this.samplesPerPixel = builder.samplePerPixel;
|
||||||
|
this.maxDepth = builder.maxDepth;
|
||||||
|
this.gamma = builder.gamma;
|
||||||
|
this.blurRadius = Math.tan(0.5 * builder.blurAngle) * builder.focusDistance;
|
||||||
|
|
||||||
|
// project direction the horizontal plane
|
||||||
|
var dXZ = direction.withY(0).unit();
|
||||||
|
this.u = Vec3.rotate(
|
||||||
|
new Vec3(- dXZ.z(), 0, dXZ.x()), // perpendicular to d in horizontal plane
|
||||||
|
direction, builder.rotation
|
||||||
|
);
|
||||||
|
this.v = direction.cross(u); // perpendicular to viewportU and direction
|
||||||
|
|
||||||
|
this.pixelU = u.times(viewportWidth / width);
|
||||||
|
this.pixelV = v.times(viewportHeight / height);
|
||||||
|
|
||||||
|
this.pixel00 = origin.plus(direction.times(builder.focusDistance))
|
||||||
|
.minus(u.times(0.5 * viewportWidth)).minus(v.times(0.5 * viewportHeight))
|
||||||
|
.plus(pixelU.div(2)).plus(pixelV.div(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
public @NotNull Image render(@NotNull Scene scene) {
|
||||||
|
var image = new Image(width, height);
|
||||||
|
render(scene, image);
|
||||||
|
return image;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void render(@NotNull Scene scene, @NotNull Canvas canvas) {
|
||||||
|
render(scene, canvas, RenderMode.SEQUENTIEL);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void render(@NotNull Scene scene, @NotNull Canvas canvas, @NotNull RenderMode mode) {
|
||||||
|
if (canvas.width() != width || canvas.height() != height) throw new IllegalArgumentException();
|
||||||
|
|
||||||
|
var progress = new AtomicInteger();
|
||||||
|
mode.render(this, scene, canvas, () -> {
|
||||||
|
var val = progress.incrementAndGet();
|
||||||
|
if (val % 1000 == 0) System.out.println(val);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private @NotNull Ray getRay(int x, int y) {
|
||||||
|
var origin = this.origin;
|
||||||
|
if (blurRadius > 0) {
|
||||||
|
double bu, bv;
|
||||||
|
do {
|
||||||
|
bu = 2 * Math.random() - 1;
|
||||||
|
bv = 2 * Math.random() - 1;
|
||||||
|
} while (bu * bu + bv * bv >= 1);
|
||||||
|
|
||||||
|
origin = origin.plus(u.times(blurRadius * bu)).plus(v.times(blurRadius * bv));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Ray(origin, getPixel(x, y).minus(origin));
|
||||||
|
}
|
||||||
|
|
||||||
|
private @NotNull Vec3 getPixel(int x, int y) {
|
||||||
|
Objects.checkIndex(x, width);
|
||||||
|
Objects.checkIndex(y, height);
|
||||||
|
|
||||||
|
double dx = x + Math.random() - 0.5;
|
||||||
|
double dy = y + Math.random() - 0.5;
|
||||||
|
return pixel00.plus(pixelU.times(dx)).plus(pixelV.times(dy));
|
||||||
|
}
|
||||||
|
|
||||||
|
private @NotNull Color getColor(@NotNull Scene scene, @NotNull Ray ray) {
|
||||||
|
return getColor(scene, ray, maxDepth);
|
||||||
|
}
|
||||||
|
|
||||||
|
private @NotNull Color getColor(@NotNull Scene scene, @NotNull Ray ray, int depth) {
|
||||||
|
if (depth <= 0) return Color.BLACK;
|
||||||
|
|
||||||
|
var optional = scene.hit(ray, new Range(0.001, Double.POSITIVE_INFINITY));
|
||||||
|
if (optional.isPresent()) {
|
||||||
|
var hit = optional.get();
|
||||||
|
var material = hit.material();
|
||||||
|
return material.scatter(ray, hit)
|
||||||
|
.map(scatter -> Color.multiply(scatter.attenuation(), getColor(scene, scatter.ray(), depth - 1)))
|
||||||
|
.orElse(Color.BLACK);
|
||||||
|
} else {
|
||||||
|
return getSkyboxColor(ray);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static @NotNull Color getSkyboxColor(@NotNull Ray ray) {
|
||||||
|
// altitude from -pi/2 to pi/2
|
||||||
|
var alt = Math.copySign(
|
||||||
|
Math.acos(ray.direction().withY(0).unit().times(ray.direction().unit())),
|
||||||
|
ray.direction().y()
|
||||||
|
);
|
||||||
|
return Color.lerp(Color.WHITE, Color.SKY, alt / Math.PI + 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int width() {
|
||||||
|
return width;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int height() {
|
||||||
|
return height;
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum RenderMode {
|
||||||
|
SEQUENTIEL {
|
||||||
|
@Override
|
||||||
|
public void render(@NotNull Camera camera, @NotNull Scene scene, @NotNull Canvas canvas, @NotNull Runnable onProgressUpdate) {
|
||||||
|
for (int y = 0; y < camera.height; y++) {
|
||||||
|
for (int x = 0; x < camera.width; x++) {
|
||||||
|
Color color = Color.BLACK;
|
||||||
|
for (int i = 1; i <= camera.samplesPerPixel; i++) {
|
||||||
|
var ray = camera.getRay(x, y);
|
||||||
|
var c = camera.getColor(scene, ray);
|
||||||
|
color = Color.average(color, c, i);
|
||||||
|
}
|
||||||
|
canvas.set(x, y, color);
|
||||||
|
onProgressUpdate.run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
PARALLEL {
|
||||||
|
@Override
|
||||||
|
public void render(@NotNull Camera camera, @NotNull Scene scene, @NotNull Canvas canvas, @NotNull Runnable onProgressUpdate) {
|
||||||
|
coordinates(camera.width, camera.height).parallel().forEach(pos -> {
|
||||||
|
var x = (int) pos;
|
||||||
|
var y = (int) (pos >> 32);
|
||||||
|
Color color = Color.BLACK;
|
||||||
|
for (int i = 1; i <= camera.samplesPerPixel; i++) {
|
||||||
|
var ray = camera.getRay(x, y);
|
||||||
|
var c = camera.getColor(scene, ray);
|
||||||
|
color = Color.average(color, c, i);
|
||||||
|
}
|
||||||
|
canvas.set(x, y, color);
|
||||||
|
onProgressUpdate.run();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
SAMPLES {
|
||||||
|
@Override
|
||||||
|
public void render(@NotNull Camera camera, @NotNull Scene scene, @NotNull Canvas canvas, @NotNull Runnable onProgressUpdate) {
|
||||||
|
for (int i = 1; i <= camera.samplesPerPixel; i++) {
|
||||||
|
var sample = i;
|
||||||
|
coordinates(camera.width, camera.height).forEach(pos -> {
|
||||||
|
var x = (int) pos;
|
||||||
|
var y = (int) (pos >> 32);
|
||||||
|
var ray = camera.getRay(x, y);
|
||||||
|
var color = Color.average(canvas.get(x, y), camera.getColor(scene, ray), sample);
|
||||||
|
canvas.set(x, y, color);
|
||||||
|
});
|
||||||
|
onProgressUpdate.run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
;
|
||||||
|
|
||||||
|
private static LongStream coordinates(int width, int height) {
|
||||||
|
return IntStream.range(0, height)
|
||||||
|
.mapToObj(y -> IntStream.range(0, width).mapToLong(x -> (long) y << 32 | x))
|
||||||
|
.flatMapToLong(Function.identity());
|
||||||
|
}
|
||||||
|
|
||||||
|
public abstract void render(@NotNull Camera camera, @NotNull Scene scene, @NotNull Canvas canvas, @NotNull Runnable onProgressUpdate);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class Builder {
|
||||||
|
private int imageWidth = 1920;
|
||||||
|
private int imageHeight = 1080;
|
||||||
|
|
||||||
|
private @NotNull Vec3 position = Vec3.ZERO;
|
||||||
|
private @Nullable Vec3 direction = Vec3.UNIT_Z.neg();
|
||||||
|
private @Nullable Vec3 target = null;
|
||||||
|
private double rotation = 0.0;
|
||||||
|
|
||||||
|
private double fov = 0.5 * Math.PI;
|
||||||
|
private double focusDistance = 10;
|
||||||
|
private double blurAngle = 0.0;
|
||||||
|
|
||||||
|
private int samplePerPixel = 100;
|
||||||
|
private int maxDepth = 10;
|
||||||
|
private double gamma = 2.0;
|
||||||
|
|
||||||
|
private Builder() {}
|
||||||
|
|
||||||
|
public @NotNull Builder withImage(int width, int height) {
|
||||||
|
if (width <= 0 || height <= 0) throw new IllegalArgumentException("width and height must be positive");
|
||||||
|
this.imageWidth = width;
|
||||||
|
this.imageHeight = height;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public @NotNull Builder withPosition(@NotNull Vec3 position) {
|
||||||
|
this.position = Objects.requireNonNull(position);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public @NotNull Builder withDirection(@NotNull Vec3 direction) {
|
||||||
|
this.direction = Objects.requireNonNull(direction).unit();
|
||||||
|
this.target = null;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public @NotNull Builder withTarget(@NotNull Vec3 target) {
|
||||||
|
this.target = Objects.requireNonNull(target);
|
||||||
|
this.direction = null;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public @NotNull Builder withRotation(double rotation) {
|
||||||
|
if (!Double.isFinite(rotation)) throw new IllegalArgumentException("rotation must be finite");
|
||||||
|
this.rotation = rotation;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public @NotNull Builder withFieldOfView(double fov) {
|
||||||
|
if (fov <= 0 || fov >= Math.PI || !Double.isFinite(fov)) throw new IllegalArgumentException("fov must be in the range (0, π)");
|
||||||
|
this.fov = fov;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public @NotNull Builder withFocusDistance(double focusDistance) {
|
||||||
|
if (focusDistance <= 0 || !Double.isFinite(focusDistance)) throw new IllegalArgumentException("focus distance must be positive");
|
||||||
|
this.focusDistance = focusDistance;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public @NotNull Builder withBlurAngle(double angle) {
|
||||||
|
if (angle < 0 || angle >= Math.PI || !Double.isFinite(angle)) throw new IllegalArgumentException("blur-angle must be in the range [0, π)");
|
||||||
|
this.blurAngle = angle;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public @NotNull Builder withSamplesPerPixel(int samples) {
|
||||||
|
if (samples <= 0) throw new IllegalArgumentException("samples must be positive");
|
||||||
|
this.samplePerPixel = samples;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public @NotNull Builder withMaxDepth(int depth) {
|
||||||
|
if (depth <= 0) throw new IllegalArgumentException("depth must be positive");
|
||||||
|
this.maxDepth = depth;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public @NotNull Builder withGamma(double gamma) {
|
||||||
|
if (gamma <= 0 || !Double.isFinite(gamma)) throw new IllegalArgumentException("gamma must be positive");
|
||||||
|
this.gamma = gamma;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public @NotNull Camera build() {
|
||||||
|
return new Camera(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
69
src/main/java/eu/jonahbauer/raytracing/render/Color.java
Normal file
69
src/main/java/eu/jonahbauer/raytracing/render/Color.java
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
package eu.jonahbauer.raytracing.render;
|
||||||
|
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
|
||||||
|
public record Color(double r, double g, double b) {
|
||||||
|
public static final @NotNull Color BLACK = new Color(0.0, 0.0, 0.0);
|
||||||
|
public static final @NotNull Color WHITE = new Color(1.0, 1.0, 1.0);
|
||||||
|
public static final @NotNull Color SKY = new Color(0.5, 0.7, 1.0);
|
||||||
|
public static final @NotNull Color RED = new Color(1.0, 0.0, 0.0);
|
||||||
|
public static final @NotNull Color GREEN = new Color(0.0, 1.0, 0.0);
|
||||||
|
public static final @NotNull Color BLUE = new Color(0.0, 0.0, 1.0);
|
||||||
|
|
||||||
|
public static @NotNull Color lerp(@NotNull Color a, @NotNull Color b, double t) {
|
||||||
|
if (t < 0) return a;
|
||||||
|
if (t > 1) return b;
|
||||||
|
return new Color(
|
||||||
|
(1 - t) * a.r + t * b.r,
|
||||||
|
(1 - t) * a.g + t * b.g,
|
||||||
|
(1 - t) * a.b + t * b.b
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static @NotNull Color multiply(@NotNull Color a, @NotNull Color b) {
|
||||||
|
return new Color(a.r() * b.r(), a.g() * b.g(), a.b() * b.b());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static @NotNull Color random() {
|
||||||
|
return new Color(Math.random(), Math.random(), Math.random());
|
||||||
|
}
|
||||||
|
|
||||||
|
public static @NotNull Color random(double min, double max) {
|
||||||
|
var span = max - min;
|
||||||
|
return new Color(
|
||||||
|
Math.fma(Math.random(), span, min),
|
||||||
|
Math.fma(Math.random(), span, min),
|
||||||
|
Math.fma(Math.random(), span, min)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static @NotNull Color average(@NotNull Color current, @NotNull Color next, int index) {
|
||||||
|
return new Color(
|
||||||
|
current.r() + (next.r() - current.r()) / index,
|
||||||
|
current.g() + (next.g() - current.g()) / index,
|
||||||
|
current.b() + (next.b() - current.b()) / index
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Color {
|
||||||
|
if (r < 0 || r > 1 || g < 0 || g > 1 || b < 0 || b > 1) {
|
||||||
|
throw new IllegalArgumentException("r, g and b must be in the range 0 to 1");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public Color(int red, int green, int blue) {
|
||||||
|
this(red / 255f, green / 255f, blue / 255f);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int red() {
|
||||||
|
return (int) (255.99 * r);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int green() {
|
||||||
|
return (int) (255.99 * g);
|
||||||
|
}
|
||||||
|
|
||||||
|
public int blue() {
|
||||||
|
return (int) (255.99 * b);
|
||||||
|
}
|
||||||
|
}
|
131
src/main/java/eu/jonahbauer/raytracing/render/ImageFormat.java
Normal file
131
src/main/java/eu/jonahbauer/raytracing/render/ImageFormat.java
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
package eu.jonahbauer.raytracing.render;
|
||||||
|
|
||||||
|
import eu.jonahbauer.raytracing.render.canvas.Canvas;
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
|
||||||
|
import java.io.*;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.zip.CRC32;
|
||||||
|
import java.util.zip.CheckedOutputStream;
|
||||||
|
import java.util.zip.DeflaterOutputStream;
|
||||||
|
|
||||||
|
public enum ImageFormat {
|
||||||
|
PPM {
|
||||||
|
@Override
|
||||||
|
public void write(@NotNull Canvas image, @NotNull OutputStream out) throws IOException {
|
||||||
|
try (var writer = new BufferedWriter(new OutputStreamWriter(out, StandardCharsets.US_ASCII))) {
|
||||||
|
writer.write("P3\n");
|
||||||
|
writer.write(String.valueOf(image.width()));
|
||||||
|
writer.write(" ");
|
||||||
|
writer.write(String.valueOf(image.height()));
|
||||||
|
writer.write("\n255\n");
|
||||||
|
|
||||||
|
var it = image.pixels().iterator();
|
||||||
|
while (it.hasNext()) {
|
||||||
|
var color = it.next();
|
||||||
|
writer.write(String.valueOf(color.red()));
|
||||||
|
writer.write(" ");
|
||||||
|
writer.write(String.valueOf(color.green()));
|
||||||
|
writer.write(" ");
|
||||||
|
writer.write(String.valueOf(color.blue()));
|
||||||
|
writer.write("\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
PNG {
|
||||||
|
private static final byte[] MAGIC = new byte[] { (byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A };
|
||||||
|
private static final int IHDR_LENGTH = 13;
|
||||||
|
private static final int IHDR_TYPE = 0x49484452;
|
||||||
|
private static final int IDAT_TYPE = 0x49444154;
|
||||||
|
private static final int IEND_TYPE = 0x49454E44;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void write(@NotNull Canvas image, @NotNull OutputStream out) throws IOException {
|
||||||
|
try (var data = new NoCloseDataOutputStream(out); var _ = data.closeable()) {
|
||||||
|
data.write(MAGIC);
|
||||||
|
|
||||||
|
writeIHDR(image, data);
|
||||||
|
writeIDAT(image, data);
|
||||||
|
writeIEND(image, data);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void writeIHDR(@NotNull Canvas image, @NotNull DataOutputStream data) throws IOException {
|
||||||
|
data.writeInt(IHDR_LENGTH);
|
||||||
|
try (
|
||||||
|
var crc = new CheckedOutputStream(data, new CRC32());
|
||||||
|
var ihdr = new DataOutputStream(crc)
|
||||||
|
) {
|
||||||
|
ihdr.writeInt(IHDR_TYPE);
|
||||||
|
ihdr.writeInt(image.width()); // image width
|
||||||
|
ihdr.writeInt(image.height()); // image height
|
||||||
|
ihdr.writeByte(8); // bit depth
|
||||||
|
ihdr.writeByte(2); // color type
|
||||||
|
ihdr.writeByte(0); // compression method
|
||||||
|
ihdr.writeByte(0); // filter method
|
||||||
|
ihdr.writeByte(0); // interlace method
|
||||||
|
ihdr.flush();
|
||||||
|
data.writeInt((int) crc.getChecksum().getValue());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void writeIDAT(@NotNull Canvas image, @NotNull DataOutputStream data) throws IOException {
|
||||||
|
try (
|
||||||
|
var baos = new ByteArrayOutputStream();
|
||||||
|
var crc = new CheckedOutputStream(baos, new CRC32());
|
||||||
|
var idat = new DataOutputStream(crc)
|
||||||
|
) {
|
||||||
|
idat.writeInt(IDAT_TYPE);
|
||||||
|
|
||||||
|
try (var deflate = new DataOutputStream(new DeflaterOutputStream(idat))) {
|
||||||
|
var pixels = image.pixels().iterator();
|
||||||
|
for (int i = 0; pixels.hasNext(); i = (i + 1) % image.width()) {
|
||||||
|
if (i == 0) deflate.writeByte(0); // filter type
|
||||||
|
var pixel = pixels.next();
|
||||||
|
deflate.writeByte(pixel.red());
|
||||||
|
deflate.writeByte(pixel.green());
|
||||||
|
deflate.writeByte(pixel.blue());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var bytes = baos.toByteArray();
|
||||||
|
data.writeInt(bytes.length);
|
||||||
|
data.write(bytes);
|
||||||
|
data.writeInt((int) crc.getChecksum().getValue());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void writeIEND(@NotNull Canvas image, @NotNull DataOutputStream data) throws IOException {
|
||||||
|
data.writeInt(0);
|
||||||
|
data.writeInt(IEND_TYPE);
|
||||||
|
data.writeInt(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static class NoCloseDataOutputStream extends DataOutputStream {
|
||||||
|
public NoCloseDataOutputStream(OutputStream out) {
|
||||||
|
super(out);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
// do nothing
|
||||||
|
}
|
||||||
|
|
||||||
|
public Closeable closeable() {
|
||||||
|
return super::close;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
;
|
||||||
|
|
||||||
|
public void write(@NotNull Canvas image, @NotNull Path path) throws IOException {
|
||||||
|
try (var out = Files.newOutputStream(path)) {
|
||||||
|
write(image, out);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public abstract void write(@NotNull Canvas image, @NotNull OutputStream out) throws IOException;
|
||||||
|
}
|
@ -0,0 +1,26 @@
|
|||||||
|
package eu.jonahbauer.raytracing.render.canvas;
|
||||||
|
|
||||||
|
import eu.jonahbauer.raytracing.render.Color;
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
|
||||||
|
public class BufferedImageCanvas extends Image {
|
||||||
|
private final @NotNull BufferedImage image;
|
||||||
|
|
||||||
|
public BufferedImageCanvas(int width, int height) {
|
||||||
|
super(width, height);
|
||||||
|
this.image = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
|
||||||
|
}
|
||||||
|
|
||||||
|
public @NotNull BufferedImage getImage() {
|
||||||
|
return image;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void set(int x, int y, @NotNull Color color) {
|
||||||
|
super.set(x, y, color);
|
||||||
|
var rgb = color.red() << 16 | color.green() << 8 | color.blue();
|
||||||
|
image.setRGB(x, y, rgb);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,22 @@
|
|||||||
|
package eu.jonahbauer.raytracing.render.canvas;
|
||||||
|
|
||||||
|
import eu.jonahbauer.raytracing.render.Color;
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
|
||||||
|
import java.util.function.Function;
|
||||||
|
import java.util.stream.IntStream;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
|
public interface Canvas {
|
||||||
|
int width();
|
||||||
|
int height();
|
||||||
|
|
||||||
|
void set(int x, int y, @NotNull Color color);
|
||||||
|
@NotNull Color get(int x, int y);
|
||||||
|
|
||||||
|
default @NotNull Stream<Color> pixels() {
|
||||||
|
return IntStream.range(0, height())
|
||||||
|
.mapToObj(y -> IntStream.range(0, width()).mapToObj(x -> get(x, y)))
|
||||||
|
.flatMap(Function.identity());
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,47 @@
|
|||||||
|
package eu.jonahbauer.raytracing.render.canvas;
|
||||||
|
|
||||||
|
import eu.jonahbauer.raytracing.render.Color;
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
public class Image implements Canvas {
|
||||||
|
private final int width;
|
||||||
|
private final int height;
|
||||||
|
|
||||||
|
private final Color[][] data;
|
||||||
|
|
||||||
|
public Image(int width, int height) {
|
||||||
|
this.width = width;
|
||||||
|
this.height = height;
|
||||||
|
|
||||||
|
if (width <= 0) throw new IllegalArgumentException("width must be positive");
|
||||||
|
if (height <= 0) throw new IllegalArgumentException("height must be positive");
|
||||||
|
|
||||||
|
this.data = new Color[height][width];
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int width() {
|
||||||
|
return width;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int height() {
|
||||||
|
return height;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public @NotNull Color get(int x, int y) {
|
||||||
|
Objects.checkIndex(x, width);
|
||||||
|
Objects.checkIndex(y, height);
|
||||||
|
return Objects.requireNonNullElse(this.data[y][x], Color.BLACK);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void set(int x, int y, @NotNull Color color) {
|
||||||
|
Objects.checkIndex(x, width);
|
||||||
|
Objects.checkIndex(y, height);
|
||||||
|
this.data[y][x] = Objects.requireNonNull(color);
|
||||||
|
}
|
||||||
|
}
|
26
src/main/java/eu/jonahbauer/raytracing/scene/HitResult.java
Normal file
26
src/main/java/eu/jonahbauer/raytracing/scene/HitResult.java
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
package eu.jonahbauer.raytracing.scene;
|
||||||
|
|
||||||
|
import eu.jonahbauer.raytracing.math.Vec3;
|
||||||
|
import eu.jonahbauer.raytracing.material.Material;
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
public record HitResult(
|
||||||
|
double t,
|
||||||
|
@NotNull Vec3 position,
|
||||||
|
@NotNull Vec3 normal,
|
||||||
|
@NotNull Material material,
|
||||||
|
boolean frontFace
|
||||||
|
) implements Comparable<HitResult> {
|
||||||
|
public HitResult {
|
||||||
|
if (t < 0 || !Double.isFinite(t)) throw new IllegalArgumentException("t must be non-negative");
|
||||||
|
Objects.requireNonNull(position, "position");
|
||||||
|
normal = normal.unit();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int compareTo(@NotNull HitResult o) {
|
||||||
|
return Double.compare(t, o.t);
|
||||||
|
}
|
||||||
|
}
|
17
src/main/java/eu/jonahbauer/raytracing/scene/Hittable.java
Normal file
17
src/main/java/eu/jonahbauer/raytracing/scene/Hittable.java
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
package eu.jonahbauer.raytracing.scene;
|
||||||
|
|
||||||
|
import eu.jonahbauer.raytracing.math.Range;
|
||||||
|
import eu.jonahbauer.raytracing.math.Ray;
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public interface Hittable {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@return the value <code>t</code> such that <code>ray.at(t)</code> is the intersection of this shaped closest to
|
||||||
|
* the ray origin, or <code>Double.NaN</code> if the ray does not intersect this shape}
|
||||||
|
* @param ray a ray
|
||||||
|
*/
|
||||||
|
@NotNull Optional<HitResult> hit(@NotNull Ray ray, @NotNull Range range);
|
||||||
|
}
|
31
src/main/java/eu/jonahbauer/raytracing/scene/Scene.java
Normal file
31
src/main/java/eu/jonahbauer/raytracing/scene/Scene.java
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
package eu.jonahbauer.raytracing.scene;
|
||||||
|
|
||||||
|
import eu.jonahbauer.raytracing.math.Range;
|
||||||
|
import eu.jonahbauer.raytracing.math.Ray;
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public record Scene(@NotNull List<@NotNull Hittable> objects) implements Hittable {
|
||||||
|
|
||||||
|
public Scene {
|
||||||
|
objects = List.copyOf(objects);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Scene(@NotNull Hittable @NotNull ... objects) {
|
||||||
|
this(List.of(objects));
|
||||||
|
}
|
||||||
|
|
||||||
|
public @NotNull Optional<HitResult> hit(@NotNull Ray ray, @NotNull Range range) {
|
||||||
|
var result = (HitResult) null;
|
||||||
|
for (var object : objects) {
|
||||||
|
var r = object.hit(ray, range);
|
||||||
|
if (r.isPresent() && range.surrounds(r.get().t())) {
|
||||||
|
result = r.get();
|
||||||
|
range = new Range(range.min(), result.t());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Optional.ofNullable(result);
|
||||||
|
}
|
||||||
|
}
|
58
src/main/java/eu/jonahbauer/raytracing/scene/Sphere.java
Normal file
58
src/main/java/eu/jonahbauer/raytracing/scene/Sphere.java
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
package eu.jonahbauer.raytracing.scene;
|
||||||
|
|
||||||
|
import eu.jonahbauer.raytracing.material.Material;
|
||||||
|
import eu.jonahbauer.raytracing.math.Range;
|
||||||
|
import eu.jonahbauer.raytracing.math.Ray;
|
||||||
|
import eu.jonahbauer.raytracing.math.Vec3;
|
||||||
|
import org.jetbrains.annotations.NotNull;
|
||||||
|
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public record Sphere(@NotNull Vec3 center, double radius, @NotNull Material material) implements Hittable {
|
||||||
|
|
||||||
|
public Sphere {
|
||||||
|
Objects.requireNonNull(center, "center");
|
||||||
|
Objects.requireNonNull(material, "material");
|
||||||
|
if (radius <= 0 || !Double.isFinite(radius)) throw new IllegalArgumentException("radius must be positive");
|
||||||
|
}
|
||||||
|
|
||||||
|
public Sphere(double x, double y, double z, double r, @NotNull Material material) {
|
||||||
|
this(new Vec3(x, y, z), r, material);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public @NotNull Optional<HitResult> hit(@NotNull Ray ray, @NotNull Range range) {
|
||||||
|
var oc = ray.origin().minus(center());
|
||||||
|
|
||||||
|
var a = ray.direction().squared();
|
||||||
|
var h = ray.direction().times(oc);
|
||||||
|
var c = oc.squared() - radius * radius;
|
||||||
|
|
||||||
|
var discriminant = h * h - a * c;
|
||||||
|
if (discriminant < 0) return Optional.empty();
|
||||||
|
|
||||||
|
var sd = Math.sqrt(discriminant);
|
||||||
|
|
||||||
|
double t = (- h - sd) / a;
|
||||||
|
if (!range.surrounds(t)) t = (- h + sd) / a;
|
||||||
|
if (!range.surrounds(t)) return Optional.empty();
|
||||||
|
|
||||||
|
var position = ray.at(t);
|
||||||
|
var normal = position.minus(center);
|
||||||
|
var frontFace = normal.times(ray.direction()) < 0;
|
||||||
|
return Optional.of(new HitResult(t, position, frontFace ? normal : normal.times(-1), material, frontFace));
|
||||||
|
}
|
||||||
|
|
||||||
|
public @NotNull Sphere withCenter(@NotNull Vec3 center) {
|
||||||
|
return new Sphere(center, radius, material);
|
||||||
|
}
|
||||||
|
|
||||||
|
public @NotNull Sphere withCenter(double x, double y, double z) {
|
||||||
|
return withCenter(new Vec3(x, y, z));
|
||||||
|
}
|
||||||
|
|
||||||
|
public @NotNull Sphere withRadius(double radius) {
|
||||||
|
return new Sphere(center, radius, material);
|
||||||
|
}
|
||||||
|
}
|
16
src/test/java/eu/jonahbauer/raytracing/math/RayTest.java
Normal file
16
src/test/java/eu/jonahbauer/raytracing/math/RayTest.java
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
package eu.jonahbauer.raytracing.math;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class RayTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void at() {
|
||||||
|
var origin = new Vec3(0, 0, 0);
|
||||||
|
var direction = new Vec3(1, 2, 3);
|
||||||
|
var ray = new Ray(origin, direction);
|
||||||
|
assertEquals(new Vec3(5, 10, 15), ray.at(5));
|
||||||
|
}
|
||||||
|
}
|
61
src/test/java/eu/jonahbauer/raytracing/math/Vec3Test.java
Normal file
61
src/test/java/eu/jonahbauer/raytracing/math/Vec3Test.java
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
package eu.jonahbauer.raytracing.math;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class Vec3Test {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void plus() {
|
||||||
|
var a = new Vec3(1, 2, 3);
|
||||||
|
var b = new Vec3(-1, 1, -2);
|
||||||
|
assertEquals(new Vec3(0, 3, 1), a.plus(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void minus() {
|
||||||
|
var a = new Vec3(1, 2, 3);
|
||||||
|
var b = new Vec3(-1, 1, -2);
|
||||||
|
assertEquals(new Vec3(2, 1, 5), a.minus(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void timesVec() {
|
||||||
|
var a = new Vec3(1, 2, 3);
|
||||||
|
var b = new Vec3(-1, 1, -2);
|
||||||
|
assertEquals(-5, a.times(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void timeScalar() {
|
||||||
|
var a = new Vec3(1, 2, 3);
|
||||||
|
var b = 2.5;
|
||||||
|
assertEquals(new Vec3(2.5, 5, 7.5), a.times(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void div() {
|
||||||
|
var a = new Vec3(1, 2, 3);
|
||||||
|
var b = 2;
|
||||||
|
assertEquals(new Vec3(0.5, 1, 1.5), a.div(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void squared() {
|
||||||
|
var a = new Vec3(1, 2, 3);
|
||||||
|
assertEquals(14, a.squared());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void length() {
|
||||||
|
var a = new Vec3(3, 4, 0);
|
||||||
|
assertEquals(5, a.length());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unit() {
|
||||||
|
var a = new Vec3(3, 4, 0);
|
||||||
|
assertEquals(new Vec3(0.6, 0.8, 0), a.unit());
|
||||||
|
}
|
||||||
|
}
|
48
src/test/java/eu/jonahbauer/raytracing/render/ImageTest.java
Normal file
48
src/test/java/eu/jonahbauer/raytracing/render/ImageTest.java
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
package eu.jonahbauer.raytracing.render;
|
||||||
|
|
||||||
|
import eu.jonahbauer.raytracing.render.canvas.Image;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.io.TempDir;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Objects;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
|
||||||
|
class ImageTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void test(@TempDir Path dir) throws IOException {
|
||||||
|
var image = new Image(256, 256);
|
||||||
|
|
||||||
|
for (var y = 0; y < image.height(); y++) {
|
||||||
|
for (var x = 0; x < image.width(); x++) {
|
||||||
|
var r = (double) x / (image.width() - 1);
|
||||||
|
var g = (double) y / (image.height() - 1);
|
||||||
|
var b = 0;
|
||||||
|
|
||||||
|
image.set(x, y, new Color(r, g, b));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
System.out.println(dir);
|
||||||
|
ImageFormat.PPM.write(image, dir.resolve("img.ppm"));
|
||||||
|
|
||||||
|
String expected;
|
||||||
|
String actual;
|
||||||
|
|
||||||
|
try (var in = Objects.requireNonNull(ImageTest.class.getResourceAsStream("simple_image.ppm"))) {
|
||||||
|
expected = new String(in.readAllBytes(), StandardCharsets.US_ASCII);
|
||||||
|
}
|
||||||
|
|
||||||
|
try (var in = Files.newInputStream(dir.resolve("img.ppm"))) {
|
||||||
|
actual = new String(in.readAllBytes(), StandardCharsets.US_ASCII);
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(expected, actual);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
26
src/test/java/eu/jonahbauer/raytracing/scene/SphereTest.java
Normal file
26
src/test/java/eu/jonahbauer/raytracing/scene/SphereTest.java
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
package eu.jonahbauer.raytracing.scene;
|
||||||
|
|
||||||
|
import eu.jonahbauer.raytracing.math.Range;
|
||||||
|
import eu.jonahbauer.raytracing.math.Ray;
|
||||||
|
import eu.jonahbauer.raytracing.math.Vec3;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.*;
|
||||||
|
|
||||||
|
class SphereTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hit() {
|
||||||
|
var center = new Vec3(1, 2, 3);
|
||||||
|
var radius = 5;
|
||||||
|
var sphere = new Sphere(center, radius);
|
||||||
|
|
||||||
|
var origin = new Vec3(6, 7, 8);
|
||||||
|
var direction = new Vec3(-1, -1, -1);
|
||||||
|
var ray = new Ray(origin, direction);
|
||||||
|
|
||||||
|
var result = sphere.hit(ray, Range.NON_NEGATIVE);
|
||||||
|
assertFalse(result.isEmpty());
|
||||||
|
assertEquals(center.plus(new Vec3(1, 1, 1).unit().times(radius)), ray.at(result.get().t()));
|
||||||
|
}
|
||||||
|
}
|
65539
src/test/resources/eu/jonahbauer/raytracing/render/simple_image.ppm
Normal file
65539
src/test/resources/eu/jonahbauer/raytracing/render/simple_image.ppm
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
x
Reference in New Issue
Block a user