separate camera from rendering
This commit is contained in:
parent
c17b9aedf5
commit
0c6db707e0
@ -5,11 +5,12 @@ 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.Camera;
|
||||
import eu.jonahbauer.raytracing.render.Color;
|
||||
import eu.jonahbauer.raytracing.render.ImageFormat;
|
||||
import eu.jonahbauer.raytracing.render.camera.SimpleCamera;
|
||||
import eu.jonahbauer.raytracing.render.canvas.LiveCanvas;
|
||||
import eu.jonahbauer.raytracing.render.canvas.Image;
|
||||
import eu.jonahbauer.raytracing.render.renderer.SimpleRenderer;
|
||||
import eu.jonahbauer.raytracing.scene.Hittable;
|
||||
import eu.jonahbauer.raytracing.scene.Scene;
|
||||
import eu.jonahbauer.raytracing.scene.Sphere;
|
||||
@ -23,13 +24,16 @@ public class Main {
|
||||
public static void main(String[] args) throws IOException {
|
||||
var scene = getScene();
|
||||
|
||||
var camera = Camera.builder()
|
||||
var camera = SimpleCamera.builder()
|
||||
.withImage(1200, 675)
|
||||
.withPosition(new Vec3(13, 2, 3))
|
||||
.withTarget(new Vec3(0, 0, 0))
|
||||
.withFieldOfView(Math.toRadians(20))
|
||||
.withFocusDistance(10.0)
|
||||
.withBlurAngle(Math.toRadians(0.6))
|
||||
.build();
|
||||
|
||||
var renderer = SimpleRenderer.builder()
|
||||
.withSamplesPerPixel(500)
|
||||
.withMaxDepth(50)
|
||||
.build();
|
||||
@ -37,7 +41,7 @@ public class Main {
|
||||
var image = new LiveCanvas(new Image(camera.getWidth(), camera.getHeight()));
|
||||
image.preview();
|
||||
|
||||
camera.render(scene, image);
|
||||
renderer.render(camera, scene, image);
|
||||
ImageFormat.PNG.write(image, Path.of("scene-" + System.currentTimeMillis() + ".png"));
|
||||
}
|
||||
|
||||
|
@ -37,6 +37,32 @@ public record Color(double r, double g, double b) {
|
||||
);
|
||||
}
|
||||
|
||||
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 static @NotNull Color gamma(@NotNull Color color, double gamma) {
|
||||
if (gamma == 1.0) {
|
||||
return color;
|
||||
} else if (gamma == 2.0) {
|
||||
return new Color(
|
||||
Math.sqrt(color.r()),
|
||||
Math.sqrt(color.g()),
|
||||
Math.sqrt(color.b())
|
||||
);
|
||||
} else {
|
||||
return new Color(
|
||||
Math.pow(color.r(), 1 / gamma),
|
||||
Math.pow(color.g(), 1 / gamma),
|
||||
Math.pow(color.b(), 1 / gamma)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
|
@ -0,0 +1,22 @@
|
||||
package eu.jonahbauer.raytracing.render.camera;
|
||||
|
||||
import eu.jonahbauer.raytracing.math.Ray;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
public interface Camera {
|
||||
/**
|
||||
* {@return the width of this camera in pixels}
|
||||
*/
|
||||
int getWidth();
|
||||
|
||||
/**
|
||||
* {@return the height of this camera in pixels}
|
||||
*/
|
||||
int getHeight();
|
||||
|
||||
/**
|
||||
* Casts a ray through the given pixel.
|
||||
* @return a new ray
|
||||
*/
|
||||
@NotNull Ray cast(int x, int y);
|
||||
}
|
@ -1,19 +1,13 @@
|
||||
package eu.jonahbauer.raytracing.render;
|
||||
package eu.jonahbauer.raytracing.render.camera;
|
||||
|
||||
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.stream.IntStream;
|
||||
|
||||
public final class Camera {
|
||||
public final class SimpleCamera implements Camera {
|
||||
// image size
|
||||
private final int width;
|
||||
private final int height;
|
||||
@ -22,9 +16,6 @@ public final class Camera {
|
||||
private final @NotNull Vec3 origin;
|
||||
|
||||
// rendering
|
||||
private final int samplesPerPixel;
|
||||
private final int maxDepth;
|
||||
private final double gamma;
|
||||
private final double blurRadius;
|
||||
|
||||
// internal properties
|
||||
@ -39,11 +30,11 @@ public final class Camera {
|
||||
return new Builder();
|
||||
}
|
||||
|
||||
public Camera() {
|
||||
this(new Builder());
|
||||
public static @NotNull Camera withDefaults() {
|
||||
return new Builder().build();
|
||||
}
|
||||
|
||||
private Camera(@NotNull Builder builder) {
|
||||
private SimpleCamera(@NotNull Builder builder) {
|
||||
this.width = builder.imageWidth;
|
||||
this.height = builder.imageHeight;
|
||||
|
||||
@ -53,9 +44,6 @@ public final class Camera {
|
||||
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
|
||||
@ -74,100 +62,65 @@ public final class Camera {
|
||||
.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) {
|
||||
if (canvas.getWidth() != width || canvas.getHeight() != height) throw new IllegalArgumentException();
|
||||
|
||||
var lines = new AtomicInteger();
|
||||
IntStream.range(0, height).parallel().forEach(y -> {
|
||||
System.out.println(lines.incrementAndGet());
|
||||
for (int x = 0; x < width; x++) {
|
||||
var r = 0d;
|
||||
var g = 0d;
|
||||
var b = 0d;
|
||||
|
||||
for (int i = 0; i < samplesPerPixel; i++) {
|
||||
var ray = getRay(x, y);
|
||||
var color = getColor(scene, ray);
|
||||
r += color.r();
|
||||
g += color.g();
|
||||
b += color.b();
|
||||
}
|
||||
|
||||
canvas.set(x, y, new Color(
|
||||
Math.pow(r / samplesPerPixel, 1 / gamma),
|
||||
Math.pow(g / samplesPerPixel, 1 / gamma),
|
||||
Math.pow(b / samplesPerPixel, 1 / gamma)
|
||||
));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public int getWidth() {
|
||||
return width;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public int getHeight() {
|
||||
return height;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@inheritDoc}
|
||||
*/
|
||||
public @NotNull Ray cast(int x, int y) {
|
||||
Objects.checkIndex(x, width);
|
||||
Objects.checkIndex(y, height);
|
||||
|
||||
var origin = getRayOrigin();
|
||||
var target = getRayTarget(x, y);
|
||||
return new Ray(origin, target.minus(origin));
|
||||
}
|
||||
|
||||
/**
|
||||
* {@return the origin for a ray cast by this camera} The ray origin is randomized within a disk of
|
||||
* radius {@link #blurRadius} centered on the camera position and perpendicular to the direction to simulate depth
|
||||
* of field.
|
||||
*/
|
||||
private @NotNull Vec3 getRayOrigin() {
|
||||
if (blurRadius <= 0) return origin;
|
||||
|
||||
while (true) {
|
||||
var du = 2 * Math.random() - 1;
|
||||
var dv = 2 * Math.random() - 1;
|
||||
if (du * du + dv * dv >= 1) continue;
|
||||
|
||||
var ru = blurRadius * du;
|
||||
var rv = blurRadius * dv;
|
||||
|
||||
return new Vec3(
|
||||
origin.x() + ru * u.x() + rv * v.x(),
|
||||
origin.y() + ru * u.y() + rv * v.y(),
|
||||
origin.z() + ru * u.z() + rv * v.z()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@return the target vector for a ray through the given pixel} The position is randomized within the pixel.
|
||||
*/
|
||||
private @NotNull Vec3 getRayTarget(int x, int y) {
|
||||
double dx = x + Math.random() - 0.5;
|
||||
double dy = y + Math.random() - 0.5;
|
||||
return pixel00.plus(pixelU.times(dx)).plus(pixelV.times(dy));
|
||||
}
|
||||
|
||||
public static class Builder {
|
||||
private int imageWidth = 1920;
|
||||
private int imageHeight = 1080;
|
||||
@ -181,9 +134,6 @@ public final class Camera {
|
||||
private double focusDistance = 10;
|
||||
private double blurAngle = 0.0;
|
||||
|
||||
private int samplePerPixel = 100;
|
||||
private int maxDepth = 10;
|
||||
private double gamma = 2.0;
|
||||
|
||||
private Builder() {}
|
||||
|
||||
@ -235,26 +185,8 @@ public final class Camera {
|
||||
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);
|
||||
public @NotNull SimpleCamera build() {
|
||||
return new SimpleCamera(this);
|
||||
}
|
||||
|
||||
}
|
@ -0,0 +1,20 @@
|
||||
package eu.jonahbauer.raytracing.render.renderer;
|
||||
|
||||
import eu.jonahbauer.raytracing.render.camera.Camera;
|
||||
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;
|
||||
|
||||
public interface Renderer {
|
||||
default @NotNull Image render(@NotNull Camera camera, @NotNull Scene scene) {
|
||||
var image = new Image(camera.getWidth(), camera.getHeight());
|
||||
render(camera, scene, image);
|
||||
return image;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the {@code scene} as seen by the {@code camera} to the {@code canvas}.
|
||||
*/
|
||||
void render(@NotNull Camera camera, @NotNull Scene scene, @NotNull Canvas canvas);
|
||||
}
|
@ -0,0 +1,126 @@
|
||||
package eu.jonahbauer.raytracing.render.renderer;
|
||||
|
||||
import eu.jonahbauer.raytracing.math.Range;
|
||||
import eu.jonahbauer.raytracing.math.Ray;
|
||||
import eu.jonahbauer.raytracing.render.Color;
|
||||
import eu.jonahbauer.raytracing.render.camera.Camera;
|
||||
import eu.jonahbauer.raytracing.render.canvas.Canvas;
|
||||
import eu.jonahbauer.raytracing.scene.Scene;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.IntStream;
|
||||
import java.util.stream.LongStream;
|
||||
|
||||
public final class SimpleRenderer implements Renderer {
|
||||
private final int samplesPerPixel;
|
||||
private final int maxDepth;
|
||||
private final double gamma;
|
||||
|
||||
public static @NotNull Builder builder() {
|
||||
return new Builder();
|
||||
}
|
||||
|
||||
public static @NotNull Renderer withDefaults() {
|
||||
return new Builder().build();
|
||||
}
|
||||
|
||||
private SimpleRenderer(@NotNull Builder builder) {
|
||||
this.samplesPerPixel = builder.samplesPerPixel;
|
||||
this.maxDepth = builder.maxDepth;
|
||||
this.gamma = builder.gamma;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void render(@NotNull Camera camera, @NotNull Scene scene, @NotNull Canvas canvas) {
|
||||
if (canvas.getWidth() != camera.getWidth() || canvas.getHeight() != camera.getHeight()) {
|
||||
throw new IllegalArgumentException("sizes of camera and canvas are different");
|
||||
}
|
||||
|
||||
getPixelStream(camera.getWidth(), camera.getHeight()).parallel().forEach(pixel -> {
|
||||
var y = (int) (pixel >> 32);
|
||||
var x = (int) pixel;
|
||||
|
||||
var color = Color.BLACK;
|
||||
for (int i = 1; i <= samplesPerPixel; i++) {
|
||||
var ray = camera.cast(x, y);
|
||||
var c = getColor(scene, ray);
|
||||
color = Color.average(color, c, i);
|
||||
}
|
||||
canvas.set(x, y, Color.gamma(color, gamma));
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* {@return the color of the given ray in the given scene}
|
||||
*/
|
||||
private @NotNull Color getColor(@NotNull Scene scene, @NotNull Ray ray) {
|
||||
return getColor0(scene, ray, maxDepth);
|
||||
}
|
||||
|
||||
private @NotNull Color getColor0(@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(), getColor0(scene, scatter.ray(), depth - 1)))
|
||||
.orElse(Color.BLACK);
|
||||
} else {
|
||||
return getSkyboxColor(ray);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* {@return a stream of the pixels in a canvas with the given size} The pixels {@code x} and {@code y} coordinate
|
||||
* are encoded in the longs lower and upper 32 bits respectively.
|
||||
*/
|
||||
private static @NotNull LongStream getPixelStream(int width, int height) {
|
||||
return IntStream.range(0, height)
|
||||
.mapToObj(y -> IntStream.range(0, width).mapToLong(x -> (long) y << 32 | x))
|
||||
.flatMapToLong(Function.identity());
|
||||
}
|
||||
|
||||
/**
|
||||
* {@return the color of the skybox for a given ray} The skybox color is a linear gradient based on the altitude of
|
||||
* the ray above the horizon with {@link Color#SKY} at the top and {@link Color#WHITE} at the bottom.
|
||||
*/
|
||||
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 static class Builder {
|
||||
private int samplesPerPixel = 100;
|
||||
private int maxDepth = 10;
|
||||
private double gamma = 2.0;
|
||||
|
||||
public @NotNull Builder withSamplesPerPixel(int samples) {
|
||||
if (samples <= 0) throw new IllegalArgumentException("samples must be positive");
|
||||
this.samplesPerPixel = 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 SimpleRenderer build() {
|
||||
return new SimpleRenderer(this);
|
||||
}
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user