separate camera from rendering

main
jbb01 6 months ago
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,98 +62,63 @@ 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));
/**
* {@inheritDoc}
*/
public int getWidth() {
return width;
}
return new Ray(origin, getPixel(x, y).minus(origin));
/**
* {@inheritDoc}
*/
public int getHeight() {
return height;
}
private @NotNull Vec3 getPixel(int x, int y) {
/**
* {@inheritDoc}
*/
public @NotNull Ray cast(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));
var origin = getRayOrigin();
var target = getRayTarget(x, y);
return new Ray(origin, target.minus(origin));
}
private @NotNull Color getColor(@NotNull Scene scene, @NotNull Ray ray) {
return getColor(scene, ray, maxDepth);
}
/**
* {@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;
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);
}
}
while (true) {
var du = 2 * Math.random() - 1;
var dv = 2 * Math.random() - 1;
if (du * du + dv * dv >= 1) continue;
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()
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 Color.lerp(Color.WHITE, Color.SKY, alt / Math.PI + 0.5);
}
public int getWidth() {
return width;
}
public int getHeight() {
return height;
/**
* {@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 {
@ -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…
Cancel
Save