From 171df53a594d6977f0599f789e044cdab055655a Mon Sep 17 00:00:00 2001 From: Robert Sheehy Date: Thu, 16 Oct 2025 09:43:25 -0500 Subject: [PATCH] Setup basic example (wip) --- README.md | 5 ++ build.zig | 14 ++++ build.zig.zon | 12 ++- src/geometry/box.zig | 17 +++++ src/geometry/intersection.zig | 18 +++++ src/geometry/octahedron.zig | 12 +++ src/geometry/rotate.zig | 21 ++++++ src/geometry/scale.zig | 19 +++++ src/geometry/sphere.zig | 11 +++ src/geometry/subtraction.zig | 18 +++++ src/geometry/torus.zig | 13 ++++ src/geometry/union.zig | 18 +++++ src/geometry/union_smooth.zig | 29 ++++++++ src/main.zig | 93 ++++++++++++++++++++++- src/scene.zig | 134 ++++++++++++++++++++++++++++++++++ 15 files changed, 432 insertions(+), 2 deletions(-) create mode 100644 src/geometry/box.zig create mode 100644 src/geometry/intersection.zig create mode 100644 src/geometry/octahedron.zig create mode 100644 src/geometry/rotate.zig create mode 100644 src/geometry/scale.zig create mode 100644 src/geometry/sphere.zig create mode 100644 src/geometry/subtraction.zig create mode 100644 src/geometry/torus.zig create mode 100644 src/geometry/union.zig create mode 100644 src/geometry/union_smooth.zig create mode 100644 src/scene.zig diff --git a/README.md b/README.md index 0484799..1751bce 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,7 @@ # donut + 🍩 Bake a rotating 3D donut in pure text + +# References + +[Signed Distance Functions](https://iquilezles.org/articles/distfunctions/) diff --git a/build.zig b/build.zig index d28b839..7bacaf9 100644 --- a/build.zig +++ b/build.zig @@ -21,6 +21,20 @@ pub fn build(b: *std.Build) void { }), }); + const zlm = b.dependency("zlm", .{ + .target = target, + .optimize = optimize, + }); + + exe.root_module.addImport("zlm", zlm.module("zlm")); + + const vaxis = b.dependency("vaxis", .{ + .target = target, + .optimize = optimize, + }); + + exe.root_module.addImport("vaxis", vaxis.module("vaxis")); + b.installArtifact(exe); const run_step = b.step("run", "Run the app"); diff --git a/build.zig.zon b/build.zig.zon index 5ce0e4e..4604bdd 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -3,7 +3,17 @@ .version = "0.0.0", .fingerprint = 0x8ebc1f793621bd75, // Changing this has security and trust implications. .minimum_zig_version = "0.15.1", - .dependencies = .{}, + .dependencies = .{ + .zlm = .{ + // TODO: change this to upstream once merged https://github.com/ziglibs/zlm/pull/33 + .url = "git+https://github.com/theMagicalKarp/zlm?ref=fix-format#2587468c37d50732f086dec2ec690e72aa26a19b", + .hash = "zlm-0.1.0-LoZ6yOquAADMCJBFg5QNcgBIcj1cXrLAzkAp5x2sDNS1", + }, + .vaxis = .{ + .url = "git+https://github.com/rockorager/libvaxis.git#7dbb9fd3122e4ffad262dd7c151d80d863b68558", + .hash = "vaxis-0.5.1-BWNV_LosCQAGmCCNOLljCIw6j6-yt53tji6n6rwJ2BhS", + }, + }, .paths = .{ "build.zig", "build.zig.zon", diff --git a/src/geometry/box.zig b/src/geometry/box.zig new file mode 100644 index 0000000..7aa56d2 --- /dev/null +++ b/src/geometry/box.zig @@ -0,0 +1,17 @@ +const math = @import("zlm").as(f64); + +pub const Octahedron = struct { + dimensions: math.vec3, + + const Self = @This(); + + pub fn distance(self: Self, point: math.Vec3) f64 { + const q = point.abs().sub(self.dimensions); + + return math.vec3( + @max(q.x, 0.0), + @max(q.y, 0.0), + @max(q.z, 0.0), + ).length() + @min(@max(q.x, @max(q.y, q.z)), 0.0); + } +}; diff --git a/src/geometry/intersection.zig b/src/geometry/intersection.zig new file mode 100644 index 0000000..daa5578 --- /dev/null +++ b/src/geometry/intersection.zig @@ -0,0 +1,18 @@ +const math = @import("zlm").as(f64); + +pub fn Intersection(comptime T: type, comptime U: type) type { + return struct { + a: T, + b: U, + + const Self = @This(); + + pub fn new(a: T, b: U) Self { + return Self{ .a = a, .b = b }; + } + + pub fn distance(self: Self, point: math.Vec3) f64 { + return @max(self.a.distance(point), self.b.distance(point)); + } + }; +} diff --git a/src/geometry/octahedron.zig b/src/geometry/octahedron.zig new file mode 100644 index 0000000..a1aea74 --- /dev/null +++ b/src/geometry/octahedron.zig @@ -0,0 +1,12 @@ +const math = @import("zlm").as(f64); + +pub const Octahedron = struct { + size: f64, + + const Self = @This(); + + pub fn distance(self: Self, point: math.Vec3) f64 { + const abs_point = point.abs(); + return (abs_point.x + abs_point.y + abs_point.z - self.size) * 0.57735027; + } +}; diff --git a/src/geometry/rotate.zig b/src/geometry/rotate.zig new file mode 100644 index 0000000..344877c --- /dev/null +++ b/src/geometry/rotate.zig @@ -0,0 +1,21 @@ +const math = @import("zlm").as(f64); + +pub fn Rotate(comptime T: type) type { + return struct { + transformation: math.Mat4, + geometry: T, + + const Self = @This(); + + pub fn new(geometry: T, axis: math.Vec3, angle: f64) Self { + return Self{ + .geometry = geometry, + .transformation = math.Mat4.createAngleAxis(axis, angle), + }; + } + + pub fn distance(self: Self, point: math.Vec3) f64 { + return self.geometry.distance(point.transformPosition(self.transformation)); + } + }; +} diff --git a/src/geometry/scale.zig b/src/geometry/scale.zig new file mode 100644 index 0000000..fee5322 --- /dev/null +++ b/src/geometry/scale.zig @@ -0,0 +1,19 @@ +const math = @import("zlm").as(f64); + +pub fn Scale(comptime T: type) type { + return struct { + geometry: T, + amount: f64, + amount_inv: f64, + + const Self = @This(); + + pub fn new(geometry: T, amount: f64) Self { + return Self{ .geometry = geometry, .amount = amount, .amount_inv = 1.0 / amount }; + } + + pub fn distance(self: Self, point: math.Vec3) f64 { + return self.geometry.distance(point.scale(self.amount_inv)) * self.amount; + } + }; +} diff --git a/src/geometry/sphere.zig b/src/geometry/sphere.zig new file mode 100644 index 0000000..b676a39 --- /dev/null +++ b/src/geometry/sphere.zig @@ -0,0 +1,11 @@ +const math = @import("zlm").as(f64); + +pub const Sphere = struct { + radius: f64, + + const Self = @This(); + + pub fn distance(self: Self, point: math.Vec3) f64 { + return point.length() - self.radius; + } +}; diff --git a/src/geometry/subtraction.zig b/src/geometry/subtraction.zig new file mode 100644 index 0000000..fdb16c2 --- /dev/null +++ b/src/geometry/subtraction.zig @@ -0,0 +1,18 @@ +const math = @import("zlm").as(f64); + +pub fn Subtraction(comptime T: type, comptime U: type) type { + return struct { + a: T, + b: U, + + const Self = @This(); + + pub fn new(a: T, b: U) Self { + return Self{ .a = a, .b = b }; + } + + pub fn distance(self: Self, point: math.Vec3) f64 { + return @max(-self.a.distance(point), self.b.distance(point)); + } + }; +} diff --git a/src/geometry/torus.zig b/src/geometry/torus.zig new file mode 100644 index 0000000..2aafbd1 --- /dev/null +++ b/src/geometry/torus.zig @@ -0,0 +1,13 @@ +const math = @import("zlm").as(f64); + +pub const Torus = struct { + inner: f64, + outer: f64, + + const Self = @This(); + + pub fn distance(self: Self, point: math.Vec3) f64 { + var q = math.vec2(point.swizzle("xz").length() - self.outer, point.y); + return q.length() - self.inner; + } +}; diff --git a/src/geometry/union.zig b/src/geometry/union.zig new file mode 100644 index 0000000..cde0168 --- /dev/null +++ b/src/geometry/union.zig @@ -0,0 +1,18 @@ +const math = @import("zlm").as(f64); + +pub fn Union(comptime T: type, comptime U: type) type { + return struct { + a: T, + b: U, + + const Self = @This(); + + pub fn new(a: T, b: U) Self { + return Self{ .a = a, .b = b }; + } + + pub fn distance(self: Self, point: math.Vec3) f64 { + return @min(self.a.distance(point), self.b.distance(point)); + } + }; +} diff --git a/src/geometry/union_smooth.zig b/src/geometry/union_smooth.zig new file mode 100644 index 0000000..9b93f95 --- /dev/null +++ b/src/geometry/union_smooth.zig @@ -0,0 +1,29 @@ +const std = @import("std"); +const math = @import("zlm").as(f64); + +fn mix(x: f64, y: f64, a: f64) f64 { + return @mulAdd(f64, a, y - x, x); +} + +pub fn UnionSmooth(comptime T: type, comptime U: type) type { + return struct { + smooth: f64, + a: T, + b: U, + + const Self = @This(); + + pub fn new(a: T, b: U, smooth: f64) Self { + return Self{ .a = a, .b = b, .smooth = smooth }; + } + + pub fn distance(self: Self, point: math.Vec3) f64 { + const d1 = self.a.distance(point); + const d2 = self.b.distance(point); + + const h = std.math.clamp(0.5 + 0.5 * (d2 - d1) / self.smooth, 0.0, 1.0); + + return mix(d2, d1, h) - self.smooth * h * (1.0 - h); + } + }; +} diff --git a/src/main.zig b/src/main.zig index a885b32..f92d6d1 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,5 +1,96 @@ const std = @import("std"); +const vaxis = @import("vaxis"); +const math = @import("zlm").as(f64); + +const Scene = @import("./scene.zig").Scene; +const Rotate = @import("./geometry/rotate.zig").Rotate; +const Torus = @import("./geometry/torus.zig").Torus; +const UnionSmooth = @import("./geometry/union_smooth.zig").UnionSmooth; +const Sphere = @import("./geometry/sphere.zig").Sphere; +const Scale = @import("./geometry/scale.zig").Scale; + +const Event = union(enum) { + key_press: vaxis.Key, + winsize: vaxis.Winsize, + focus_in, +}; pub fn main() !void { - std.debug.print("Hello world\n", .{}); + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer std.debug.assert(gpa.deinit() == .ok); + const allocator = gpa.allocator(); + + var buffer: [1024]u8 = undefined; + var tty = try vaxis.Tty.init(&buffer); + defer tty.deinit(); + + var screen_buffer = std.io.Writer.Allocating.init(allocator); + defer screen_buffer.deinit(); + + var vx = try vaxis.init(allocator, .{}); + defer vx.deinit(allocator, tty.writer()); + + var loop: vaxis.Loop(Event) = .{ .tty = &tty, .vaxis = &vx }; + try loop.init(); + try loop.start(); + defer loop.stop(); + + try vx.enterAltScreen(tty.writer()); + try vx.queryTerminal(tty.writer(), 1 * std.time.ns_per_s); + + const gamma: f32 = 2.4; + const light_position = math.vec3(1.0, -1.0, -1.0).normalize(); + + const t1 = Torus{ .inner = 0.2, .outer = 1.0 }; + const rt1 = Rotate(Torus).new(t1, math.vec3(0.0, 0.0, 1.0), 90.0); + const t2 = Torus{ .inner = 0.2, .outer = 1.0 }; + const t3 = UnionSmooth(Rotate(Torus), Torus).new(rt1, t2, 0.3); + const st3 = Scale(UnionSmooth(Rotate(Torus), Torus)).new(t3, 1.2); + + const ThisScene = Scene(Scale(UnionSmooth(Rotate(Torus), Torus))); + var scene = ThisScene.new(200.0, 200.0, light_position, gamma); + + while (true) { + while (loop.tryEvent()) |event| { + switch (event) { + .key_press => |key| { + if (key.matches('c', .{ .ctrl = true })) { + return; + } else if (key.matches('q', .{})) { + return; + } + }, + + .winsize => |ws| { + try vx.resize(allocator, tty.writer(), ws); + + scene = ThisScene.new(ws.cols - 2, ws.rows - 2, light_position, gamma); + }, + else => {}, + } + } + + const win = vx.window(); + win.clear(); + + const child = win.child(.{ + .x_off = 0, + .y_off = 0, + .width = win.width, + .height = win.height, + .border = .{ + .where = .all, + .style = .{ + .fg = .{ .index = 5 }, + }, + }, + }); + + screen_buffer.clearRetainingCapacity(); + try scene.render(0.0, st3, &screen_buffer.writer); + _ = child.printSegment(.{ .text = screen_buffer.written() }, .{ .wrap = .grapheme }); + + try vx.render(tty.writer()); + std.Thread.sleep(30 * std.time.ns_per_ms); + } } diff --git a/src/scene.zig b/src/scene.zig new file mode 100644 index 0000000..e9a9a42 --- /dev/null +++ b/src/scene.zig @@ -0,0 +1,134 @@ +const std = @import("std"); +const math = @import("zlm").as(f64); + +const LUMINANCE = ".,-~:;=!*#$@"; + +fn bayer4(x: usize, y: usize) u8 { + const M = [_][4]u8{ + .{ 0, 8, 2, 10 }, + .{ 12, 4, 14, 6 }, + .{ 3, 11, 1, 9 }, + .{ 15, 7, 13, 5 }, + }; + return M[y & 3][x & 3]; +} + +fn brightnessToCharDither(x: usize, y: usize, b_in: f64, gamma: f64) u8 { + const lut = LUMINANCE; + const levels_f: f32 = @floatFromInt(lut.len); + + // 1) Clamp + gamma (linear -> perceptual) + const b = std.math.clamp(b_in, 0.0, 1.0); + const g = if (gamma <= 0.0) 1.0 else gamma; + const b_perc = std.math.pow(f64, b, 1.0 / g); + + // 2) Ordered dithering bias from Bayer (normalize to [0,1)) + // Add a *tiny* offset proportional to matrix cell; scale by level count. + const b4f: f64 = @floatFromInt(bayer4(x, y)); + const t = (b4f + 0.5) / 16.0; // [0,1) + const bias = (t - 0.5) / levels_f; // small symmetric nudge + + // 3) Quantize to nearest glyph index (rounded) + const v = std.math.clamp(b_perc + bias, 0.0, 1.0); + var idx: u8 = @intFromFloat(v * (levels_f - 1.0) + 0.5); + if (idx >= lut.len) idx = lut.len - 1; + + return lut[idx]; +} + +const HitRecord = struct { + normal: math.Vec3, + hit: bool, +}; + +pub fn get_ray_direction(uv: math.Vec2, point: math.Vec3, lookat: math.Vec3, z: f64) math.Vec3 { + const f = lookat.sub(point).normalize(); + const r = math.vec3(0.0, 1.0, 0.0).cross(f).normalize(); + const u = f.cross(r); + const c = point.add(f.scale(z)); + const i = c.add(r.scale(uv.x)).add(u.scale(uv.y)); + return i.sub(point).normalize(); +} + +pub fn Scene(comptime T: type) type { + return struct { + const Self = @This(); + + light: math.Vec3, + gamma: f64, + + width: usize, + height: usize, + + widthf: f64, + heightf: f64, + + pub fn new(width: usize, height: usize, light: math.Vec3, gamma: f64) Self { + return Self{ + .width = width, + .height = height, + .light = light, + .widthf = @floatFromInt(width), + .heightf = @floatFromInt(height), + .gamma = gamma, + }; + } + + pub fn render(self: Self, time: f64, geometry: T, writer: *std.Io.Writer) !void { + for (0..self.height) |y| { + for (0..self.width) |x| { + const xf: f64 = @floatFromInt(x); + const yf: f64 = @floatFromInt(y); + const hit_record = self.march(time, geometry, xf, yf); + if (hit_record.hit) { + const brightness = @max(0.0, hit_record.normal.normalize().dot(self.light)); + try writer.writeByte(brightnessToCharDither(x, y, brightness, self.gamma)); + } else { + try writer.writeByte(' '); + } + } + } + } + + pub fn march(self: Self, time: f64, geometry: T, x: f64, y: f64) HitRecord { + const ray_origin = math.vec3(0.0, 0.0, -2.0); + + const frag_coord = math.vec4(x + 0.5, y + 0.5, 0.0, 1.0); + const uv = frag_coord.scale(2.0).swizzle("xy").sub(math.vec2(self.widthf, self.heightf)).div(math.vec2(self.heightf, self.heightf)); + + const ray_direction = get_ray_direction(uv, ray_origin, math.vec3(0.0, 0.0, 0.0), 1.0); + var total_distance: f64 = 0.0; + for (0..80) |i| { + _ = i; + const point = ray_origin.add(ray_direction.scale(total_distance)); + + const distance: f64 = @abs(map(time, point, geometry)); + total_distance = total_distance + distance; + if (distance < 0.01) { + return HitRecord{ .hit = true, .normal = get_normal(time, point, geometry) }; + } + + if (total_distance > 100.0) { + return HitRecord{ .hit = false, .normal = math.Vec3.zero }; + } + } + return HitRecord{ .hit = false, .normal = math.Vec3.zero }; + } + + pub fn get_normal(time: f64, point: math.Vec3, geometry: T) math.Vec3 { + const distance = map(time, point, geometry); + const e = math.vec2(0.01, 0.0); + + return math.vec3( + distance - map(time, point.sub(e.swizzle("xyy")), geometry), + distance - map(time, point.sub(e.swizzle("yxy")), geometry), + distance - map(time, point.sub(e.swizzle("yyx")), geometry), + ).normalize(); + } + + pub fn map(time: f64, point: math.Vec3, geometry: T) f64 { + _ = time; + return geometry.distance(point); + } + }; +}