more lua support, fixed scene 3

main
Codinget 4 years ago
parent 694fdf8e2b
commit fcde73c1ef
  1. 2
      README.md
  2. BIN
      prod/3.png
  3. 33
      scenes/randomspheres.lua
  4. 45
      scenes/smolgalaxy.lua
  5. 4
      src/lua/color.rs
  6. 2
      src/lua/light.rs
  7. 2
      src/lua/mat3.rs
  8. 4
      src/lua/material.rs
  9. 31
      src/lua/mod.rs
  10. 8
      src/lua/obj.rs
  11. 40
      src/lua/transform.rs
  12. 71
      src/lua/util.lua
  13. 5
      src/lua/util.rs
  14. 2
      src/lua/vec3.rs
  15. 6
      src/main.rs
  16. 2
      src/object/mod.rs
  17. 11
      src/object/transform.rs
  18. 36
      src/object/with_lights.rs
  19. 2
      test

@ -19,7 +19,6 @@ A ray marching renderer in rust
## What is planned ## What is planned
- Testing of more shapes - Testing of more shapes
- Support for a lua-based scene representation DSL
- Support for controlling the whole application from lua - Support for controlling the whole application from lua
- Support for linking against a `scene.so` exporting a scene - Support for linking against a `scene.so` exporting a scene
- Support for using as a library - Support for using as a library
@ -27,6 +26,7 @@ A ray marching renderer in rust
## Examples ## Examples
![1st test scene](prod/1.png) ![1st test scene](prod/1.png)
![2nd test scene](prod/2.png) ![2nd test scene](prod/2.png)
![3rd test scene](prod/3.png)
![randomly generated spheres](prod/randomspheres.png) ![randomly generated spheres](prod/randomspheres.png)
![randomly generated objects](prod/smolgalaxy.png) ![randomly generated objects](prod/smolgalaxy.png)

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

