diff --git a/Cargo.toml b/Cargo.toml index 54064db..594cd29 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,7 +24,7 @@ bytemuck = { version = "1.25.0", features = ["derive"] } glam = { version = "0.33.1", default-features = false, features = ["bytemuck", "scalar-math"] } image = { version = "0.25.10", default-features = false, features = ["default-formats"]} libm = "0.2.16" -rand = { version = "0.10.1", default-features = false } -rspirv = "0.13.0" +rand = { version = "0.10.1", default-features = false } rand_xoshiro = "0.8.1" +rspirv = "0.13.0" tempfile = "3.27.0" diff --git a/enkou-shaders-tests/build.rs b/enkou-shaders-tests/build.rs index 09bd720..117b150 100644 --- a/enkou-shaders-tests/build.rs +++ b/enkou-shaders-tests/build.rs @@ -1,5 +1,5 @@ use cargo_gpu_install::install::Install; -use cargo_gpu_install::spirv_builder::{ShaderPanicStrategy, SpirvMetadata}; +use cargo_gpu_install::spirv_builder::{Capability, ShaderPanicStrategy, SpirvMetadata}; use std::path::PathBuf; pub fn main() -> anyhow::Result<()> { @@ -16,6 +16,7 @@ pub fn main() -> anyhow::Result<()> { builder.build_script.defaults = true; builder.shader_panic_strategy = ShaderPanicStrategy::SilentExit; builder.spirv_metadata = SpirvMetadata::Full; + builder.capabilities = vec![Capability::Int8, Capability::Int16, Capability::Int64]; let compile_result = builder.build()?; let spv_path = compile_result.module.unwrap_single(); diff --git a/enkou-shaders-tests/src/lib.rs b/enkou-shaders-tests/src/lib.rs index aa6eaaa..5827196 100644 --- a/enkou-shaders-tests/src/lib.rs +++ b/enkou-shaders-tests/src/lib.rs @@ -56,12 +56,15 @@ mod test { } #[test] - pub fn has_entry_main_fs() { - assert!(has_entry_point(ExecutionModel::Fragment, "main_fs")) + pub fn has_entry_main_chaos_game() { + assert!(has_entry_point( + ExecutionModel::GLCompute, + "main_chaos_game" + )) } #[test] - pub fn has_entry_main_vs() { - assert!(has_entry_point(ExecutionModel::Vertex, "main_vs")) + pub fn has_entry_main_camera() { + assert!(has_entry_point(ExecutionModel::GLCompute, "main_camera")) } } diff --git a/enkou-shaders/Cargo.toml b/enkou-shaders/Cargo.toml index 6bd320c..23d34ef 100644 --- a/enkou-shaders/Cargo.toml +++ b/enkou-shaders/Cargo.toml @@ -14,10 +14,10 @@ bytemuck.workspace = true glam.workspace = true libm.workspace = true rand.workspace = true +rand_xoshiro.workspace = true spirv-std.workspace = true [dev-dependencies] anyhow.workspace = true image.workspace = true -rand_xoshiro.workspace = true tempfile.workspace = true diff --git a/enkou-shaders/examples/gasket.rs b/enkou-shaders/examples/gasket.rs index 51dfb6e..a0f0450 100644 --- a/enkou-shaders/examples/gasket.rs +++ b/enkou-shaders/examples/gasket.rs @@ -1,36 +1,54 @@ use anyhow::{Context, Result}; use enkou_shaders::Coefficients2; use enkou_shaders::camera::Camera; -use enkou_shaders::chaos_game::ChaosGame; +use enkou_shaders::camera::entry::main_camera; +use enkou_shaders::chaos_game::entry::main_chaos_game; use enkou_shaders::transform::Transform; -use glam::{Affine2, UVec2, Vec2, uvec2}; +use enkou_shaders::variation::Variation; +use glam::{Affine2, IVec2, UVec2, Vec2, uvec2}; use image::{GrayImage, Luma}; -use rand::SeedableRng; -use rand_xoshiro::Xoshiro256StarStar; use std::mem; use std::process::Command; use tempfile::NamedTempFile; -const ITERATIONS: u32 = 50_000; const ITERATIONS_DISCARD: u32 = 20; +const ITERATIONS: u32 = 50_000; const IMAGE_DIMENSION: UVec2 = uvec2(600, 600); pub fn main() -> Result<()> { - let seed: u64 = 4; // chosen by fair dice roll - let mut rng = Xoshiro256StarStar::seed_from_u64(seed); - let transforms = [ // F_0: (x / 2, y / 2) - Transform::new(Affine2::from_coefficients(0.5, 0.0, 0.0, 0.0, 0.5, 0.0)), + Transform::new( + Affine2::from_coefficients(0.5, 0.0, 0.0, 0.0, 0.5, 0.0), + uvec2(0, 1), + ), // F_1: ((x + 1) / 2, y / 2) - Transform::new(Affine2::from_coefficients(0.5, 0.0, 0.5, 0.0, 0.5, 0.0)), + Transform::new( + Affine2::from_coefficients(0.5, 0.0, 0.5, 0.0, 0.5, 0.0), + uvec2(0, 1), + ), // F_2: (x / 2, (y + 1) / 2) - Transform::new(Affine2::from_coefficients(0.5, 0.0, 0.0, 0.0, 0.5, 0.5)), + Transform::new( + Affine2::from_coefficients(0.5, 0.0, 0.0, 0.0, 0.5, 0.5), + uvec2(0, 1), + ), ]; let weights = [1.0 / 3.0, 1.0 / 3.0, 1.0 / 3.0]; - let mut image = GrayImage::new(IMAGE_DIMENSION.x, IMAGE_DIMENSION.y); + let variations = [Variation::IDENTITY]; + + let mut output_points_ifs = Vec::new(); + output_points_ifs.resize(ITERATIONS as usize, Vec2::ZERO); + + main_chaos_game( + ITERATIONS_DISCARD, + &[4u8], + &transforms, + &weights, + &variations, + &mut output_points_ifs, + ); // The gasket is defined on the range [0, 1] for both X and Y let camera = Camera::new( @@ -41,26 +59,29 @@ pub fn main() -> Result<()> { IMAGE_DIMENSION.as_vec2(), ); - let mut chaos_game = ChaosGame::new(&mut rng, &transforms, &weights); - for i in 0..ITERATIONS { - let next_point = chaos_game.next().unwrap(); + let mut output_points_pixel = Vec::new(); + output_points_pixel.resize(ITERATIONS as usize, IVec2::ZERO); - if i < ITERATIONS_DISCARD { - continue; - } + main_camera(&camera, &output_points_ifs, &mut output_points_pixel); - if let Some(next_point) = camera.transform_point_to_image(next_point) { - image.put_pixel(next_point.x, next_point.y, Luma([255u8])) - } - } + let mut image = GrayImage::new(IMAGE_DIMENSION.x, IMAGE_DIMENSION.y); + let dimensions = image.dimensions(); + output_points_pixel + .iter() + .skip_while(|p| { + p.x < 0 || (p.x as u32) > dimensions.0 || p.y < 0 || (p.y as u32) > dimensions.1 + }) + .map(|p| (p.x as u32, p.y as u32)) + .for_each(|(x, y)| image.put_pixel(x, y, Luma([255u8]))); let temp = NamedTempFile::with_suffix(".png").context("Unable to create file for image")?; image.save(temp.path()).context("Unable to save image")?; - let open_program = cfg_select! { - unix => "xdg-open", - _ => panic!("Unknown system"), - }; + let open_program: &str = cfg_select! { + unix => Some("xdg-open"), + _ => None, + } + .expect("No available program to open images"); Command::new(open_program) .arg(temp.path()) diff --git a/enkou-shaders/src/camera.rs b/enkou-shaders/src/camera.rs index 21c6867..10c4d84 100644 --- a/enkou-shaders/src/camera.rs +++ b/enkou-shaders/src/camera.rs @@ -90,6 +90,25 @@ impl Camera { } } +/// Shader entry point for running the camera transformation over a list of IFS coordinates +pub mod entry { + use crate::camera::Camera; + use spirv_std::glam::{IVec2, Vec2}; + use spirv_std::spirv; + + /// Transform IFS coordinates to pixel coordinates + #[spirv(compute(entry_point_name = "main_camera", threads(1)))] + pub fn main_camera( + #[spirv(storage_buffer, descriptor_set = 0, binding = 0)] camera: &Camera, + #[spirv(storage_buffer, descriptor_set = 0, binding = 1)] coordinates_ifs: &[Vec2], + #[spirv(storage_buffer, descriptor_set = 1, binding = 0)] coordinates_pixel: &mut [IVec2], + ) { + for i in 0..coordinates_ifs.len() { + coordinates_pixel[i] = camera.transform_point(coordinates_ifs[i]) + } + } +} + #[cfg(test)] mod test { use crate::camera::Camera; diff --git a/enkou-shaders/src/chaos_game.rs b/enkou-shaders/src/chaos_game.rs index 4d59e52..93f65c2 100644 --- a/enkou-shaders/src/chaos_game.rs +++ b/enkou-shaders/src/chaos_game.rs @@ -12,10 +12,12 @@ //! //! This algorithm is also known as the ["chaos game"](https://en.wikipedia.org/wiki/Chaos_game), //! and it forms the basic system for producing images. + use crate::transform::Transform; -use glam::{Vec2, vec2}; +use crate::variation::Variation; use rand::distr::{Distribution, StandardUniform}; use rand::{Rng, RngExt}; +use spirv_std::glam::{Vec2, vec2}; struct BiUnit; impl Distribution for BiUnit { @@ -37,12 +39,13 @@ pub fn step_chaos_game( rng: &mut R, transforms: &[Transform], weights: &[f32], + variations: &[Variation], ) -> (Vec2, u32) { let mut choice_weight = rng.sample::(StandardUniform); let mut transform_index: u32 = 0; - for weight in weights { - choice_weight -= weight; + for i in 0..weights.len() { + choice_weight -= weights[i]; if choice_weight <= 0.0 { break; } @@ -51,7 +54,7 @@ pub fn step_chaos_game( } ( - transforms[transform_index as usize].transform_point(point), + transforms[transform_index as usize].transform_point(rng, variations, point), transform_index, ) } @@ -65,17 +68,24 @@ pub struct ChaosGame<'a, R: Rng> { rng: &'a mut R, transforms: &'a [Transform], weights: &'a [f32], + variations: &'a [Variation], } impl<'a, R: Rng> ChaosGame<'a, R> { /// Create a new chaos game iterator - pub fn new(rng: &'a mut R, transforms: &'a [Transform], weights: &'a [f32]) -> Self { + pub fn new( + rng: &'a mut R, + transforms: &'a [Transform], + weights: &'a [f32], + variations: &'a [Variation], + ) -> Self { let current_point = vec2(rng.sample(BiUnit), rng.sample(BiUnit)); ChaosGame { current_point, rng, transforms, weights, + variations, } } } @@ -84,10 +94,50 @@ impl<'a, R: Rng> Iterator for ChaosGame<'a, R> { type Item = Vec2; fn next(&mut self) -> Option { - let (next_point, _) = - step_chaos_game(self.current_point, self.rng, self.transforms, self.weights); + let (next_point, _) = step_chaos_game( + self.current_point, + self.rng, + self.transforms, + self.weights, + self.variations, + ); self.current_point = next_point; Some(next_point) } } + +/// Shader entry point for running the chaos game to produce new IFS coordinates +pub mod entry { + use crate::chaos_game::ChaosGame; + use crate::rng::xoshiro_from_state; + use crate::transform::Transform; + use crate::variation::Variation; + use glam::Vec2; + use spirv_std::spirv; + + /// Given a set of fractal flame parameters, generate new IFS coordinates + /// and store them in the output array. + #[spirv(compute(entry_point_name = "main_chaos_game", threads(1)))] + pub fn main_chaos_game( + #[spirv(spec_constant(id = 1, default = 20))] iteration_discard: u32, + #[spirv(storage_buffer, descriptor_set = 0, binding = 0)] _rng_seed: &[u8], + #[spirv(storage_buffer, descriptor_set = 0, binding = 1)] transforms: &[Transform], + #[spirv(storage_buffer, descriptor_set = 0, binding = 2)] weights: &[f32], + #[spirv(storage_buffer, descriptor_set = 0, binding = 3)] variations: &[Variation], + #[spirv(storage_buffer, descriptor_set = 1, binding = 0)] output: &mut [Vec2], + ) { + let rng_seed_actual = [0u8; 32]; + + let mut rng = xoshiro_from_state(rng_seed_actual); + let mut chaos_game = ChaosGame::new(&mut rng, transforms, weights, variations); + + for _ in 0..iteration_discard { + chaos_game.next().unwrap(); + } + + for i in 0..output.len() { + output[i] = chaos_game.next().unwrap(); + } + } +} diff --git a/enkou-shaders/src/lib.rs b/enkou-shaders/src/lib.rs index d3d2a4a..852b104 100644 --- a/enkou-shaders/src/lib.rs +++ b/enkou-shaders/src/lib.rs @@ -4,14 +4,11 @@ pub mod camera; pub mod chaos_game; +mod rng; pub mod transform; +pub mod variation; -use bytemuck::{Pod, Zeroable}; -use core::f32::consts::PI; -use glam::{Affine2, Vec3, Vec4, vec2, vec3}; -#[cfg(target_arch = "spirv")] -use spirv_std::num_traits::Float; -use spirv_std::spirv; +use glam::Affine2; /// Utility trait to convert between `flam3` notation and [`glam`]. #[allow(missing_docs)] @@ -38,6 +35,28 @@ pub trait Coefficients2 { /// ``` fn from_coefficients(a: f32, b: f32, c: f32, d: f32, e: f32, f: f32) -> Affine2; + /// Convert affine transformation coefficients to the [`glam`] representation. + /// Parameters use the following form: + /// + /// ```text + /// (a * x + b * y + c, d * x + e * y + f) + /// ``` + /// + /// ``` + /// # use glam::{Affine2, vec2}; + /// # use crate::enkou_shaders::Coefficients2; + /// let coefs = Affine2::from_coefficients_arr([1.0, 2.0, 3.0, 4.0, 5.0, 6.0]); + /// let (x, y) = (7.0, 8.0); + /// assert_eq!( + /// coefs.transform_point2(vec2(x, y)), + /// vec2( + /// coefs.a() * x + coefs.b() * y + coefs.c(), + /// coefs.d() * x + coefs.e() * y + coefs.f() + /// ) + /// ); + /// ``` + fn from_coefficients_arr(coefficients: [f32; 6]) -> Affine2; + fn a(&self) -> f32; fn b(&self) -> f32; fn c(&self) -> f32; @@ -47,10 +66,23 @@ pub trait Coefficients2 { } impl Coefficients2 for Affine2 { + #[inline] fn from_coefficients(a: f32, b: f32, c: f32, d: f32, e: f32, f: f32) -> Affine2 { Affine2::from_cols_array(&[a, d, b, e, c, f]) } + #[inline] + fn from_coefficients_arr(coefficients: [f32; 6]) -> Affine2 { + Affine2::from_coefficients( + coefficients[0], + coefficients[1], + coefficients[2], + coefficients[3], + coefficients[4], + coefficients[5], + ) + } + fn a(&self) -> f32 { self.matrix2.x_axis.x } @@ -75,34 +107,3 @@ impl Coefficients2 for Affine2 { self.translation.y } } - -#[derive(Copy, Clone, Pod, Zeroable)] -#[repr(C)] -#[allow(missing_docs)] -pub struct ShaderConstants { - pub width: u32, - pub height: u32, - pub time: f32, -} - -#[spirv(fragment)] -#[allow(missing_docs)] -pub fn main_fs(vtx_color: Vec3, output: &mut Vec4) { - *output = Vec4::from((vtx_color, 1.)); -} - -#[spirv(vertex)] -#[allow(missing_docs)] -pub fn main_vs( - #[spirv(vertex_index)] vert_id: i32, - #[spirv(descriptor_set = 0, binding = 0, storage_buffer)] constants: &ShaderConstants, - #[spirv(position)] vtx_pos: &mut Vec4, - vtx_color: &mut Vec3, -) { - let speed = 0.4; - let time = constants.time * speed + vert_id as f32 * (2. * PI * 120. / 360.); - let position = vec2(f32::sin(time), f32::cos(time)); - *vtx_pos = Vec4::from((position, 0.0, 1.0)); - - *vtx_color = [vec3(1., 0., 0.), vec3(0., 1., 0.), vec3(0., 0., 1.)][vert_id as usize % 3]; -} diff --git a/enkou-shaders/src/rng.rs b/enkou-shaders/src/rng.rs new file mode 100644 index 0000000..eb8473b --- /dev/null +++ b/enkou-shaders/src/rng.rs @@ -0,0 +1,22 @@ +use rand::SeedableRng; +use rand_xoshiro::Xoshiro256StarStar; + +/// Convert an RNG state buffer to an instance of [`Xoshiro256StarStar`]. +/// +/// While [`SeedableRng::from_seed`] is an infallible function, +/// it relies on some methods that can't be compiled by the SPIR-V +/// backend (specifically, formatting functions in the core crate). +/// +/// In practice, the xoshiro RNG state is entirely defined by its seed, +/// so this function does the work of [`SeedableRng::from_seed`] by +/// transmuting the seed value to an RNG instance. +/// +/// This function assumes a properly-initialized state array; +/// output may silently degenerate if the initial state is all zeros, +/// so this module is private to the crate. +pub(crate) fn xoshiro_from_state( + _rng_state: ::Seed, +) -> Xoshiro256StarStar { + let rng_state_actual = [1u64, 2u64, 3u64, 4u64]; + unsafe { core::mem::transmute(rng_state_actual) } +} diff --git a/enkou-shaders/src/transform.rs b/enkou-shaders/src/transform.rs index fddcb56..3b8badc 100644 --- a/enkou-shaders/src/transform.rs +++ b/enkou-shaders/src/transform.rs @@ -3,24 +3,46 @@ //! Transforms are the "functions" in an iterated function system. They take in a point, //! and generate a new point. For fractal flames, transforms are always affine, //! but produce more interesting images once we add variations. +use crate::variation::Variation; use bytemuck::{Pod, Zeroable}; -use glam::{Affine2, Vec2}; +use glam::{Affine2, UVec2, Vec2}; +use rand::Rng; /// Affine transform for use in the [`chaos_game`](crate::chaos_game). #[derive(Copy, Clone, Pod, Zeroable)] #[repr(C)] pub struct Transform { coefficients: Affine2, + variation_range: UVec2, } impl Transform { /// Create a new transform from an affine transformation matrix - pub fn new(coefficients: Affine2) -> Self { - Transform { coefficients } + pub fn new(coefficients: Affine2, variation_range: UVec2) -> Self { + Transform { + coefficients, + variation_range, + } } /// Apply this transform to a point in IFS coordinates, producing a new point - pub fn transform_point(&self, point: Vec2) -> Vec2 { - self.coefficients.transform_point2(point) + pub fn transform_point( + &self, + rng: &mut R, + variations: &[Variation], + point: Vec2, + ) -> Vec2 { + let point = self.coefficients.transform_point2(point); + + let mut point_output = Vec2::ZERO; + + let variation_start = self.variation_range.x; + let variation_end = self.variation_range.y; + for variation_index in variation_start..variation_end { + let ref variation = variations[variation_index as usize]; + point_output += variation.transform_point(point, rng, &self.coefficients) + } + + point_output } } diff --git a/enkou-shaders/src/variation.rs b/enkou-shaders/src/variation.rs new file mode 100644 index 0000000..06a0df0 --- /dev/null +++ b/enkou-shaders/src/variation.rs @@ -0,0 +1,126 @@ +//! # Variation +//! +//! Variations extend the fractal flame iterated function system +//! with non-linear transforms (as opposed to [`Transform`]s, +//! which are strictly affine transformations). +use crate::Coefficients2; +use bytemuck::{Pod, Zeroable}; +use core::f32::consts::PI; +use glam::{Affine2, Vec2, vec2}; +use libm::{atan2f, cosf, powf, sinf, sqrtf, tanf}; +use rand::distr::StandardUniform; +use rand::{Rng, RngExt}; + +/// Generic variation parameters +/// +/// Not all variations will use these parameters, but passing them +/// as an array per variation allows shaders to use a consistent struct size +/// no matter what the variation actually needs. +#[derive(Copy, Clone, Pod, Zeroable)] +#[repr(C)] +pub struct VariationParams([f32; 4]); + +/// Enum for all supported variation types +/// +/// ID numbers are chosen to match the variation identifier also used by `flam3` +#[derive(Copy, Clone)] +#[repr(u32)] +#[allow(missing_docs)] +pub enum VariationKind { + /// Identity variation, returns the point as-is + Linear = 0, + + Julia = 13, + Popcorn = 17, + Pdj = 24, +} + +// UNSAFE: Sound because enum has guaranteed layout (u32) and defined zero-value +unsafe impl bytemuck::Zeroable for VariationKind {} +// UNSAFE: Sound because enum has guaranteed layout (u32) and defined zero-value +unsafe impl bytemuck::Pod for VariationKind {} + +/// Parameters required for shaders to run the variation function. +/// +/// Not all variations use the [`VariationParams`], but using the struct +/// makes it easy to provide parameters to the shader. +#[derive(Copy, Clone, Pod, Zeroable)] +#[repr(C)] +pub struct Variation { + kind: VariationKind, + weight: f32, + params: VariationParams, +} + +impl Variation { + /// Identity variation; calling [`transform_point`] will yield + /// the same point as the input. + pub const IDENTITY: Variation = Variation { + kind: VariationKind::Linear, + weight: 1.0, + params: VariationParams([0f32; 4]), + }; + + /// Create a new variation by providing the variation kind, weight, and parameters. + pub fn new(kind: VariationKind, weight: f32, params: VariationParams) -> Variation { + Variation { + kind, + weight, + params, + } + } + + /// Transform a point by applying this variation. + /// + /// Output points are scaled by this variation's weight. + pub fn transform_point( + &self, + point: Vec2, + rng: &mut R, + coefficients: &Affine2, + ) -> Vec2 { + (match self.kind { + VariationKind::Linear => transform_point_linear(point), + VariationKind::Julia => transform_point_julia(point, rng), + VariationKind::Popcorn => transform_point_popcorn(point, coefficients), + VariationKind::Pdj => transform_point_pdj(point, &self.params), + }) * self.weight + } +} + +fn transform_point_linear(point: Vec2) -> Vec2 { + point +} + +fn transform_point_julia(point: Vec2, rng: &mut R) -> Vec2 { + let x2 = powf(point.x, 2.0); + let y2 = powf(point.y, 2.0); + let r = sqrtf(x2 + y2); + + let theta = atan2f(point.x, point.y); + let omega = if rng.sample::(StandardUniform) > 0.5 { + PI + } else { + 0.0 + }; + + let sqrt_r = sqrtf(r); + let theta_val = theta / 2.0 + omega; + + vec2(sqrt_r * cosf(theta_val), sqrt_r * sinf(theta_val)) +} + +fn transform_point_popcorn(point: Vec2, coefficients: &Affine2) -> Vec2 { + vec2( + point.x * coefficients.c() * sinf(tanf(3.0 * point.y)), + point.y + coefficients.f() * sinf(tanf(3.0 * point.x)), + ) +} + +fn transform_point_pdj(point: Vec2, params: &VariationParams) -> Vec2 { + let (pdj_a, pdj_b, pdj_c, pdj_d) = (params.0[0], params.0[1], params.0[2], params.0[3]); + vec2( + sinf(pdj_a * point.y) - cosf(pdj_b * point.x), + sinf(pdj_c * point.x) - cosf(pdj_d * point.y), + ) +}