@ -1,28 +1,20 @@
local unpack = table.unpack or _G.unpack local unpack = table.unpack or _G.unpack
math.randomseed(os.time()) util.seed()
local function randab(a, b)
return math.random() * (b-a) + a
end
local function randtable(n, a, b) local function randtable(n, a, b)
local t = {} local t = {}
for i=1, n do for i=1, n do
t[i] = randab(a, b) t[i] = util.randab(a, b)
end end
return t return t
end end
local function choice(list)
return list[math.random(1, #list)]
end
local function randomsphere() local function randomsphere()
local pos = vec3.new(unpack(randtable(3, -2, 2))) local pos = vec3.new(unpack(randtable(3, -2, 2)))
local radius = randab(0, 0.5) local radius = util.randab(0, 0.5)
local emission = colorvec.new(unpack(randtable(4, 0, 1))) local emission = colorvec.new(unpack(randtable(4, 0, 1)))
local reflection = colormat.new(unpack(randtable(16, 0, 1))) local reflection = colormat.new(unpack(randtable(16, 0, 1)))
local surfacetype = choice({surfacetype.DIFFUSE, surfacetype.REFLECTIVE, surfacetype.STOP}) local surfacetype = util.choice({surfacetype.DIFFUSE, surfacetype.REFLECTIVE, surfacetype.STOP})
return obj.withmaterial( return obj.withmaterial(
obj.sphere(pos, radius), obj.sphere(pos, radius),
@ -30,23 +22,8 @@ local function randomsphere()
) )
end end
local function union(objs)
local n = #objs
if n == 1 then return objs[1] end
local mid = n//2
local left, right = {}, {}
for i=1, mid do
left[i] = objs[i]
end
for i=mid+1, n do
right[i-mid] = objs[i]
end
return obj.union(union(left), union(right))
end
local spheres = {} local spheres = {}
for i=1, 100 do for i=1, 100 do
spheres[i] = randomsphere() spheres[i] = randomsphere()
end end
return union(spheres) return util.union(spheres)

@ -1,30 +1,22 @@
local unpack = table.unpack or _G.unpack local unpack = table.unpack or _G.unpack
math.randomseed(os.time()) util.seed()
local SCALE = 0.3 local SCALE = 0.3
local N_EACH = 25 local N_EACH = 25
local function randab(a, b)
return math.random() * (b-a) + a
end
local function randtable(n, a, b) local function randtable(n, a, b)
local t = {} local t = {}
for i=1, n do for i=1, n do
t[i] = randab(a, b) t[i] = util.randab(a, b)
end end
return t return t
end end
local function choice(list)
return list[math.random(1, #list)]
end
local MATERIALS = {surfacetype.DIFFUSE, surfacetype.REFLECTIVE, surfacetype.STOP} local MATERIALS = {surfacetype.DIFFUSE, surfacetype.REFLECTIVE, surfacetype.STOP}
local function randommaterial() local function randommaterial()
local emission = colorvec.new(unpack(randtable(4, 0, 1))) local emission = colorvec.new(unpack(randtable(4, 0, 1)))
local reflection = colormat.new(unpack(randtable(16, 0, 1))) local reflection = colormat.new(unpack(randtable(16, 0, 1)))
local surfacetype = choice(MATERIALS) local surfacetype = util.choice(MATERIALS)
return material.new(emission, reflection, surfacetype) return material.new(emission, reflection, surfacetype)
end end
@ -62,20 +54,20 @@ local ORIENTATIONS = {
) )
} }
local function randomorientation() local function randomorientation()
return choice(ORIENTATIONS) return util.choice(ORIENTATIONS)
end end
local function randomsphere() local function randomsphere()
local pos = vec3.new(unpack(randtable(3, -2, 2))) local pos = vec3.new(unpack(randtable(3, -2, 2)))
local radius = randab(0, SCALE) local radius = util.randab(0, SCALE)
return obj.sphere(pos, radius) return obj.sphere(pos, radius)
end end
local function randomtorus() local function randomtorus()
local pos = vec3.new(unpack(randtable(3, -2, 2))) local pos = vec3.new(unpack(randtable(3, -2, 2)))
local radius = randab(0, SCALE) local radius = util.randab(0, SCALE)
local thickness = randab(radius/4, radius) local thickness = util.randab(radius/4, radius)
local orientation = randomorientation() local orientation = randomorientation()
return obj.affinetransform(obj.torus(pos, radius, thickness), orientation, vec3.O) return obj.affinetransform(obj.torus(pos, radius, thickness), orientation, vec3.O)
@ -83,15 +75,15 @@ end
local function randomcuboid() local function randomcuboid()
local pos = vec3.new(unpack(randtable(3, -2, 2))) local pos = vec3.new(unpack(randtable(3, -2, 2)))
local radius = randab(0, SCALE) local radius = util.randab(0, SCALE)
return obj.cuboid(pos, vec3.new(radius, radius, radius)) return obj.cuboid(pos, vec3.new(radius, radius, radius))
end end
local function randomcylinder() local function randomcylinder()
local pos = vec3.new(unpack(randtable(3, -2, 2))) local pos = vec3.new(unpack(randtable(3, -2, 2)))
local radius = randab(0, SCALE) local radius = util.randab(0, SCALE)
local height = randab(0, SCALE) local height = util.randab(0, SCALE)
local orientation = randomorientation() local orientation = randomorientation()
return obj.affinetransform( return obj.affinetransform(
@ -104,25 +96,10 @@ local function randomcylinder()
) )
end end
local function union(objs)
local n = #objs
if n == 1 then return objs[1] end
local mid = n//2
local left, right = {}, {}
for i=1, mid do
left[i] = objs[i]
end
for i=mid+1, n do
right[i-mid] = objs[i]
end
return obj.union(union(left), union(right))
end
local objects = {} local objects = {}
for i=1, N_EACH do table.insert(objects, randomsphere()) end for i=1, N_EACH do table.insert(objects, randomsphere()) end
for i=1, N_EACH do table.insert(objects, randomtorus()) end for i=1, N_EACH do table.insert(objects, randomtorus()) end
for i=1, N_EACH do table.insert(objects, randomcuboid()) end for i=1, N_EACH do table.insert(objects, randomcuboid()) end
for i=1, N_EACH do table.insert(objects, randomcylinder()) end for i=1, N_EACH do table.insert(objects, randomcylinder()) end
for i, object in ipairs(objects) do objects[i] = obj.withmaterial(object, randommaterial()) end for i, object in ipairs(objects) do objects[i] = obj.withmaterial(object, randommaterial()) end
return union(objects) return util.union(objects)

@ -4,7 +4,7 @@ use rlua::{UserData, Context, Table};
impl UserData for ColorVec {} impl UserData for ColorVec {}
impl UserData for ColorMat {} impl UserData for ColorMat {}
pub fn color_vec(ctx: Context, _: ()) -> rlua::Result<Table> { pub fn color_vec<'lua>(ctx: Context<'lua>, _env: Table<'lua>) -> rlua::Result<Table<'lua>> {
let module = ctx.create_table()?; let module = ctx.create_table()?;
module.set("new", ctx.create_function( module.set("new", ctx.create_function(
@ -16,7 +16,7 @@ pub fn color_vec(ctx: Context, _: ()) -> rlua::Result<Table> {
Ok(module) Ok(module)
} }
pub fn color_mat(ctx: Context, _: ()) -> rlua::Result<Table> { pub fn color_mat<'lua>(ctx: Context<'lua>, _env: Table<'lua>) -> rlua::Result<Table<'lua>> {
let module = ctx.create_table()?; let module = ctx.create_table()?;
module.set("new", ctx.create_function( module.set("new", ctx.create_function(

@ -3,7 +3,7 @@ use rlua::{UserData, Context, Table};
impl UserData for Light {} impl UserData for Light {}
pub fn light(ctx: Context, _: ()) -> rlua::Result<Table> { pub fn light<'lua>(ctx: Context<'lua>, _env: Table<'lua>) -> rlua::Result<Table<'lua>> {
let module = ctx.create_table()?; let module = ctx.create_table()?;
module.set("new", ctx.create_function( module.set("new", ctx.create_function(

@ -29,7 +29,7 @@ impl UserData for Mat3 {
} }
} }
pub fn mat3(ctx: Context, _: ()) -> rlua::Result<Table> { pub fn mat3<'lua>(ctx: Context<'lua>, _env: Table<'lua>) -> rlua::Result<Table<'lua>> {
let module = ctx.create_table()?; let module = ctx.create_table()?;
module.set("new", ctx.create_function( module.set("new", ctx.create_function(

@ -4,7 +4,7 @@ use rlua::{UserData, Context, Table};
impl UserData for SurfaceType {} impl UserData for SurfaceType {}
impl UserData for Material {} impl UserData for Material {}
pub fn surface_type(ctx: Context, _: ()) -> rlua::Result<Table> { pub fn surface_type<'lua>(ctx: Context<'lua>, _env: Table<'lua>) -> rlua::Result<Table<'lua>> {
let module = ctx.create_table()?; let module = ctx.create_table()?;
module.set("DIFFUSE", SurfaceType::Diffuse)?; module.set("DIFFUSE", SurfaceType::Diffuse)?;
@ -14,7 +14,7 @@ pub fn surface_type(ctx: Context, _: ()) -> rlua::Result<Table> {
Ok(module) Ok(module)
} }
pub fn material(ctx: Context, _: ()) -> rlua::Result<Table> { pub fn material<'lua>(ctx: Context<'lua>, _env: Table<'lua>) -> rlua::Result<Table<'lua>> {
let module = ctx.create_table()?; let module = ctx.create_table()?;
module.set("new", ctx.create_function( module.set("new", ctx.create_function(

@ -6,6 +6,8 @@ mod mat3;
mod color; mod color;
mod material; mod material;
mod light; mod light;
mod transform;
mod util;
pub use obj::{LuaObject, obj}; pub use obj::{LuaObject, obj};
pub use vec3::vec3; pub use vec3::vec3;
@ -13,25 +15,28 @@ pub use mat3::mat3;
pub use color::{color_vec, color_mat}; pub use color::{color_vec, color_mat};
pub use material::{surface_type, material}; pub use material::{surface_type, material};
pub use light::light; pub use light::light;
pub use transform::transform;
pub use util::util;
pub fn env(ctx: Context, _: ()) -> rlua::Result<Table> { pub fn add_scene_env<'lua>(ctx: Context<'lua>, env: Table<'lua>) -> rlua::Result<Table<'lua>> {
let module = ctx.create_table()?; env.set("obj", obj(ctx, env.clone())?)?;
env.set("vec3", vec3(ctx, env.clone())?)?;
env.set("mat3", mat3(ctx, env.clone())?)?;
env.set("colorvec", color_vec(ctx, env.clone())?)?;
env.set("colormat", color_mat(ctx, env.clone())?)?;
env.set("surfacetype", surface_type(ctx, env.clone())?)?;
env.set("material", material(ctx, env.clone())?)?;
env.set("light", light(ctx, env.clone())?)?;
env.set("transform", transform(ctx, env.clone())?)?;
env.set("util", util(ctx, env.clone())?)?;
module.set("obj", obj(ctx, ())?)?; Ok(env)
module.set("vec3", vec3(ctx, ())?)?;
module.set("mat3", mat3(ctx, ())?)?;
module.set("colorvec", color_vec(ctx, ())?)?;
module.set("colormat", color_mat(ctx, ())?)?;
module.set("surfacetype", surface_type(ctx, ())?)?;
module.set("material", material(ctx, ())?)?;
module.set("light", light(ctx, ())?)?;
Ok(module)
} }
pub fn scene_from_file(file: String) -> rlua::Result<LuaObject> { pub fn scene_from_file(file: String) -> rlua::Result<LuaObject> {
Lua::new().context(|ctx| { Lua::new().context(|ctx| {
let env = env(ctx, ())?; let env = ctx.create_table()?;
add_scene_env(ctx, env.clone())?;
let meta = ctx.create_table()?; let meta = ctx.create_table()?;
meta.set("__index", ctx.globals())?; meta.set("__index", ctx.globals())?;
env.set_metatable(Some(meta)); env.set_metatable(Some(meta));

@ -27,7 +27,7 @@ impl Obj for LuaObject {
impl UserData for LuaObject {} impl UserData for LuaObject {}
pub fn obj(ctx: Context, _: ()) -> rlua::Result<Table> { pub fn obj<'lua>(ctx: Context<'lua>, _env: Table<'lua>) -> rlua::Result<Table<'lua>> {
let module = ctx.create_table()?; let module = ctx.create_table()?;
module.set("cuboid", ctx.create_function( module.set("cuboid", ctx.create_function(
@ -70,10 +70,14 @@ pub fn obj(ctx: Context, _: ()) -> rlua::Result<Table> {
|ctx, ()| LuaObject::new(Waves::new()) |ctx, ()| LuaObject::new(Waves::new())
)?)?; )?)?;
module.set("withlights", ctx.create_function( module.set("withlight", ctx.create_function(
|ctx, (obj, light): (LuaObject, Light)| LuaObject::new(WithLights::new_one(obj.get(), light)) |ctx, (obj, light): (LuaObject, Light)| LuaObject::new(WithLights::new_one(obj.get(), light))
)?)?; )?)?;
module.set("withlights", ctx.create_function(
|ctx, (obj, lights): (LuaObject, Vec<Light>)| LuaObject::new(WithAnyLights::new(obj.get(), lights))
)?)?;
module.set("withmaterial", ctx.create_function( module.set("withmaterial", ctx.create_function(
|ctx, (obj, material): (LuaObject, Material)| LuaObject::new(WithMaterial::new(obj.get(), material)) |ctx, (obj, material): (LuaObject, Material)| LuaObject::new(WithMaterial::new(obj.get(), material))
)?)?; )?)?;

@ -0,0 +1,40 @@
use rlua::{Context, Table, Error};
use crate::object::{SWAP_XY, SWAP_YZ, SWAP_XZ, scale_xyz, scale, scale_x, scale_y, scale_z};
use crate::structs::{Mat3, I3};
pub fn transform<'lua>(ctx: Context<'lua>, _env: Table<'lua>) -> rlua::Result<Table<'lua>> {
let module = ctx.create_table()?;
module.set("SWAPXY", SWAP_XY)?;
module.set("SWAPXZ", SWAP_XZ)?;
module.set("SWAPYZ", SWAP_YZ)?;
module.set("scalexyz", ctx.create_function(
|ctx, (x, y, z)| Ok(scale_xyz(x, y, z))
)?)?;
module.set("scale", ctx.create_function(
|ctx, k| Ok(scale(k))
)?)?;
module.set("scalex", ctx.create_function(
|ctx, k| Ok(scale_x(k))
)?)?;
module.set("scaley", ctx.create_function(
|ctx, k| Ok(scale_y(k))
)?)?;
module.set("scalez", ctx.create_function(
|ctx, k| Ok(scale_z(k))
)?)?;
module.set("stack", ctx.create_function(
|ctx, transforms: Vec<Mat3>| {
let mut acc = I3;
for trans in transforms.iter().rev().cloned() {
acc = trans * I3;
}
Ok(acc)
}
)?)?;
Ok(module)
}

@ -0,0 +1,71 @@
local env = ...
local obj = env.obj
local vec3 = env.vec3
local mat3 = env.mat3
local transform = env.transform
local M = {}
-- technically a binary fold
local function binstack(objs, fn)
local n = #objs
if n == 1 then return objs[1] end
local mid = n//2
local left, right = {}, {}
for i=1, mid do
left[i] = objs[i]
end
for i=mid+1, n do
right[i-mid] = objs[i]
end
return fn(binstack(left, fn), binstack(right, fn))
end
-- technically a fold
local function linstack(objs, fn)
local head = objs[1]
for i=2, #objs do
head = fn(head, objs[i])
end
return head
end
-- random functions
local SEED = os.getenv "SEED" or os.time()
function M.seed()
math.randomseed(SEED)
end
function M.randab(a, b)
return math.random() * (b-a) + a
end
function M.choice(list)
return list[math.random(1, #list)]
end
-- multiple object composition
function M.union(objs)
return binstack(objs, obj.union)
end
function M.intersection(objs)
return binstack(objs, obj.intersection)
end
function M.smoothunion(objs, k)
return linstack(objs, function(a, b) return obj.smoothunion(a, b, k) end)
end
function M.smoothintersection(objs, k)
return linstack(objs, function(a, b) return obj.smoothintersection(a, b, k) end)
end
-- simpler transformations
function M.scale(object, k)
return obj.affinetransform(object, transform.scale(k), vec3.O)
end
function M.transform(object, mat)
return obj.affinetransform(object, mat, vec3.O)
end
function M.translate(object, vec)
return obj.affinetransform(object, mat3.O, vec)
end
return M

@ -0,0 +1,5 @@
use rlua::{Context, Table};
pub fn util<'lua>(ctx: Context<'lua>, env: Table<'lua>) -> rlua::Result<Table<'lua>> {
ctx.load(include_str!("util.lua")).call(env)
}

@ -24,7 +24,7 @@ impl UserData for Vec3 {
} }
} }
pub fn vec3(ctx: Context, _: ()) -> rlua::Result<Table> { pub fn vec3<'lua>(ctx: Context<'lua>, _env: Table<'lua>) -> rlua::Result<Table<'lua>> {
let module = ctx.create_table()?; let module = ctx.create_table()?;
module.set("new", ctx.create_function( module.set("new", ctx.create_function(

@ -68,8 +68,7 @@ fn default_scene2() -> Scene {
} }
fn default_scene3() -> Scene { fn default_scene3() -> Scene {
//TODO fix this scene let s1 = WithMaterial::new(Sphere::new_xyz(4., 0., 0., 1.), MIRROR);
let s1 = WithMaterial::new(Sphere::new_xyz(4., 0., 0., 1.), WHITE);
let s2 = WithMaterial::new(Sphere::new_xyz(3., 1., 1., 0.5), GREEN); let s2 = WithMaterial::new(Sphere::new_xyz(3., 1., 1., 0.5), GREEN);
let navion = WithMaterial::new(Plane::new_xyz(0., 1., -1., 3.), BLUE); let navion = WithMaterial::new(Plane::new_xyz(0., 1., -1., 3.), BLUE);
let backwall = WithMaterial::new(Plane::new_xyz(-1., -1., -0.5, 8.), RED); let backwall = WithMaterial::new(Plane::new_xyz(-1., -1., -0.5, 8.), RED);
@ -86,7 +85,8 @@ fn default_scene3() -> Scene {
fn main() { fn main() {
// get scene and camera // get scene and camera
let scene = scene_from_file("scenes/smolgalaxy.lua".to_owned()).unwrap(); //let scene = scene_from_file("scenes/randomspheres.lua".to_owned()).unwrap();
let scene = default_scene3();
let cam = default_cam(); let cam = default_cam();
// get stats on the scene we're about to render // get stats on the scene we're about to render

@ -70,6 +70,6 @@ pub use cylinder::Cylinder;
pub use torus::Torus; pub use torus::Torus;
pub use waves::Waves; pub use waves::Waves;
pub use with_material::{WithMaterial, WithDynamicMaterial}; pub use with_material::{WithMaterial, WithDynamicMaterial};
pub use with_lights::{WithLights, WithLight}; pub use with_lights::{WithLights, WithLight, WithAnyLights};
pub use transform::*; pub use transform::*;
pub use scene::Scene; pub use scene::Scene;

@ -8,18 +8,19 @@ pub struct AffineTransform<T: Obj + Clone> {
obj: T, obj: T,
transform: Mat3, transform: Mat3,
transform_inv: Mat3, transform_inv: Mat3,
translate: Vec3 translate: Vec3,
scale: f64
} }
impl<T: Obj + Clone> AffineTransform<T> { impl<T: Obj + Clone> AffineTransform<T> {
pub fn new(obj: T, transform: Mat3, translate: Vec3) -> AffineTransform<T> { pub fn new(obj: T, transform: Mat3, translate: Vec3) -> AffineTransform<T> {
AffineTransform { obj, transform, transform_inv: transform.invert(), translate } AffineTransform { obj, transform, transform_inv: transform.invert(), translate, scale: transform.det().cbrt() }
} }
pub fn new_linear(obj: T, transform: Mat3) -> AffineTransform<T> { pub fn new_linear(obj: T, transform: Mat3) -> AffineTransform<T> {
AffineTransform { obj, transform, transform_inv: transform.invert(), translate: O } AffineTransform { obj, transform, transform_inv: transform.invert(), translate: O, scale: transform.det().cbrt() }
} }
pub fn new_translate(obj: T, translate: Vec3) -> AffineTransform<T> { pub fn new_translate(obj: T, translate: Vec3) -> AffineTransform<T> {
AffineTransform { obj, transform: I3, transform_inv: I3, translate } AffineTransform { obj, transform: I3, transform_inv: I3, translate, scale: 1. }
} }
fn apply_rev(&self, point: Vec3) -> Vec3 { fn apply_rev(&self, point: Vec3) -> Vec3 {
@ -60,7 +61,7 @@ pub const fn scale_z(k: f64) -> Mat3 { scale_xyz(1., 1., k) }
impl<T: Obj + Clone> Obj for AffineTransform<T> { impl<T: Obj + Clone> Obj for AffineTransform<T> {
fn distance_to(&self, point: Vec3) -> f64 { fn distance_to(&self, point: Vec3) -> f64 {
self.obj.distance_to(self.apply_rev(point)) self.obj.distance_to(self.apply_rev(point)) * self.scale
} }
fn normal_at(&self, point: Vec3) -> Vec3 { fn normal_at(&self, point: Vec3) -> Vec3 {
self.apply_fwd(self.obj.normal_at(self.apply_rev(point))).unit() self.apply_fwd(self.obj.normal_at(self.apply_rev(point))).unit()

@ -42,3 +42,39 @@ impl<T: Obj> WithLight<T> {
WithLight { obj, lights: [light; 1] } WithLight { obj, lights: [light; 1] }
} }
} }
#[derive(Debug, Clone, PartialEq)]
pub struct WithAnyLights<T: Obj> {
obj: T,
lights: Vec<Light>
}
impl<T: Obj> WithAnyLights<T> {
pub fn new(obj: T, lights: Vec<Light>) -> WithAnyLights<T> {
WithAnyLights { obj, lights }
}
pub fn add_light(&mut self, light: Light) {
self.lights.push(light)
}
}
impl<T: Obj> Obj for WithAnyLights<T> {
fn distance_to(&self, point: Vec3) -> f64 {
self.obj.distance_to(point)
}
fn normal_at(&self, point: Vec3) -> Vec3 {
self.obj.normal_at(point)
}
fn material_at(&self, point: Vec3) -> Material {
self.obj.material_at(point)
}
fn get_lights(&self) -> Vec<Light> {
let mut l = self.obj.get_lights();
l.extend(&self.lights);
l
}
fn node_count(&self) -> u32 {
self.obj.node_count() + 1
}
}

@ -2,6 +2,6 @@
clear clear
cargo build --release && \ cargo build --release && \
clear && \ clear && \
time target/release/rmarcher && \ time SEED=0 target/release/rmarcher && \
printf '\n' && \ printf '\n' && \
kitty +kitten icat --align=left a.png kitty +kitten icat --align=left a.png

Loading…
Cancel
Save