mirror of
https://github.com/storytold/lightcraft.git
synced 2026-10-28 03:57:57 -06:00
M0.3/M0.5: develop settings model + control specs + presets; procedural demo scenes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
474e44c29e
commit
3f54765bd0
@@ -0,0 +1,271 @@
|
||||
//! Control specs: one table describes every numeric develop slider — id, label, section, range,
|
||||
//! default, step, display precision and track style. The UI builds panels from it, MCP exposes it as
|
||||
//! a schema, and `get`/`set` address settings by id (e.g. `light.exposure`, `mixer.red.hue`).
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::settings::DevelopSettings;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Section {
|
||||
Light,
|
||||
Curve,
|
||||
Color,
|
||||
Mixer,
|
||||
BwMix,
|
||||
Grading,
|
||||
Effects,
|
||||
Vignette,
|
||||
Grain,
|
||||
Detail,
|
||||
Optics,
|
||||
Geometry,
|
||||
Profile,
|
||||
}
|
||||
|
||||
impl Section {
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Section::Light => "Light",
|
||||
Section::Curve => "Tone Curve",
|
||||
Section::Color => "Color",
|
||||
Section::Mixer => "Color Mixer",
|
||||
Section::BwMix => "B&W Mixer",
|
||||
Section::Grading => "Color Grading",
|
||||
Section::Effects => "Effects",
|
||||
Section::Vignette => "Vignette",
|
||||
Section::Grain => "Grain",
|
||||
Section::Detail => "Detail",
|
||||
Section::Optics => "Optics",
|
||||
Section::Geometry => "Geometry",
|
||||
Section::Profile => "Profile",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How the slider track is drawn.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase", tag = "kind")]
|
||||
pub enum Track {
|
||||
/// Plain grey track, fill from the minimum.
|
||||
Plain,
|
||||
/// Grey track, zero in the middle.
|
||||
Centered,
|
||||
/// Blue → yellow (white balance temperature).
|
||||
Temp,
|
||||
/// Green → magenta (tint).
|
||||
Tint,
|
||||
/// A two-colour gradient (sRGB hex strings).
|
||||
Gradient { from: &'static str, to: &'static str },
|
||||
/// Hue shift for a mixer band: gradient from the neighbouring hues through the band colour.
|
||||
Hue { band: u8 },
|
||||
/// Saturation for a band: grey → band colour.
|
||||
Sat { band: u8 },
|
||||
/// Luminance for a band: dark → light band colour.
|
||||
Lum { band: u8 },
|
||||
/// Full hue rainbow.
|
||||
Rainbow,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize)]
|
||||
pub struct ControlSpec {
|
||||
pub id: &'static str,
|
||||
pub label: &'static str,
|
||||
pub section: Section,
|
||||
pub min: f64,
|
||||
pub max: f64,
|
||||
pub default: f64,
|
||||
pub step: f64,
|
||||
/// Decimal places shown.
|
||||
pub decimals: u8,
|
||||
pub track: Track,
|
||||
}
|
||||
|
||||
impl ControlSpec {
|
||||
pub fn clamp(&self, v: f64) -> f64 {
|
||||
if v.is_finite() { v.clamp(self.min, self.max) } else { self.default }
|
||||
}
|
||||
pub fn format(&self, v: f64) -> String {
|
||||
let s = format!("{:.*}", self.decimals as usize, v);
|
||||
if v > 0.0 && self.min < 0.0 { format!("+{s}") } else { s }
|
||||
}
|
||||
}
|
||||
|
||||
macro_rules! controls {
|
||||
($( $id:literal => $($field:ident).+ , $label:literal, $section:ident, $min:expr, $max:expr, $def:expr, $step:expr, $dec:expr, $track:expr; )*) => {
|
||||
/// All numeric develop controls, in panel order.
|
||||
pub static CONTROLS: &[ControlSpec] = &[
|
||||
$( ControlSpec { id: $id, label: $label, section: Section::$section, min: $min as f64, max: $max as f64, default: $def as f64, step: $step as f64, decimals: $dec, track: $track }, )*
|
||||
];
|
||||
|
||||
/// Read a control value by id.
|
||||
pub fn get(s: &DevelopSettings, id: &str) -> Option<f64> {
|
||||
match id {
|
||||
$( $id => Some(s.$($field).+ as f64), )*
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a control value by id (clamped to the spec range). Returns false for unknown ids.
|
||||
pub fn set(s: &mut DevelopSettings, id: &str, v: f64) -> bool {
|
||||
let Some(spec) = find(id) else { return false };
|
||||
let v = spec.clamp(v);
|
||||
match id {
|
||||
$( $id => { s.$($field).+ = v as _; true } )*
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
use Track::*;
|
||||
|
||||
controls! {
|
||||
"profile.amount" => profile.amount, "Amount", Profile, 0, 200, 100, 1, 0, Plain;
|
||||
"wb.temp" => wb.temp, "Temp", Color, 2000, 50000, 6500, 50, 0, Temp;
|
||||
"wb.tint" => wb.tint, "Tint", Color, -150, 150, 0, 1, 0, Tint;
|
||||
"light.exposure" => light.exposure, "Exposure", Light, -5, 5, 0, 0.01, 2, Centered;
|
||||
"light.contrast" => light.contrast, "Contrast", Light, -100, 100, 0, 1, 0, Centered;
|
||||
"light.highlights" => light.highlights, "Highlights", Light, -100, 100, 0, 1, 0, Centered;
|
||||
"light.shadows" => light.shadows, "Shadows", Light, -100, 100, 0, 1, 0, Centered;
|
||||
"light.whites" => light.whites, "Whites", Light, -100, 100, 0, 1, 0, Centered;
|
||||
"light.blacks" => light.blacks, "Blacks", Light, -100, 100, 0, 1, 0, Centered;
|
||||
"curve.highlights" => curve.highlights, "Highlights", Curve, -100, 100, 0, 1, 0, Centered;
|
||||
"curve.lights" => curve.lights, "Lights", Curve, -100, 100, 0, 1, 0, Centered;
|
||||
"curve.darks" => curve.darks, "Darks", Curve, -100, 100, 0, 1, 0, Centered;
|
||||
"curve.shadows" => curve.shadows, "Shadows", Curve, -100, 100, 0, 1, 0, Centered;
|
||||
"curve.splitShadows" => curve.split_shadows, "Shadows split", Curve, 10, 70, 25, 1, 0, Plain;
|
||||
"curve.splitMid" => curve.split_mid, "Midtones split", Curve, 20, 80, 50, 1, 0, Plain;
|
||||
"curve.splitHighlights" => curve.split_highlights, "Highlights split", Curve, 30, 90, 75, 1, 0, Plain;
|
||||
"color.vibrance" => color.vibrance, "Vibrance", Color, -100, 100, 0, 1, 0, Gradient { from: "#7a7a7a", to: "#d8406a" };
|
||||
"color.saturation" => color.saturation, "Saturation", Color, -100, 100, 0, 1, 0, Gradient { from: "#7a7a7a", to: "#e04a3a" };
|
||||
"mixer.red.hue" => mixer.red.hue, "Red Hue", Mixer, -100, 100, 0, 1, 0, Hue { band: 0 };
|
||||
"mixer.red.sat" => mixer.red.sat, "Red Saturation", Mixer, -100, 100, 0, 1, 0, Sat { band: 0 };
|
||||
"mixer.red.lum" => mixer.red.lum, "Red Luminance", Mixer, -100, 100, 0, 1, 0, Lum { band: 0 };
|
||||
"mixer.orange.hue" => mixer.orange.hue, "Orange Hue", Mixer, -100, 100, 0, 1, 0, Hue { band: 1 };
|
||||
"mixer.orange.sat" => mixer.orange.sat, "Orange Saturation", Mixer, -100, 100, 0, 1, 0, Sat { band: 1 };
|
||||
"mixer.orange.lum" => mixer.orange.lum, "Orange Luminance", Mixer, -100, 100, 0, 1, 0, Lum { band: 1 };
|
||||
"mixer.yellow.hue" => mixer.yellow.hue, "Yellow Hue", Mixer, -100, 100, 0, 1, 0, Hue { band: 2 };
|
||||
"mixer.yellow.sat" => mixer.yellow.sat, "Yellow Saturation", Mixer, -100, 100, 0, 1, 0, Sat { band: 2 };
|
||||
"mixer.yellow.lum" => mixer.yellow.lum, "Yellow Luminance", Mixer, -100, 100, 0, 1, 0, Lum { band: 2 };
|
||||
"mixer.green.hue" => mixer.green.hue, "Green Hue", Mixer, -100, 100, 0, 1, 0, Hue { band: 3 };
|
||||
"mixer.green.sat" => mixer.green.sat, "Green Saturation", Mixer, -100, 100, 0, 1, 0, Sat { band: 3 };
|
||||
"mixer.green.lum" => mixer.green.lum, "Green Luminance", Mixer, -100, 100, 0, 1, 0, Lum { band: 3 };
|
||||
"mixer.aqua.hue" => mixer.aqua.hue, "Aqua Hue", Mixer, -100, 100, 0, 1, 0, Hue { band: 4 };
|
||||
"mixer.aqua.sat" => mixer.aqua.sat, "Aqua Saturation", Mixer, -100, 100, 0, 1, 0, Sat { band: 4 };
|
||||
"mixer.aqua.lum" => mixer.aqua.lum, "Aqua Luminance", Mixer, -100, 100, 0, 1, 0, Lum { band: 4 };
|
||||
"mixer.blue.hue" => mixer.blue.hue, "Blue Hue", Mixer, -100, 100, 0, 1, 0, Hue { band: 5 };
|
||||
"mixer.blue.sat" => mixer.blue.sat, "Blue Saturation", Mixer, -100, 100, 0, 1, 0, Sat { band: 5 };
|
||||
"mixer.blue.lum" => mixer.blue.lum, "Blue Luminance", Mixer, -100, 100, 0, 1, 0, Lum { band: 5 };
|
||||
"mixer.purple.hue" => mixer.purple.hue, "Purple Hue", Mixer, -100, 100, 0, 1, 0, Hue { band: 6 };
|
||||
"mixer.purple.sat" => mixer.purple.sat, "Purple Saturation", Mixer, -100, 100, 0, 1, 0, Sat { band: 6 };
|
||||
"mixer.purple.lum" => mixer.purple.lum, "Purple Luminance", Mixer, -100, 100, 0, 1, 0, Lum { band: 6 };
|
||||
"mixer.magenta.hue" => mixer.magenta.hue, "Magenta Hue", Mixer, -100, 100, 0, 1, 0, Hue { band: 7 };
|
||||
"mixer.magenta.sat" => mixer.magenta.sat, "Magenta Saturation", Mixer, -100, 100, 0, 1, 0, Sat { band: 7 };
|
||||
"mixer.magenta.lum" => mixer.magenta.lum, "Magenta Luminance", Mixer, -100, 100, 0, 1, 0, Lum { band: 7 };
|
||||
"bw.red" => bw_mix.red, "Red", BwMix, -100, 100, 0, 1, 0, Lum { band: 0 };
|
||||
"bw.orange" => bw_mix.orange, "Orange", BwMix, -100, 100, 0, 1, 0, Lum { band: 1 };
|
||||
"bw.yellow" => bw_mix.yellow, "Yellow", BwMix, -100, 100, 0, 1, 0, Lum { band: 2 };
|
||||
"bw.green" => bw_mix.green, "Green", BwMix, -100, 100, 0, 1, 0, Lum { band: 3 };
|
||||
"bw.aqua" => bw_mix.aqua, "Aqua", BwMix, -100, 100, 0, 1, 0, Lum { band: 4 };
|
||||
"bw.blue" => bw_mix.blue, "Blue", BwMix, -100, 100, 0, 1, 0, Lum { band: 5 };
|
||||
"bw.purple" => bw_mix.purple, "Purple", BwMix, -100, 100, 0, 1, 0, Lum { band: 6 };
|
||||
"bw.magenta" => bw_mix.magenta, "Magenta", BwMix, -100, 100, 0, 1, 0, Lum { band: 7 };
|
||||
"grading.shadows.hue" => grading.shadows.hue, "Shadows Hue", Grading, 0, 360, 0, 1, 0, Rainbow;
|
||||
"grading.shadows.sat" => grading.shadows.sat, "Shadows Saturation", Grading, 0, 100, 0, 1, 0, Plain;
|
||||
"grading.shadows.lum" => grading.shadows.lum, "Shadows Luminance", Grading, -100, 100, 0, 1, 0, Centered;
|
||||
"grading.midtones.hue" => grading.midtones.hue, "Midtones Hue", Grading, 0, 360, 0, 1, 0, Rainbow;
|
||||
"grading.midtones.sat" => grading.midtones.sat, "Midtones Saturation", Grading, 0, 100, 0, 1, 0, Plain;
|
||||
"grading.midtones.lum" => grading.midtones.lum, "Midtones Luminance", Grading, -100, 100, 0, 1, 0, Centered;
|
||||
"grading.highlights.hue" => grading.highlights.hue, "Highlights Hue", Grading, 0, 360, 0, 1, 0, Rainbow;
|
||||
"grading.highlights.sat" => grading.highlights.sat, "Highlights Saturation", Grading, 0, 100, 0, 1, 0, Plain;
|
||||
"grading.highlights.lum" => grading.highlights.lum, "Highlights Luminance", Grading, -100, 100, 0, 1, 0, Centered;
|
||||
"grading.global.hue" => grading.global.hue, "Global Hue", Grading, 0, 360, 0, 1, 0, Rainbow;
|
||||
"grading.global.sat" => grading.global.sat, "Global Saturation", Grading, 0, 100, 0, 1, 0, Plain;
|
||||
"grading.global.lum" => grading.global.lum, "Global Luminance", Grading, -100, 100, 0, 1, 0, Centered;
|
||||
"grading.blending" => grading.blending, "Blending", Grading, 0, 100, 50, 1, 0, Plain;
|
||||
"grading.balance" => grading.balance, "Balance", Grading, -100, 100, 0, 1, 0, Centered;
|
||||
"effects.texture" => effects.texture, "Texture", Effects, -100, 100, 0, 1, 0, Centered;
|
||||
"effects.clarity" => effects.clarity, "Clarity", Effects, -100, 100, 0, 1, 0, Centered;
|
||||
"effects.dehaze" => effects.dehaze, "Dehaze", Effects, -100, 100, 0, 1, 0, Centered;
|
||||
"vignette.amount" => vignette.amount, "Vignette", Vignette, -100, 100, 0, 1, 0, Gradient { from: "#101010", to: "#f0f0f0" };
|
||||
"vignette.midpoint" => vignette.midpoint, "Midpoint", Vignette, 0, 100, 50, 1, 0, Plain;
|
||||
"vignette.roundness" => vignette.roundness, "Roundness", Vignette, -100, 100, 0, 1, 0, Centered;
|
||||
"vignette.feather" => vignette.feather, "Feather", Vignette, 0, 100, 50, 1, 0, Plain;
|
||||
"vignette.highlights" => vignette.highlights, "Highlights", Vignette, 0, 100, 0, 1, 0, Plain;
|
||||
"grain.amount" => grain.amount, "Grain", Grain, 0, 100, 0, 1, 0, Plain;
|
||||
"grain.size" => grain.size, "Size", Grain, 0, 100, 25, 1, 0, Plain;
|
||||
"grain.roughness" => grain.roughness, "Roughness", Grain, 0, 100, 50, 1, 0, Plain;
|
||||
"detail.sharpenAmount" => detail.sharpen_amount, "Sharpening", Detail, 0, 150, 0, 1, 0, Plain;
|
||||
"detail.sharpenRadius" => detail.sharpen_radius, "Radius", Detail, 0.5, 3, 1, 0.1, 1, Plain;
|
||||
"detail.sharpenDetail" => detail.sharpen_detail, "Detail", Detail, 0, 100, 25, 1, 0, Plain;
|
||||
"detail.sharpenMasking" => detail.sharpen_masking, "Masking", Detail, 0, 100, 0, 1, 0, Plain;
|
||||
"detail.nrLuminance" => detail.nr_luminance, "Noise Reduction", Detail, 0, 100, 0, 1, 0, Plain;
|
||||
"detail.nrDetail" => detail.nr_detail, "Detail", Detail, 0, 100, 50, 1, 0, Plain;
|
||||
"detail.nrContrast" => detail.nr_contrast, "Contrast", Detail, 0, 100, 0, 1, 0, Plain;
|
||||
"detail.nrColor" => detail.nr_color, "Color Noise Reduction", Detail, 0, 100, 0, 1, 0, Plain;
|
||||
"detail.nrColorDetail" => detail.nr_color_detail, "Detail", Detail, 0, 100, 50, 1, 0, Plain;
|
||||
"detail.nrColorSmoothness" => detail.nr_color_smoothness, "Smoothness", Detail, 0, 100, 50, 1, 0, Plain;
|
||||
"optics.distortion" => optics.distortion, "Distortion", Optics, -100, 100, 0, 1, 0, Centered;
|
||||
"optics.vignetting" => optics.vignetting, "Vignetting", Optics, -100, 100, 0, 1, 0, Centered;
|
||||
"optics.vignettingMidpoint" => optics.vignetting_midpoint, "Midpoint", Optics, 0, 100, 50, 1, 0, Plain;
|
||||
"optics.profileDistortion" => optics.profile_distortion, "Profile Distortion", Optics, 0, 200, 100, 1, 0, Plain;
|
||||
"optics.profileVignetting" => optics.profile_vignetting, "Profile Vignetting", Optics, 0, 200, 100, 1, 0, Plain;
|
||||
"optics.defringePurple" => optics.defringe_purple_amount, "Purple Amount", Optics, 0, 20, 0, 1, 0, Gradient { from: "#7a7a7a", to: "#a040d0" };
|
||||
"optics.defringeGreen" => optics.defringe_green_amount, "Green Amount", Optics, 0, 20, 0, 1, 0, Gradient { from: "#7a7a7a", to: "#40c040" };
|
||||
"geometry.vertical" => geometry.vertical, "Vertical", Geometry, -100, 100, 0, 1, 0, Centered;
|
||||
"geometry.horizontal" => geometry.horizontal, "Horizontal", Geometry, -100, 100, 0, 1, 0, Centered;
|
||||
"geometry.rotate" => geometry.rotate, "Rotate", Geometry, -10, 10, 0, 0.1, 1, Centered;
|
||||
"geometry.aspect" => geometry.aspect, "Aspect", Geometry, -100, 100, 0, 1, 0, Centered;
|
||||
"geometry.scale" => geometry.scale, "Scale", Geometry, 50, 150, 100, 1, 0, Plain;
|
||||
"geometry.offsetX" => geometry.offset_x, "Offset X", Geometry, -100, 100, 0, 0.1, 1, Centered;
|
||||
"geometry.offsetY" => geometry.offset_y, "Offset Y", Geometry, -100, 100, 0, 0.1, 1, Centered;
|
||||
"crop.angle" => crop.geometry.angle, "Straighten", Geometry, -45, 45, 0, 0.01, 2, Centered;
|
||||
}
|
||||
|
||||
pub fn find(id: &str) -> Option<&'static ControlSpec> {
|
||||
CONTROLS.iter().find(|c| c.id == id)
|
||||
}
|
||||
|
||||
pub fn in_section(section: Section) -> impl Iterator<Item = &'static ControlSpec> {
|
||||
CONTROLS.iter().filter(move |c| c.section == section)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn every_control_reads_its_default() {
|
||||
let s = DevelopSettings::default();
|
||||
for c in CONTROLS {
|
||||
let v = get(&s, c.id).unwrap_or_else(|| panic!("no getter for {}", c.id));
|
||||
assert_eq!(v, c.default, "{}", c.id);
|
||||
assert!(c.min <= c.default && c.default <= c.max, "{}", c.id);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ids_unique_and_settable() {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut s = DevelopSettings::default();
|
||||
for c in CONTROLS {
|
||||
assert!(seen.insert(c.id), "duplicate {}", c.id);
|
||||
assert!(set(&mut s, c.id, c.max + 1000.0));
|
||||
assert_eq!(get(&s, c.id), Some(c.max), "{}", c.id);
|
||||
assert!(set(&mut s, c.id, f64::NAN));
|
||||
assert_eq!(get(&s, c.id), Some(c.default));
|
||||
}
|
||||
assert!(!set(&mut s, "nope", 1.0));
|
||||
assert_eq!(get(&s, "nope"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn formatting() {
|
||||
let e = find("light.exposure").unwrap();
|
||||
assert_eq!(e.format(0.5), "+0.50");
|
||||
assert_eq!(e.format(-1.0), "-1.00");
|
||||
assert_eq!(find("wb.temp").unwrap().format(5500.0), "5500");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
//! The non-destructive edit model of LightCraft.
|
||||
//!
|
||||
//! [`DevelopSettings`] is the complete description of a photo's look: pure, serializable data.
|
||||
//! The pipeline evaluates it; the catalog stores it; presets, copy/paste, sync, versions and history
|
||||
//! are operations on it. Numeric sliders are addressed by id through [`controls`].
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
pub mod controls;
|
||||
pub mod presets;
|
||||
pub mod settings;
|
||||
|
||||
pub use controls::{CONTROLS, ControlSpec, Section, Track};
|
||||
pub use presets::{Preset, SettingsGroup, apply_partial, extract_groups};
|
||||
pub use settings::*;
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
impl DevelopSettings {
|
||||
/// Settings for a freshly imported raw file (Lightroom applies capture sharpening and colour
|
||||
/// noise reduction by default to raw files).
|
||||
pub fn for_raw(as_shot_temp: f64, as_shot_tint: f64) -> DevelopSettings {
|
||||
let mut s = DevelopSettings::default();
|
||||
s.wb.temp = as_shot_temp;
|
||||
s.wb.tint = as_shot_tint;
|
||||
s.detail.sharpen_amount = 40.0;
|
||||
s.detail.nr_color = 25.0;
|
||||
s
|
||||
}
|
||||
|
||||
pub fn to_json(&self) -> Value {
|
||||
serde_json::to_value(self).unwrap_or(Value::Null)
|
||||
}
|
||||
|
||||
pub fn from_json(v: &Value) -> Result<DevelopSettings, serde_json::Error> {
|
||||
serde_json::from_value(v.clone())
|
||||
}
|
||||
|
||||
/// Merge a partial JSON object into these settings (fields not mentioned are kept).
|
||||
pub fn merged(&self, partial: &Value) -> Result<DevelopSettings, serde_json::Error> {
|
||||
let mut v = self.to_json();
|
||||
presets::deep_merge(&mut v, partial);
|
||||
DevelopSettings::from_json(&v)
|
||||
}
|
||||
|
||||
/// Stable 64-bit hash of the settings (FNV-1a over canonical JSON), for preview cache keys.
|
||||
pub fn hash64(&self) -> u64 {
|
||||
let s = serde_json::to_string(self).unwrap_or_default();
|
||||
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
|
||||
for b in s.as_bytes() {
|
||||
h ^= *b as u64;
|
||||
h = h.wrapping_mul(0x0100_0000_01b3);
|
||||
}
|
||||
h
|
||||
}
|
||||
|
||||
/// True if nothing differs from a fresh default (ignoring white balance "as shot" values).
|
||||
pub fn is_unedited(&self) -> bool {
|
||||
let mut a = self.clone();
|
||||
let d = DevelopSettings::default();
|
||||
a.wb = d.wb;
|
||||
a == d
|
||||
}
|
||||
|
||||
pub fn section_enabled(&self, section: &str) -> bool {
|
||||
!self.disabled_sections.iter().any(|s| s == section)
|
||||
}
|
||||
|
||||
pub fn set_section_enabled(&mut self, section: &str, on: bool) {
|
||||
self.disabled_sections.retain(|s| s != section);
|
||||
if !on {
|
||||
self.disabled_sections.push(section.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset every control in `section` to its default.
|
||||
pub fn reset_section(&mut self, section: Section) {
|
||||
for c in controls::in_section(section) {
|
||||
controls::set(self, c.id, c.default);
|
||||
}
|
||||
match section {
|
||||
Section::Curve => {
|
||||
let d = ToneCurve::default();
|
||||
self.curve = d;
|
||||
}
|
||||
Section::Color => self.wb.mode = WbMode::AsShot,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Next free mask id.
|
||||
pub fn next_mask_id(&self) -> u32 {
|
||||
self.masks.iter().map(|m| m.id).max().map_or(1, |m| m + 1)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn serde_roundtrip_default_and_edited() {
|
||||
let mut s = DevelopSettings::default();
|
||||
controls::set(&mut s, "light.exposure", 1.25);
|
||||
s.masks.push(Mask {
|
||||
id: 1,
|
||||
components: vec![MaskComponent {
|
||||
op: MaskOp::Add,
|
||||
invert: false,
|
||||
shape: MaskShape::Radial {
|
||||
center: lightcraft_geom::Point::new(0.5, 0.5),
|
||||
rx: 0.2,
|
||||
ry: 0.1,
|
||||
angle: 10.0,
|
||||
feather: 50.0,
|
||||
invert: false,
|
||||
},
|
||||
}],
|
||||
..Default::default()
|
||||
});
|
||||
let v = s.to_json();
|
||||
let back = DevelopSettings::from_json(&v).unwrap();
|
||||
assert_eq!(back, s);
|
||||
assert_ne!(back.hash64(), DevelopSettings::default().hash64());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_fields_take_defaults_and_unknown_ignored() {
|
||||
let s = DevelopSettings::from_json(&json!({"light": {"exposure": 0.5}, "futureThing": 3})).unwrap();
|
||||
assert_eq!(s.light.exposure, 0.5);
|
||||
assert_eq!(s.grading.blending, 50.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_partial() {
|
||||
let s = DevelopSettings::default();
|
||||
let m = s.merged(&json!({"light": {"contrast": 20}, "effects": {"clarity": 10}})).unwrap();
|
||||
assert_eq!(m.light.contrast, 20.0);
|
||||
assert_eq!(m.effects.clarity, 10.0);
|
||||
assert_eq!(m.light.exposure, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unedited_and_reset() {
|
||||
let mut s = DevelopSettings::default();
|
||||
s.wb.temp = 5000.0;
|
||||
assert!(s.is_unedited());
|
||||
s.light.exposure = 1.0;
|
||||
s.light.shadows = 30.0;
|
||||
assert!(!s.is_unedited());
|
||||
s.reset_section(Section::Light);
|
||||
assert!(s.is_unedited());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn section_toggles() {
|
||||
let mut s = DevelopSettings::default();
|
||||
assert!(s.section_enabled("effects"));
|
||||
s.set_section_enabled("effects", false);
|
||||
s.set_section_enabled("effects", false);
|
||||
assert!(!s.section_enabled("effects"));
|
||||
assert_eq!(s.disabled_sections.len(), 1);
|
||||
s.set_section_enabled("effects", true);
|
||||
assert!(s.section_enabled("effects"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
//! Presets and copy/paste subsets.
|
||||
//!
|
||||
//! A preset is a *partial* settings object (JSON) — only the groups it includes. Applying merges it
|
||||
//! into the photo's settings; `amount` (0..200 %) interpolates numeric values between the current
|
||||
//! settings and the preset's. Copy/paste/sync use the same group selection ([`SettingsGroup`]).
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::DevelopSettings;
|
||||
|
||||
/// Groups shown in "Choose settings to copy" / "Create preset" (Lightroom's checklist).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum SettingsGroup {
|
||||
Profile,
|
||||
Treatment,
|
||||
WhiteBalance,
|
||||
Light,
|
||||
ToneCurve,
|
||||
Color,
|
||||
ColorMixer,
|
||||
ColorGrading,
|
||||
Effects,
|
||||
Vignette,
|
||||
Grain,
|
||||
Detail,
|
||||
Optics,
|
||||
Geometry,
|
||||
Crop,
|
||||
Masks,
|
||||
Spots,
|
||||
RedEye,
|
||||
LensBlur,
|
||||
}
|
||||
|
||||
impl SettingsGroup {
|
||||
pub const ALL: [SettingsGroup; 19] = [
|
||||
SettingsGroup::Profile,
|
||||
SettingsGroup::Treatment,
|
||||
SettingsGroup::WhiteBalance,
|
||||
SettingsGroup::Light,
|
||||
SettingsGroup::ToneCurve,
|
||||
SettingsGroup::Color,
|
||||
SettingsGroup::ColorMixer,
|
||||
SettingsGroup::ColorGrading,
|
||||
SettingsGroup::Effects,
|
||||
SettingsGroup::Vignette,
|
||||
SettingsGroup::Grain,
|
||||
SettingsGroup::Detail,
|
||||
SettingsGroup::Optics,
|
||||
SettingsGroup::Geometry,
|
||||
SettingsGroup::Crop,
|
||||
SettingsGroup::Masks,
|
||||
SettingsGroup::Spots,
|
||||
SettingsGroup::RedEye,
|
||||
SettingsGroup::LensBlur,
|
||||
];
|
||||
|
||||
/// Groups included by default when copying (Lightroom excludes crop, masks and spots by default).
|
||||
pub fn default_copy() -> Vec<SettingsGroup> {
|
||||
SettingsGroup::ALL
|
||||
.into_iter()
|
||||
.filter(|g| !matches!(g, SettingsGroup::Crop | SettingsGroup::Masks | SettingsGroup::Spots | SettingsGroup::RedEye))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
SettingsGroup::Profile => "Profile",
|
||||
SettingsGroup::Treatment => "Treatment",
|
||||
SettingsGroup::WhiteBalance => "White Balance",
|
||||
SettingsGroup::Light => "Light",
|
||||
SettingsGroup::ToneCurve => "Tone Curve",
|
||||
SettingsGroup::Color => "Color",
|
||||
SettingsGroup::ColorMixer => "Color Mixer",
|
||||
SettingsGroup::ColorGrading => "Color Grading",
|
||||
SettingsGroup::Effects => "Effects",
|
||||
SettingsGroup::Vignette => "Vignette",
|
||||
SettingsGroup::Grain => "Grain",
|
||||
SettingsGroup::Detail => "Detail",
|
||||
SettingsGroup::Optics => "Optics",
|
||||
SettingsGroup::Geometry => "Geometry",
|
||||
SettingsGroup::Crop => "Crop",
|
||||
SettingsGroup::Masks => "Masking",
|
||||
SettingsGroup::Spots => "Remove",
|
||||
SettingsGroup::RedEye => "Red Eye",
|
||||
SettingsGroup::LensBlur => "Lens Blur",
|
||||
}
|
||||
}
|
||||
|
||||
/// Top-level JSON keys of `DevelopSettings` that belong to the group.
|
||||
pub fn keys(self) -> &'static [&'static str] {
|
||||
match self {
|
||||
SettingsGroup::Profile => &["profile"],
|
||||
SettingsGroup::Treatment => &["treatment"],
|
||||
SettingsGroup::WhiteBalance => &["wb"],
|
||||
SettingsGroup::Light => &["light"],
|
||||
SettingsGroup::ToneCurve => &["curve"],
|
||||
SettingsGroup::Color => &["color"],
|
||||
SettingsGroup::ColorMixer => &["mixer", "bw_mix"],
|
||||
SettingsGroup::ColorGrading => &["grading"],
|
||||
SettingsGroup::Effects => &["effects"],
|
||||
SettingsGroup::Vignette => &["vignette"],
|
||||
SettingsGroup::Grain => &["grain"],
|
||||
SettingsGroup::Detail => &["detail", "enhance"],
|
||||
SettingsGroup::Optics => &["optics"],
|
||||
SettingsGroup::Geometry => &["geometry"],
|
||||
SettingsGroup::Crop => &["crop", "orientation"],
|
||||
SettingsGroup::Masks => &["masks"],
|
||||
SettingsGroup::Spots => &["spots"],
|
||||
SettingsGroup::RedEye => &["red_eye"],
|
||||
SettingsGroup::LensBlur => &["lens_blur"],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The subset of `s` covering `groups`, as a partial JSON object.
|
||||
pub fn extract_groups(s: &DevelopSettings, groups: &[SettingsGroup]) -> Value {
|
||||
let full = s.to_json();
|
||||
let mut out = Map::new();
|
||||
for g in groups {
|
||||
for k in g.keys() {
|
||||
if let Some(v) = full.get(*k) {
|
||||
out.insert((*k).to_string(), v.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Object(out)
|
||||
}
|
||||
|
||||
/// Recursively merge `patch` into `base` (objects merge; everything else replaces).
|
||||
pub fn deep_merge(base: &mut Value, patch: &Value) {
|
||||
match (base, patch) {
|
||||
(Value::Object(b), Value::Object(p)) => {
|
||||
for (k, v) in p {
|
||||
match b.get_mut(k) {
|
||||
Some(bv) if bv.is_object() && v.is_object() => deep_merge(bv, v),
|
||||
_ => {
|
||||
b.insert(k.clone(), v.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
(b, p) => *b = p.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Interpolate numbers in `patch` towards/away from `base` by `t` (1 = patch as-is); non-numeric
|
||||
/// values are taken from the patch when `t >= 0.5`.
|
||||
fn scale_patch(base: &Value, patch: &Value, t: f64) -> Value {
|
||||
match (base, patch) {
|
||||
(Value::Object(b), Value::Object(p)) => {
|
||||
let mut out = Map::new();
|
||||
for (k, pv) in p {
|
||||
let bv = b.get(k).unwrap_or(&Value::Null);
|
||||
out.insert(k.clone(), scale_patch(bv, pv, t));
|
||||
}
|
||||
Value::Object(out)
|
||||
}
|
||||
(Value::Number(b), Value::Number(p)) => {
|
||||
let (b, p) = (b.as_f64().unwrap_or(0.0), p.as_f64().unwrap_or(0.0));
|
||||
serde_json::Number::from_f64(b + (p - b) * t).map(Value::Number).unwrap_or(Value::Null)
|
||||
}
|
||||
(b, p) => {
|
||||
if t >= 0.5 {
|
||||
p.clone()
|
||||
} else {
|
||||
b.clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply a partial settings object with an amount (1.0 = 100 %). Out-of-range values are clamped
|
||||
/// through the control specs.
|
||||
pub fn apply_partial(s: &DevelopSettings, partial: &Value, amount: f64) -> DevelopSettings {
|
||||
let base = s.to_json();
|
||||
let patch = if (amount - 1.0).abs() < 1e-9 { partial.clone() } else { scale_patch(&base, partial, amount) };
|
||||
let mut out = s.merged(&patch).unwrap_or_else(|_| s.clone());
|
||||
for c in crate::controls::CONTROLS {
|
||||
if let Some(v) = crate::controls::get(&out, c.id) {
|
||||
crate::controls::set(&mut out, c.id, v);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Preset {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub group: String,
|
||||
/// Partial settings.
|
||||
pub settings: Value,
|
||||
#[serde(default)]
|
||||
pub favorite: bool,
|
||||
/// Built-in presets ship with the app and can't be deleted.
|
||||
#[serde(default)]
|
||||
pub builtin: bool,
|
||||
}
|
||||
|
||||
impl Preset {
|
||||
pub fn from_settings(id: &str, name: &str, group: &str, s: &DevelopSettings, groups: &[SettingsGroup]) -> Preset {
|
||||
Preset { id: id.into(), name: name.into(), group: group.into(), settings: extract_groups(s, groups), favorite: false, builtin: false }
|
||||
}
|
||||
pub fn apply(&self, s: &DevelopSettings, amount: f64) -> DevelopSettings {
|
||||
apply_partial(s, &self.settings, amount)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn extract_and_apply_roundtrip() {
|
||||
let mut a = DevelopSettings::default();
|
||||
a.light.exposure = 1.0;
|
||||
a.effects.clarity = 30.0;
|
||||
a.crop.geometry.angle = 5.0;
|
||||
let p = Preset::from_settings("p", "P", "User", &a, &SettingsGroup::default_copy());
|
||||
assert!(p.settings.get("crop").is_none());
|
||||
let b = p.apply(&DevelopSettings::default(), 1.0);
|
||||
assert_eq!(b.light.exposure, 1.0);
|
||||
assert_eq!(b.effects.clarity, 30.0);
|
||||
assert_eq!(b.crop.geometry.angle, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn amount_scales_numbers() {
|
||||
let base = DevelopSettings::default();
|
||||
let partial = json!({"light": {"exposure": 1.0, "contrast": 40}});
|
||||
let half = apply_partial(&base, &partial, 0.5);
|
||||
assert!((half.light.exposure - 0.5).abs() < 1e-9);
|
||||
assert!((half.light.contrast - 20.0).abs() < 1e-9);
|
||||
let more = apply_partial(&base, &partial, 2.0);
|
||||
assert!((more.light.contrast - 80.0).abs() < 1e-9);
|
||||
// clamped through specs
|
||||
let lots = apply_partial(&base, &json!({"light": {"exposure": 4.0}}), 2.0);
|
||||
assert_eq!(lots.light.exposure, 5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deep_merge_objects() {
|
||||
let mut a = json!({"x": {"a": 1, "b": 2}, "y": [1]});
|
||||
deep_merge(&mut a, &json!({"x": {"b": 3}, "y": [2, 3]}));
|
||||
assert_eq!(a, json!({"x": {"a": 1, "b": 3}, "y": [2, 3]}));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,736 @@
|
||||
//! The develop settings schema. Every field has a neutral default; serde uses `#[serde(default)]` so
|
||||
//! older/newer files load (unknown fields are ignored, missing fields take defaults).
|
||||
|
||||
use lightcraft_geom::{CropGeometry, Orientation, Point};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
pub const SCHEMA_VERSION: u32 = 1;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct DevelopSettings {
|
||||
pub version: u32,
|
||||
pub profile: Profile,
|
||||
pub treatment: Treatment,
|
||||
pub wb: WhiteBalance,
|
||||
pub light: Light,
|
||||
pub curve: ToneCurve,
|
||||
pub color: ColorAdj,
|
||||
pub mixer: Mixer,
|
||||
pub bw_mix: BwMix,
|
||||
pub grading: ColorGrading,
|
||||
pub effects: Effects,
|
||||
pub vignette: Vignette,
|
||||
pub grain: Grain,
|
||||
pub detail: Detail,
|
||||
pub optics: Optics,
|
||||
pub geometry: Geometry,
|
||||
pub crop: Crop,
|
||||
pub orientation: Orientation,
|
||||
pub masks: Vec<Mask>,
|
||||
pub spots: Vec<Spot>,
|
||||
pub red_eye: Vec<RedEye>,
|
||||
pub lens_blur: LensBlur,
|
||||
pub enhance: Enhance,
|
||||
/// Section on/off toggles (the "eye" buttons on panel headers): section id → enabled.
|
||||
pub disabled_sections: Vec<String>,
|
||||
}
|
||||
|
||||
impl Default for DevelopSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
version: SCHEMA_VERSION,
|
||||
profile: Profile::default(),
|
||||
treatment: Treatment::Color,
|
||||
wb: WhiteBalance::default(),
|
||||
light: Light::default(),
|
||||
curve: ToneCurve::default(),
|
||||
color: ColorAdj::default(),
|
||||
mixer: Mixer::default(),
|
||||
bw_mix: BwMix::default(),
|
||||
grading: ColorGrading::default(),
|
||||
effects: Effects::default(),
|
||||
vignette: Vignette::default(),
|
||||
grain: Grain::default(),
|
||||
detail: Detail::default(),
|
||||
optics: Optics::default(),
|
||||
geometry: Geometry::default(),
|
||||
crop: Crop::default(),
|
||||
orientation: Orientation::Normal,
|
||||
masks: Vec::new(),
|
||||
spots: Vec::new(),
|
||||
red_eye: Vec::new(),
|
||||
lens_blur: LensBlur::default(),
|
||||
enhance: Enhance::default(),
|
||||
disabled_sections: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum Treatment {
|
||||
#[default]
|
||||
Color,
|
||||
Bw,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Profile {
|
||||
/// Profile id from the profile registry (e.g. `lc.color`, `lc.vivid`, `lc.mono`).
|
||||
pub id: String,
|
||||
/// Creative profile amount 0..200 (%).
|
||||
pub amount: f64,
|
||||
}
|
||||
|
||||
impl Default for Profile {
|
||||
fn default() -> Self {
|
||||
Self { id: "lc.color".into(), amount: 100.0 }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum WbMode {
|
||||
#[default]
|
||||
AsShot,
|
||||
Auto,
|
||||
Daylight,
|
||||
Cloudy,
|
||||
Shade,
|
||||
Tungsten,
|
||||
Fluorescent,
|
||||
Flash,
|
||||
Custom,
|
||||
}
|
||||
|
||||
impl WbMode {
|
||||
pub const ALL: [WbMode; 9] = [
|
||||
WbMode::AsShot,
|
||||
WbMode::Auto,
|
||||
WbMode::Daylight,
|
||||
WbMode::Cloudy,
|
||||
WbMode::Shade,
|
||||
WbMode::Tungsten,
|
||||
WbMode::Fluorescent,
|
||||
WbMode::Flash,
|
||||
WbMode::Custom,
|
||||
];
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
WbMode::AsShot => "As Shot",
|
||||
WbMode::Auto => "Auto",
|
||||
WbMode::Daylight => "Daylight",
|
||||
WbMode::Cloudy => "Cloudy",
|
||||
WbMode::Shade => "Shade",
|
||||
WbMode::Tungsten => "Tungsten",
|
||||
WbMode::Fluorescent => "Fluorescent",
|
||||
WbMode::Flash => "Flash",
|
||||
WbMode::Custom => "Custom",
|
||||
}
|
||||
}
|
||||
/// Preset temperature/tint for raw files (K, tint).
|
||||
pub fn preset(self) -> Option<(f64, f64)> {
|
||||
match self {
|
||||
WbMode::Daylight => Some((5500.0, 10.0)),
|
||||
WbMode::Cloudy => Some((6500.0, 10.0)),
|
||||
WbMode::Shade => Some((7500.0, 10.0)),
|
||||
WbMode::Tungsten => Some((2850.0, 0.0)),
|
||||
WbMode::Fluorescent => Some((3800.0, 21.0)),
|
||||
WbMode::Flash => Some((5500.0, 0.0)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// White balance. For raw files `temp` is the scene illuminant in Kelvin; for rendered files
|
||||
/// (JPEG etc.) the image is assumed D65-balanced and `temp`/`tint` are the same model with an
|
||||
/// "as shot" of 6500 K / 0 (the UI shows a relative −100..100 scale for those, like Lightroom).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct WhiteBalance {
|
||||
pub mode: WbMode,
|
||||
pub temp: f64,
|
||||
pub tint: f64,
|
||||
}
|
||||
|
||||
impl Default for WhiteBalance {
|
||||
fn default() -> Self {
|
||||
Self { mode: WbMode::AsShot, temp: 6500.0, tint: 0.0 }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Light {
|
||||
pub exposure: f64,
|
||||
pub contrast: f64,
|
||||
pub highlights: f64,
|
||||
pub shadows: f64,
|
||||
pub whites: f64,
|
||||
pub blacks: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct ToneCurve {
|
||||
pub highlights: f64,
|
||||
pub lights: f64,
|
||||
pub darks: f64,
|
||||
pub shadows: f64,
|
||||
/// Region split points (percent), Lightroom defaults 25/50/75.
|
||||
pub split_shadows: f64,
|
||||
pub split_mid: f64,
|
||||
pub split_highlights: f64,
|
||||
/// Point curves in 0..1 (identity = empty or [(0,0),(1,1)]).
|
||||
pub master: Vec<Point>,
|
||||
pub red: Vec<Point>,
|
||||
pub green: Vec<Point>,
|
||||
pub blue: Vec<Point>,
|
||||
}
|
||||
|
||||
impl Default for ToneCurve {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
highlights: 0.0,
|
||||
lights: 0.0,
|
||||
darks: 0.0,
|
||||
shadows: 0.0,
|
||||
split_shadows: 25.0,
|
||||
split_mid: 50.0,
|
||||
split_highlights: 75.0,
|
||||
master: Vec::new(),
|
||||
red: Vec::new(),
|
||||
green: Vec::new(),
|
||||
blue: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ToneCurve {
|
||||
pub fn point_curve_is_identity(pts: &[Point]) -> bool {
|
||||
pts.is_empty() || pts.iter().all(|p| (p.x - p.y).abs() < 1e-9)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct ColorAdj {
|
||||
pub vibrance: f64,
|
||||
pub saturation: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Hsl {
|
||||
pub hue: f64,
|
||||
pub sat: f64,
|
||||
pub lum: f64,
|
||||
}
|
||||
|
||||
/// The 8 colour-mixer bands in Lightroom order.
|
||||
pub const MIXER_BANDS: [&str; 8] = ["red", "orange", "yellow", "green", "aqua", "blue", "purple", "magenta"];
|
||||
/// Centre hue of each band in degrees (sRGB-ish HSV hue).
|
||||
pub const MIXER_HUES: [f64; 8] = [0.0, 30.0, 60.0, 120.0, 180.0, 225.0, 270.0, 315.0];
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Mixer {
|
||||
pub red: Hsl,
|
||||
pub orange: Hsl,
|
||||
pub yellow: Hsl,
|
||||
pub green: Hsl,
|
||||
pub aqua: Hsl,
|
||||
pub blue: Hsl,
|
||||
pub purple: Hsl,
|
||||
pub magenta: Hsl,
|
||||
}
|
||||
|
||||
impl Mixer {
|
||||
pub fn bands(&self) -> [Hsl; 8] {
|
||||
[self.red, self.orange, self.yellow, self.green, self.aqua, self.blue, self.purple, self.magenta]
|
||||
}
|
||||
pub fn is_neutral(&self) -> bool {
|
||||
self.bands().iter().all(|b| *b == Hsl::default())
|
||||
}
|
||||
}
|
||||
|
||||
/// B&W mix (used when treatment = Bw): per-band luminance contribution −100..100.
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct BwMix {
|
||||
pub red: f64,
|
||||
pub orange: f64,
|
||||
pub yellow: f64,
|
||||
pub green: f64,
|
||||
pub aqua: f64,
|
||||
pub blue: f64,
|
||||
pub purple: f64,
|
||||
pub magenta: f64,
|
||||
}
|
||||
|
||||
impl BwMix {
|
||||
pub fn bands(&self) -> [f64; 8] {
|
||||
[self.red, self.orange, self.yellow, self.green, self.aqua, self.blue, self.purple, self.magenta]
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Wheel {
|
||||
/// Hue in degrees 0..360.
|
||||
pub hue: f64,
|
||||
/// Saturation 0..100.
|
||||
pub sat: f64,
|
||||
/// Luminance −100..100.
|
||||
pub lum: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct ColorGrading {
|
||||
pub shadows: Wheel,
|
||||
pub midtones: Wheel,
|
||||
pub highlights: Wheel,
|
||||
pub global: Wheel,
|
||||
/// 0..100, default 50.
|
||||
pub blending: f64,
|
||||
/// −100..100.
|
||||
pub balance: f64,
|
||||
}
|
||||
|
||||
impl Default for ColorGrading {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
shadows: Wheel::default(),
|
||||
midtones: Wheel::default(),
|
||||
highlights: Wheel::default(),
|
||||
global: Wheel::default(),
|
||||
blending: 50.0,
|
||||
balance: 0.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ColorGrading {
|
||||
pub fn is_neutral(&self) -> bool {
|
||||
[self.shadows, self.midtones, self.highlights, self.global].iter().all(|w| w.sat == 0.0 && w.lum == 0.0)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Effects {
|
||||
pub texture: f64,
|
||||
pub clarity: f64,
|
||||
pub dehaze: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum VignetteStyle {
|
||||
#[default]
|
||||
HighlightPriority,
|
||||
ColorPriority,
|
||||
PaintOverlay,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Vignette {
|
||||
pub amount: f64,
|
||||
pub midpoint: f64,
|
||||
pub roundness: f64,
|
||||
pub feather: f64,
|
||||
pub highlights: f64,
|
||||
pub style: VignetteStyle,
|
||||
}
|
||||
|
||||
impl Default for Vignette {
|
||||
fn default() -> Self {
|
||||
Self { amount: 0.0, midpoint: 50.0, roundness: 0.0, feather: 50.0, highlights: 0.0, style: VignetteStyle::HighlightPriority }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Grain {
|
||||
pub amount: f64,
|
||||
pub size: f64,
|
||||
pub roughness: f64,
|
||||
/// Seed for the procedural grain (stable per photo).
|
||||
pub seed: u32,
|
||||
}
|
||||
|
||||
impl Default for Grain {
|
||||
fn default() -> Self {
|
||||
Self { amount: 0.0, size: 25.0, roughness: 50.0, seed: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Detail {
|
||||
pub sharpen_amount: f64,
|
||||
pub sharpen_radius: f64,
|
||||
pub sharpen_detail: f64,
|
||||
pub sharpen_masking: f64,
|
||||
pub nr_luminance: f64,
|
||||
pub nr_detail: f64,
|
||||
pub nr_contrast: f64,
|
||||
pub nr_color: f64,
|
||||
pub nr_color_detail: f64,
|
||||
pub nr_color_smoothness: f64,
|
||||
}
|
||||
|
||||
impl Default for Detail {
|
||||
/// Lightroom's raw defaults are sharpening 40/1.0/25/0 and colour NR 25/50/50; we use neutral
|
||||
/// values for rendered files and apply raw defaults at import time (`DevelopSettings::for_raw`).
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
sharpen_amount: 0.0,
|
||||
sharpen_radius: 1.0,
|
||||
sharpen_detail: 25.0,
|
||||
sharpen_masking: 0.0,
|
||||
nr_luminance: 0.0,
|
||||
nr_detail: 50.0,
|
||||
nr_contrast: 0.0,
|
||||
nr_color: 0.0,
|
||||
nr_color_detail: 50.0,
|
||||
nr_color_smoothness: 50.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Optics {
|
||||
pub remove_ca: bool,
|
||||
pub lens_profile: bool,
|
||||
pub profile_distortion: f64,
|
||||
pub profile_vignetting: f64,
|
||||
pub defringe_purple_amount: f64,
|
||||
pub defringe_purple_hue_lo: f64,
|
||||
pub defringe_purple_hue_hi: f64,
|
||||
pub defringe_green_amount: f64,
|
||||
pub defringe_green_hue_lo: f64,
|
||||
pub defringe_green_hue_hi: f64,
|
||||
pub distortion: f64,
|
||||
pub vignetting: f64,
|
||||
pub vignetting_midpoint: f64,
|
||||
}
|
||||
|
||||
impl Default for Optics {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
remove_ca: false,
|
||||
lens_profile: false,
|
||||
profile_distortion: 100.0,
|
||||
profile_vignetting: 100.0,
|
||||
defringe_purple_amount: 0.0,
|
||||
defringe_purple_hue_lo: 30.0,
|
||||
defringe_purple_hue_hi: 70.0,
|
||||
defringe_green_amount: 0.0,
|
||||
defringe_green_hue_lo: 40.0,
|
||||
defringe_green_hue_hi: 60.0,
|
||||
distortion: 0.0,
|
||||
vignetting: 0.0,
|
||||
vignetting_midpoint: 50.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Upright {
|
||||
#[default]
|
||||
Off,
|
||||
Auto,
|
||||
Guided,
|
||||
Level,
|
||||
Vertical,
|
||||
Full,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Geometry {
|
||||
pub upright: Upright,
|
||||
/// Guided Upright guides (normalized image coords), up to 4 line segments.
|
||||
pub guides: Vec<(Point, Point)>,
|
||||
pub vertical: f64,
|
||||
pub horizontal: f64,
|
||||
pub rotate: f64,
|
||||
pub aspect: f64,
|
||||
pub scale: f64,
|
||||
pub offset_x: f64,
|
||||
pub offset_y: f64,
|
||||
pub constrain_crop: bool,
|
||||
}
|
||||
|
||||
impl Default for Geometry {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
upright: Upright::Off,
|
||||
guides: Vec::new(),
|
||||
vertical: 0.0,
|
||||
horizontal: 0.0,
|
||||
rotate: 0.0,
|
||||
aspect: 0.0,
|
||||
scale: 100.0,
|
||||
offset_x: 0.0,
|
||||
offset_y: 0.0,
|
||||
constrain_crop: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Geometry {
|
||||
pub fn is_identity(&self) -> bool {
|
||||
self.upright == Upright::Off
|
||||
&& self.vertical == 0.0
|
||||
&& self.horizontal == 0.0
|
||||
&& self.rotate == 0.0
|
||||
&& self.aspect == 0.0
|
||||
&& self.scale == 100.0
|
||||
&& self.offset_x == 0.0
|
||||
&& self.offset_y == 0.0
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Crop {
|
||||
pub geometry: CropGeometry,
|
||||
/// Locked aspect (w, h); None = free / original.
|
||||
pub aspect: Option<(u32, u32)>,
|
||||
pub flip_h: bool,
|
||||
pub flip_v: bool,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Masks and local adjustments
|
||||
|
||||
/// The sliders available inside a mask (Lightroom's local adjustments).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct LocalAdjustments {
|
||||
pub temp: f64,
|
||||
pub tint: f64,
|
||||
pub exposure: f64,
|
||||
pub contrast: f64,
|
||||
pub highlights: f64,
|
||||
pub shadows: f64,
|
||||
pub whites: f64,
|
||||
pub blacks: f64,
|
||||
pub texture: f64,
|
||||
pub clarity: f64,
|
||||
pub dehaze: f64,
|
||||
pub hue: f64,
|
||||
pub saturation: f64,
|
||||
pub sharpness: f64,
|
||||
pub noise: f64,
|
||||
pub moire: f64,
|
||||
pub defringe: f64,
|
||||
/// Colour overlay (hue degrees, saturation 0..100); sat 0 = none.
|
||||
pub color_hue: f64,
|
||||
pub color_sat: f64,
|
||||
/// Local vignette/grain are not in Lightroom; we keep the struct extensible.
|
||||
pub amount: f64,
|
||||
}
|
||||
|
||||
impl Default for LocalAdjustments {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
temp: 0.0,
|
||||
tint: 0.0,
|
||||
exposure: 0.0,
|
||||
contrast: 0.0,
|
||||
highlights: 0.0,
|
||||
shadows: 0.0,
|
||||
whites: 0.0,
|
||||
blacks: 0.0,
|
||||
texture: 0.0,
|
||||
clarity: 0.0,
|
||||
dehaze: 0.0,
|
||||
hue: 0.0,
|
||||
saturation: 0.0,
|
||||
sharpness: 0.0,
|
||||
noise: 0.0,
|
||||
moire: 0.0,
|
||||
defringe: 0.0,
|
||||
color_hue: 0.0,
|
||||
color_sat: 0.0,
|
||||
amount: 100.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum MaskOp {
|
||||
#[default]
|
||||
Add,
|
||||
Subtract,
|
||||
Intersect,
|
||||
}
|
||||
|
||||
/// One brush dab sequence. Coordinates normalized to the uncropped oriented image.
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct BrushStroke {
|
||||
pub points: Vec<Point>,
|
||||
/// Radius as a fraction of the image's long edge.
|
||||
pub size: f64,
|
||||
/// 0..100.
|
||||
pub feather: f64,
|
||||
pub flow: f64,
|
||||
pub density: f64,
|
||||
pub erase: bool,
|
||||
pub auto_mask: bool,
|
||||
}
|
||||
|
||||
impl Default for BrushStroke {
|
||||
fn default() -> Self {
|
||||
Self { points: Vec::new(), size: 0.03, feather: 50.0, flow: 100.0, density: 100.0, erase: false, auto_mask: false }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "kind", rename_all = "camelCase")]
|
||||
pub enum MaskShape {
|
||||
Brush {
|
||||
strokes: Vec<BrushStroke>,
|
||||
},
|
||||
/// Linear gradient: full effect at `start`, fading to none at `end` (normalized coords).
|
||||
Linear {
|
||||
start: Point,
|
||||
end: Point,
|
||||
},
|
||||
/// Radial gradient: ellipse centre, radii (normalized to the long edge), rotation (deg), feather 0..100.
|
||||
Radial {
|
||||
center: Point,
|
||||
rx: f64,
|
||||
ry: f64,
|
||||
angle: f64,
|
||||
feather: f64,
|
||||
invert: bool,
|
||||
},
|
||||
/// Colour range: sampled OkLab colours with a refine amount 0..100.
|
||||
ColorRange {
|
||||
samples: Vec<[f64; 3]>,
|
||||
refine: f64,
|
||||
},
|
||||
/// Luminance range (0..1 bounds with feathered falloff 0..1).
|
||||
LuminanceRange {
|
||||
lo: f64,
|
||||
hi: f64,
|
||||
lo_feather: f64,
|
||||
hi_feather: f64,
|
||||
},
|
||||
DepthRange {
|
||||
lo: f64,
|
||||
hi: f64,
|
||||
feather: f64,
|
||||
},
|
||||
/// AI / automatic selections, evaluated by a segmenter.
|
||||
Subject,
|
||||
Sky,
|
||||
Background,
|
||||
Object {
|
||||
hint: Vec<Point>,
|
||||
},
|
||||
People {
|
||||
person: u32,
|
||||
parts: Vec<String>,
|
||||
},
|
||||
Landscape {
|
||||
class: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct MaskComponent {
|
||||
#[serde(default)]
|
||||
pub op: MaskOp,
|
||||
#[serde(default)]
|
||||
pub invert: bool,
|
||||
pub shape: MaskShape,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Mask {
|
||||
pub id: u32,
|
||||
pub name: String,
|
||||
pub visible: bool,
|
||||
pub invert: bool,
|
||||
pub components: Vec<MaskComponent>,
|
||||
pub adjust: LocalAdjustments,
|
||||
}
|
||||
|
||||
impl Default for Mask {
|
||||
fn default() -> Self {
|
||||
Self { id: 0, name: "Mask 1".into(), visible: true, invert: false, components: Vec::new(), adjust: LocalAdjustments::default() }
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Heal / remove / red eye
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum SpotMode {
|
||||
#[default]
|
||||
Remove,
|
||||
Heal,
|
||||
Clone,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Spot {
|
||||
pub mode: SpotMode,
|
||||
/// Target: a single point (circle spot) or a brushed path. Normalized coords.
|
||||
pub points: Vec<Point>,
|
||||
/// Radius as a fraction of the long edge.
|
||||
pub size: f64,
|
||||
pub feather: f64,
|
||||
pub opacity: f64,
|
||||
/// Source offset (normalized) from the target; None = auto (content-aware remove).
|
||||
pub source_offset: Option<Point>,
|
||||
}
|
||||
|
||||
impl Default for Spot {
|
||||
fn default() -> Self {
|
||||
Self { mode: SpotMode::Remove, points: Vec::new(), size: 0.02, feather: 50.0, opacity: 100.0, source_offset: None }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct RedEye {
|
||||
pub center: Point,
|
||||
pub rx: f64,
|
||||
pub ry: f64,
|
||||
pub pupil_size: f64,
|
||||
pub darken: f64,
|
||||
pub pet: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct LensBlur {
|
||||
pub enabled: bool,
|
||||
pub amount: f64,
|
||||
pub focal_distance: f64,
|
||||
pub focal_range: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(default)]
|
||||
pub struct Enhance {
|
||||
pub denoise: f64,
|
||||
pub raw_details: bool,
|
||||
pub super_resolution: bool,
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "lightcraft-scenes"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "Procedurally generated photographic demo scenes (HDR, linear Rec.2020) for LightCraft demos and tests (L1)"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
lightcraft-color = { workspace = true }
|
||||
lightcraft-raster = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
rayon = { workspace = true }
|
||||
|
||||
[dev-dependencies]
|
||||
image = { workspace = true }
|
||||
@@ -0,0 +1,29 @@
|
||||
//! Render every demo scene into a contact sheet PNG (simple clamp + sRGB encode, no develop).
|
||||
//! `cargo run --release -p lightcraft-scenes --example contact_sheet -- out.png`
|
||||
use lightcraft_color::{REC2020, SRGB, transfer::encode_srgb8};
|
||||
|
||||
fn main() {
|
||||
let out = std::env::args().nth(1).unwrap_or_else(|| "contact.png".into());
|
||||
let lib = lightcraft_scenes::demo_library();
|
||||
let (cw, ch, cols) = (360usize, 240usize, 6usize);
|
||||
let rows = lib.len().div_ceil(cols);
|
||||
let mut sheet = image::RgbImage::new((cw * cols) as u32, (ch * rows) as u32);
|
||||
let m = REC2020.to_space(&SRGB);
|
||||
for (i, s) in lib.iter().enumerate() {
|
||||
let img = s.render_fit(cw.min(ch * 3 / 2).max(ch));
|
||||
let (ox, oy) = ((i % cols) * cw + (cw - img.width.min(cw)) / 2, (i / cols) * ch + (ch - img.height.min(ch)) / 2);
|
||||
for y in 0..img.height.min(ch) {
|
||||
for x in 0..img.width.min(cw) {
|
||||
let c = m.apply_f32(img.get(x, y));
|
||||
// simple Reinhard-ish shoulder for preview
|
||||
let t = |v: f32| {
|
||||
let v = v.max(0.0);
|
||||
encode_srgb8(v / (1.0 + v * 0.35))
|
||||
};
|
||||
sheet.put_pixel((ox + x) as u32, (oy + y) as u32, image::Rgb([t(c[0]), t(c[1]), t(c[2])]));
|
||||
}
|
||||
}
|
||||
}
|
||||
sheet.save(&out).unwrap();
|
||||
println!("{out}");
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
//! Procedurally generated, photographic-looking demo scenes.
|
||||
//!
|
||||
//! Scenes are resolution independent (`render(w, h)` evaluates a continuous function) and return
|
||||
//! scene-referred linear Rec.2020 radiance with real highlight headroom (the sun is ≫ 1.0), so the
|
||||
//! develop pipeline's highlight recovery, tone mapping and colour tools have something real to do.
|
||||
//! Every scene is deterministic for its seed. No external assets: zero licensing or privacy risk.
|
||||
#![forbid(unsafe_code)]
|
||||
|
||||
mod noise;
|
||||
mod paint;
|
||||
|
||||
use lightcraft_raster::Rgb32f;
|
||||
use serde::Serialize;
|
||||
|
||||
pub use paint::render_srgb_linear;
|
||||
|
||||
/// Kinds of scene the generator knows.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum Kind {
|
||||
AlpineLake,
|
||||
Dunes,
|
||||
OceanSunset,
|
||||
Aurora,
|
||||
MistyForest,
|
||||
Lavender,
|
||||
Macro,
|
||||
Canyon,
|
||||
Beach,
|
||||
BlueHour,
|
||||
}
|
||||
|
||||
/// Fake capture metadata so the library looks like a real shoot.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct SceneMeta {
|
||||
pub camera: &'static str,
|
||||
pub lens: &'static str,
|
||||
pub focal_mm: f32,
|
||||
pub aperture: f32,
|
||||
pub shutter: &'static str,
|
||||
pub iso: u32,
|
||||
/// ISO 8601 local time.
|
||||
pub captured: String,
|
||||
pub location: &'static str,
|
||||
pub keywords: Vec<&'static str>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct Scene {
|
||||
pub id: u32,
|
||||
pub name: String,
|
||||
pub kind: Kind,
|
||||
pub seed: u32,
|
||||
/// Native pixel size (what an import would report).
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub meta: SceneMeta,
|
||||
}
|
||||
|
||||
impl Scene {
|
||||
pub fn aspect(&self) -> f32 {
|
||||
self.width as f32 / self.height as f32
|
||||
}
|
||||
|
||||
/// Render at `w × h` (any size; the aspect should match `aspect()`).
|
||||
pub fn render(&self, w: usize, h: usize) -> Rgb32f {
|
||||
paint::render(self.kind, self.seed, w, h)
|
||||
}
|
||||
|
||||
/// Render fitting within `max_edge` on the long side.
|
||||
pub fn render_fit(&self, max_edge: usize) -> Rgb32f {
|
||||
let a = self.aspect();
|
||||
let (w, h) = if a >= 1.0 { (max_edge, (max_edge as f32 / a).round() as usize) } else { ((max_edge as f32 * a).round() as usize, max_edge) };
|
||||
self.render(w.max(1), h.max(1))
|
||||
}
|
||||
}
|
||||
|
||||
struct Def {
|
||||
kind: Kind,
|
||||
name: &'static str,
|
||||
seed: u32,
|
||||
portrait: bool,
|
||||
location: &'static str,
|
||||
keywords: &'static [&'static str],
|
||||
}
|
||||
|
||||
const DEFS: &[Def] = &[
|
||||
Def {
|
||||
kind: Kind::AlpineLake,
|
||||
name: "Lake at first light",
|
||||
seed: 11,
|
||||
portrait: false,
|
||||
location: "Dolomites",
|
||||
keywords: &["mountains", "lake", "sunrise"],
|
||||
},
|
||||
Def { kind: Kind::Dunes, name: "Dune ridges", seed: 7, portrait: false, location: "Namib", keywords: &["desert", "dunes"] },
|
||||
Def { kind: Kind::OceanSunset, name: "Last light over the sea", seed: 3, portrait: false, location: "Big Sur", keywords: &["ocean", "sunset"] },
|
||||
Def {
|
||||
kind: Kind::Aurora,
|
||||
name: "Aurora over the ridge",
|
||||
seed: 21,
|
||||
portrait: false,
|
||||
location: "Lofoten",
|
||||
keywords: &["night", "aurora", "stars"],
|
||||
},
|
||||
Def { kind: Kind::MistyForest, name: "Morning fog", seed: 5, portrait: false, location: "Black Forest", keywords: &["forest", "fog"] },
|
||||
Def { kind: Kind::Lavender, name: "Lavender rows", seed: 9, portrait: false, location: "Valensole", keywords: &["fields", "lavender", "summer"] },
|
||||
Def { kind: Kind::Macro, name: "Cosmos bloom", seed: 13, portrait: true, location: "Home garden", keywords: &["flower", "macro", "bokeh"] },
|
||||
Def { kind: Kind::Canyon, name: "Canyon walls", seed: 17, portrait: false, location: "Utah", keywords: &["canyon", "rock"] },
|
||||
Def { kind: Kind::Beach, name: "Lagoon", seed: 19, portrait: false, location: "Maldives", keywords: &["beach", "tropical"] },
|
||||
Def { kind: Kind::BlueHour, name: "Blue hour peaks", seed: 23, portrait: false, location: "Patagonia", keywords: &["mountains", "blue hour"] },
|
||||
Def { kind: Kind::AlpineLake, name: "Still water", seed: 41, portrait: false, location: "Banff", keywords: &["mountains", "lake"] },
|
||||
Def { kind: Kind::OceanSunset, name: "Golden horizon", seed: 44, portrait: false, location: "Algarve", keywords: &["ocean", "sunset"] },
|
||||
Def { kind: Kind::Dunes, name: "Afternoon sand", seed: 57, portrait: true, location: "Sahara", keywords: &["desert"] },
|
||||
Def { kind: Kind::Aurora, name: "Green curtains", seed: 60, portrait: false, location: "Yukon", keywords: &["night", "aurora"] },
|
||||
Def { kind: Kind::Macro, name: "Pink petals", seed: 71, portrait: false, location: "Home garden", keywords: &["flower", "macro"] },
|
||||
Def { kind: Kind::MistyForest, name: "Valley haze", seed: 73, portrait: false, location: "Oregon", keywords: &["forest", "fog"] },
|
||||
Def { kind: Kind::Canyon, name: "Red layers", seed: 79, portrait: true, location: "Arizona", keywords: &["canyon"] },
|
||||
Def { kind: Kind::Beach, name: "Turquoise shallows", seed: 83, portrait: false, location: "Bahamas", keywords: &["beach"] },
|
||||
Def { kind: Kind::Lavender, name: "Evening fields", seed: 89, portrait: false, location: "Provence", keywords: &["fields"] },
|
||||
Def { kind: Kind::BlueHour, name: "Cold summit", seed: 97, portrait: false, location: "Alps", keywords: &["mountains", "snow"] },
|
||||
Def { kind: Kind::AlpineLake, name: "Alpenglow", seed: 101, portrait: true, location: "Zermatt", keywords: &["mountains"] },
|
||||
Def { kind: Kind::OceanSunset, name: "Calm sea", seed: 107, portrait: false, location: "Crete", keywords: &["ocean"] },
|
||||
Def { kind: Kind::Macro, name: "Violet bloom", seed: 113, portrait: false, location: "Kew", keywords: &["flower"] },
|
||||
Def { kind: Kind::Dunes, name: "Wind lines", seed: 127, portrait: false, location: "Death Valley", keywords: &["desert"] },
|
||||
];
|
||||
|
||||
const CAMERAS: &[(&str, &str, f32)] = &[
|
||||
("Synthetic S1", "24-70mm f/2.8", 35.0),
|
||||
("Synthetic S1", "70-200mm f/4", 135.0),
|
||||
("Synthetic X2", "16-35mm f/4", 18.0),
|
||||
("Synthetic X2", "90mm f/2.8 Macro", 90.0),
|
||||
];
|
||||
|
||||
/// The demo library: 24 scenes, 6000×4000 (or 4000×6000) nominal size.
|
||||
pub fn demo_library() -> Vec<Scene> {
|
||||
DEFS.iter()
|
||||
.enumerate()
|
||||
.map(|(i, d)| {
|
||||
let cam = if d.kind == Kind::Macro { CAMERAS[3] } else { CAMERAS[i % 3] };
|
||||
let shutter = match d.kind {
|
||||
Kind::Aurora => "8",
|
||||
Kind::BlueHour => "1/4",
|
||||
Kind::MistyForest => "1/60",
|
||||
_ => ["1/250", "1/500", "1/125", "1/1000"][i % 4],
|
||||
};
|
||||
let iso = match d.kind {
|
||||
Kind::Aurora => 3200,
|
||||
Kind::BlueHour => 400,
|
||||
_ => [100, 200, 64, 100][i % 4],
|
||||
};
|
||||
let (w, h) = if d.portrait { (4000, 6000) } else { (6000, 4000) };
|
||||
Scene {
|
||||
id: i as u32 + 1,
|
||||
name: d.name.to_string(),
|
||||
kind: d.kind,
|
||||
seed: d.seed,
|
||||
width: w,
|
||||
height: h,
|
||||
meta: SceneMeta {
|
||||
camera: cam.0,
|
||||
lens: cam.1,
|
||||
focal_mm: cam.2,
|
||||
aperture: [2.8, 8.0, 11.0, 4.0][i % 4],
|
||||
shutter,
|
||||
iso,
|
||||
captured: format!("2026-{:02}-{:02}T{:02}:{:02}:00", 3 + i % 7, 1 + (i * 5) % 27, 5 + (i * 3) % 15, (i * 17) % 60),
|
||||
location: d.location,
|
||||
keywords: d.keywords.to_vec(),
|
||||
},
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn library_is_deterministic_and_varied() {
|
||||
let lib = demo_library();
|
||||
assert_eq!(lib.len(), 24);
|
||||
let a = lib[0].render(64, 43);
|
||||
let b = lib[0].render(64, 43);
|
||||
assert_eq!(a, b);
|
||||
// every scene renders finite, non-negative, non-trivial images
|
||||
for s in &lib {
|
||||
let img = s.render_fit(48);
|
||||
assert!(img.data.iter().all(|p| p.iter().all(|v| v.is_finite() && *v >= 0.0)), "{}", s.name);
|
||||
let mean: f32 = img.data.iter().map(|p| p[1]).sum::<f32>() / img.len() as f32;
|
||||
assert!(mean > 0.005 && mean < 3.0, "{} mean {mean}", s.name);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sunsets_have_highlight_headroom() {
|
||||
let lib = demo_library();
|
||||
let s = lib.iter().find(|s| s.kind == Kind::OceanSunset).unwrap();
|
||||
let img = s.render_fit(200);
|
||||
let max = img.data.iter().map(|p| p[0]).fold(0.0f32, f32::max);
|
||||
assert!(max > 2.0, "{max}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolution_independent() {
|
||||
let s = &demo_library()[1];
|
||||
let small = s.render(60, 40);
|
||||
let big = s.render(240, 160);
|
||||
let down = lightcraft_raster::resample::resize(&big, 60, 40, lightcraft_raster::resample::Filter::Box);
|
||||
let err: f32 = small.data.iter().zip(&down.data).map(|(a, b)| (a[1] - b[1]).abs()).sum::<f32>() / small.len() as f32;
|
||||
assert!(err < 0.08, "{err}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
//! Deterministic gradient noise and fractal sums.
|
||||
|
||||
#[inline]
|
||||
pub fn hash(x: i32, y: i32, seed: u32) -> u32 {
|
||||
let mut h = (x as u32).wrapping_mul(0x8da6_b343) ^ (y as u32).wrapping_mul(0xd816_3841) ^ seed.wrapping_mul(0xcb1a_b31f);
|
||||
h ^= h >> 13;
|
||||
h = h.wrapping_mul(0x5bd1_e995);
|
||||
h ^= h >> 15;
|
||||
h
|
||||
}
|
||||
|
||||
/// Uniform in 0..1.
|
||||
#[inline]
|
||||
pub fn rand01(x: i32, y: i32, seed: u32) -> f32 {
|
||||
(hash(x, y, seed) & 0x00ff_ffff) as f32 / 16_777_216.0
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn grad(ix: i32, iy: i32, seed: u32, dx: f32, dy: f32) -> f32 {
|
||||
let a = rand01(ix, iy, seed) * std::f32::consts::TAU;
|
||||
let (s, c) = a.sin_cos();
|
||||
c * dx + s * dy
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn fade(t: f32) -> f32 {
|
||||
t * t * t * (t * (t * 6.0 - 15.0) + 10.0)
|
||||
}
|
||||
|
||||
/// Gradient noise in roughly −0.7..0.7.
|
||||
pub fn noise(x: f32, y: f32, seed: u32) -> f32 {
|
||||
let (x0, y0) = (x.floor(), y.floor());
|
||||
let (ix, iy) = (x0 as i32, y0 as i32);
|
||||
let (fx, fy) = (x - x0, y - y0);
|
||||
let (u, v) = (fade(fx), fade(fy));
|
||||
let a = grad(ix, iy, seed, fx, fy);
|
||||
let b = grad(ix + 1, iy, seed, fx - 1.0, fy);
|
||||
let c = grad(ix, iy + 1, seed, fx, fy - 1.0);
|
||||
let d = grad(ix + 1, iy + 1, seed, fx - 1.0, fy - 1.0);
|
||||
let ab = a + (b - a) * u;
|
||||
let cd = c + (d - c) * u;
|
||||
ab + (cd - ab) * v
|
||||
}
|
||||
|
||||
/// Fractal Brownian motion, roughly −1..1.
|
||||
pub fn fbm(x: f32, y: f32, seed: u32, octaves: u32) -> f32 {
|
||||
let (mut sum, mut amp, mut f, mut norm) = (0.0, 1.0, 1.0, 0.0);
|
||||
for o in 0..octaves {
|
||||
sum += noise(x * f, y * f, seed.wrapping_add(o * 101)) * amp;
|
||||
norm += amp;
|
||||
amp *= 0.5;
|
||||
f *= 2.03;
|
||||
}
|
||||
sum / norm * 1.4
|
||||
}
|
||||
|
||||
/// Ridged multifractal, 0..1 (sharp crests — mountain ridges).
|
||||
pub fn ridged(x: f32, y: f32, seed: u32, octaves: u32) -> f32 {
|
||||
let (mut sum, mut amp, mut f, mut norm) = (0.0, 1.0, 1.0, 0.0);
|
||||
for o in 0..octaves {
|
||||
let n = 1.0 - (noise(x * f, y * f, seed.wrapping_add(o * 131)) * 1.4).abs();
|
||||
sum += n * n * amp;
|
||||
norm += amp;
|
||||
amp *= 0.5;
|
||||
f *= 2.1;
|
||||
}
|
||||
sum / norm
|
||||
}
|
||||
@@ -0,0 +1,655 @@
|
||||
//! Scene painters. Coordinates: `x` = u·aspect (isotropic), `v` = 0 (top) .. 1 (bottom).
|
||||
//! Colours are authored as linear sRGB and converted to linear Rec.2020 at the end.
|
||||
|
||||
use lightcraft_color::{REC2020, SRGB};
|
||||
use lightcraft_raster::Rgb32f;
|
||||
use rayon::prelude::*;
|
||||
|
||||
use crate::Kind;
|
||||
use crate::noise::{fbm, hash, noise, rand01, ridged};
|
||||
|
||||
type C = [f32; 3];
|
||||
|
||||
fn hex(v: u32) -> C {
|
||||
let f = |c: u32| lightcraft_color::transfer::srgb_to_linear(((v >> c) & 0xff) as f32 / 255.0);
|
||||
[f(16), f(8), f(0)]
|
||||
}
|
||||
#[inline]
|
||||
fn mix(a: C, b: C, t: f32) -> C {
|
||||
[a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t]
|
||||
}
|
||||
#[inline]
|
||||
fn add(a: C, b: C) -> C {
|
||||
[a[0] + b[0], a[1] + b[1], a[2] + b[2]]
|
||||
}
|
||||
#[inline]
|
||||
fn mul(a: C, s: f32) -> C {
|
||||
[a[0] * s, a[1] * s, a[2] * s]
|
||||
}
|
||||
#[inline]
|
||||
fn mulc(a: C, b: C) -> C {
|
||||
[a[0] * b[0], a[1] * b[1], a[2] * b[2]]
|
||||
}
|
||||
#[inline]
|
||||
fn smooth(e0: f32, e1: f32, x: f32) -> f32 {
|
||||
let t = ((x - e0) / (e1 - e0)).clamp(0.0, 1.0);
|
||||
t * t * (3.0 - 2.0 * t)
|
||||
}
|
||||
|
||||
/// Per-scene random parameters.
|
||||
struct P {
|
||||
seed: u32,
|
||||
aspect: f32,
|
||||
}
|
||||
|
||||
impl P {
|
||||
fn r(&self, i: u32) -> f32 {
|
||||
rand01(i as i32, 7, self.seed)
|
||||
}
|
||||
}
|
||||
|
||||
struct Sky {
|
||||
zenith: C,
|
||||
horizon: C,
|
||||
horizon_v: f32,
|
||||
sun: (f32, f32),
|
||||
sun_col: C,
|
||||
sun_disc: f32,
|
||||
glow: f32,
|
||||
clouds: f32,
|
||||
cloud_col: C,
|
||||
stars: f32,
|
||||
}
|
||||
|
||||
fn sky(p: &P, s: &Sky, x: f32, v: f32) -> C {
|
||||
let elev = ((s.horizon_v - v) / s.horizon_v).clamp(0.0, 1.0);
|
||||
let mut c = mix(s.horizon, s.zenith, elev.powf(0.55));
|
||||
let (dx, dy) = (x - s.sun.0, (v - s.sun.1) * 1.0);
|
||||
let d = (dx * dx + dy * dy).sqrt();
|
||||
c = add(c, mul(s.sun_col, s.glow * (0.9 * (-d * 9.0).exp() + 0.35 * (-d * 2.6).exp())));
|
||||
let disc_r = 0.028;
|
||||
if s.sun_disc > 0.0 {
|
||||
let e = smooth(disc_r, disc_r * 0.8, d);
|
||||
c = mix(c, mul(s.sun_col, s.sun_disc), e);
|
||||
}
|
||||
if s.clouds > 0.0 {
|
||||
let n = fbm(x * 1.7 + 3.0, v * 5.5, p.seed ^ 0xc10d, 6);
|
||||
let dens = smooth(1.0 - s.clouds, 1.25 - s.clouds, n * 0.5 + 0.5) * smooth(0.0, 0.12, elev);
|
||||
if dens > 0.0 {
|
||||
let edge = fbm(x * 6.0, v * 14.0, p.seed ^ 0xed9e, 3) * 0.5 + 0.5;
|
||||
let lit = (-d * 2.2).exp() * 2.5 + 0.35 * edge;
|
||||
let cc = add(mul(s.cloud_col, 0.55 + 0.3 * edge), mul(s.sun_col, lit * s.glow.min(1.2) * 0.5));
|
||||
c = mix(c, cc, dens * 0.92);
|
||||
}
|
||||
}
|
||||
if s.stars > 0.0 {
|
||||
let g = 900.0;
|
||||
let (cx, cy) = ((x * g) as i32, (v * g) as i32);
|
||||
let h = rand01(cx, cy, p.seed ^ 0x5a5a);
|
||||
if h > 0.9965 {
|
||||
let b = (h - 0.9965) / 0.0035;
|
||||
let tint = mix([0.8, 0.85, 1.0], [1.0, 0.9, 0.75], rand01(cx, cy, 99));
|
||||
c = add(c, mul(tint, s.stars * b * b * 1.6 * elev.sqrt()));
|
||||
}
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
struct Layer {
|
||||
base: f32,
|
||||
amp: f32,
|
||||
freq: f32,
|
||||
col: C,
|
||||
haze: f32,
|
||||
snow: f32,
|
||||
ridged: bool,
|
||||
seed: u32,
|
||||
}
|
||||
|
||||
/// Low-octave height, for shading (slopes of the detailed crest alias into vertical streaks).
|
||||
fn layer_h_lo(l: &Layer, x: f32) -> f32 {
|
||||
let n = if l.ridged {
|
||||
0.75 * ridged(x * l.freq, 0.37, l.seed, 2) + 0.25 * (fbm(x * l.freq * 0.5, 1.7, l.seed, 2) * 0.5 + 0.5)
|
||||
} else {
|
||||
fbm(x * l.freq, 0.37, l.seed, 2) * 0.5 + 0.5
|
||||
};
|
||||
l.base + l.amp * n
|
||||
}
|
||||
|
||||
fn layer_h(l: &Layer, x: f32) -> f32 {
|
||||
let n = if l.ridged {
|
||||
0.75 * ridged(x * l.freq, 0.37, l.seed, 4) + 0.25 * (fbm(x * l.freq * 0.5, 1.7, l.seed, 3) * 0.5 + 0.5)
|
||||
} else {
|
||||
fbm(x * l.freq, 0.37, l.seed, 5) * 0.5 + 0.5
|
||||
};
|
||||
l.base + l.amp * n
|
||||
}
|
||||
|
||||
/// Terrain shading for a layer hit at (x, v) whose crest is at `top`.
|
||||
fn terrain(l: &Layer, x: f32, v: f32, top: f32, sun_x: f32, sun_col: C, haze_col: C) -> C {
|
||||
let e = 0.012;
|
||||
let slope = (layer_h_lo(l, x + e) - layer_h_lo(l, x - e)) / (2.0 * e);
|
||||
let dir = if sun_x > x { 1.0 } else { -1.0 };
|
||||
let face = (slope * dir * 1.6).clamp(-1.0, 1.0);
|
||||
let depth = (v - top).max(0.0);
|
||||
let tex = fbm(x * 38.0, v * 38.0, l.seed ^ 77, 4) * 0.12;
|
||||
let light = (0.62 + 0.38 * face + tex).max(0.05);
|
||||
let mut c = mul(l.col, light * (1.0 - (depth * 2.5).min(0.45)));
|
||||
c = add(c, mul(mulc(sun_col, l.col), 0.35 * face.max(0.0)));
|
||||
if l.snow > 0.0 {
|
||||
let s = l.snow * (0.6 + 0.5 * (fbm(x * 9.0, v * 4.0, l.seed ^ 5, 3) * 0.5 + 0.5));
|
||||
if depth < s * 0.18 * (1.0 - smooth(0.0, 0.18, depth) * 0.3) {
|
||||
let sc = mix([0.55, 0.6, 0.72], add(sun_col, [0.35, 0.35, 0.4]), (0.55 + 0.45 * face).clamp(0.0, 1.0));
|
||||
c = mix(c, sc, 0.95);
|
||||
}
|
||||
}
|
||||
mix(c, haze_col, l.haze)
|
||||
}
|
||||
|
||||
fn pine(p: &P, x: f32, v: f32, ground: f32, height: f32, density: f32) -> Option<f32> {
|
||||
let tw = 0.035;
|
||||
let cell = (x / tw).floor() as i32;
|
||||
let mut best: Option<f32> = None;
|
||||
for c in cell - 1..=cell + 1 {
|
||||
if rand01(c, 1, p.seed ^ 0x7ee) > density {
|
||||
continue;
|
||||
}
|
||||
let cx = (c as f32 + rand01(c, 2, p.seed)) * tw;
|
||||
let th = height * (0.45 + 0.8 * rand01(c, 3, p.seed));
|
||||
let base = ground + 0.01 * rand01(c, 4, p.seed);
|
||||
let top = base - th;
|
||||
if v < top || v > base {
|
||||
continue;
|
||||
}
|
||||
let t = (v - top) / th;
|
||||
let jag = 0.75 + 0.25 * ((t * 22.0 + c as f32).sin() * 0.5 + 0.5);
|
||||
let hw = t * th * 0.26 * jag + 0.0015;
|
||||
let dx = (x - cx).abs();
|
||||
if dx < hw {
|
||||
best = Some(best.map_or(dx / hw, |b: f32| b.min(dx / hw)));
|
||||
}
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
fn water(p: &P, x: f32, v: f32, wl: f32, calm: f32, above: &dyn Fn(f32, f32) -> C, tint: C, sun: (f32, f32), sun_col: C) -> C {
|
||||
let d = v - wl;
|
||||
let rip = fbm(x * 9.0, d * 60.0 / (d + 0.05), p.seed ^ 0xaa, 4) * (1.0 - calm);
|
||||
let rv = (wl - d * 1.0 + rip * (0.01 + 0.06 * (1.0 - calm)) * (d * 10.0 + 0.2)).max(0.0);
|
||||
let rx = x + rip * 0.02 * (d * 6.0 + 0.1);
|
||||
let refl = above(rx, rv);
|
||||
let fres = 0.55 + 0.4 * (1.0 - (d * 3.0).min(1.0));
|
||||
let mut c = add(mul(refl, fres), mul(tint, 0.25));
|
||||
// glitter along the sun path
|
||||
let path = (-(x - sun.0).abs() * (14.0 - d * 8.0).max(3.0)).exp();
|
||||
let g = rand01((x * 700.0) as i32, (v * 1400.0) as i32, p.seed ^ 0x61);
|
||||
if g > 0.93 - 0.1 * path && path > 0.02 && sun.1 < wl {
|
||||
c = add(c, mul(sun_col, path * (g - 0.8) * 6.0 * (1.0 - calm * 0.6)));
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
pub fn render(kind: Kind, seed: u32, w: usize, h: usize) -> Rgb32f {
|
||||
let aspect = w as f32 / h as f32;
|
||||
let p = P { seed, aspect };
|
||||
let ss = if w * h <= 300_000 { 2 } else { 1 };
|
||||
let to2020 = SRGB.to_space(&REC2020).to_f32();
|
||||
let mut img = Rgb32f::new(w, h);
|
||||
img.data.par_chunks_mut(w).enumerate().for_each(|(y, row)| {
|
||||
for (xi, px) in row.iter_mut().enumerate() {
|
||||
let mut acc = [0.0f32; 3];
|
||||
for sy in 0..ss {
|
||||
for sx in 0..ss {
|
||||
let u = (xi as f32 + (sx as f32 + 0.5) / ss as f32) / w as f32;
|
||||
let v = (y as f32 + (sy as f32 + 0.5) / ss as f32) / h as f32;
|
||||
acc = add(acc, shade(kind, &p, u * aspect, v));
|
||||
}
|
||||
}
|
||||
let c = mul(acc, 1.0 / (ss * ss) as f32);
|
||||
let m = &to2020;
|
||||
*px = [
|
||||
(m[0][0] * c[0] + m[0][1] * c[1] + m[0][2] * c[2]).max(0.0),
|
||||
(m[1][0] * c[0] + m[1][1] * c[1] + m[1][2] * c[2]).max(0.0),
|
||||
(m[2][0] * c[0] + m[2][1] * c[1] + m[2][2] * c[2]).max(0.0),
|
||||
];
|
||||
}
|
||||
});
|
||||
img
|
||||
}
|
||||
|
||||
/// Linear sRGB variant (for tests/tools that don't want Rec.2020).
|
||||
pub fn render_srgb_linear(kind: Kind, seed: u32, w: usize, h: usize) -> Rgb32f {
|
||||
let to709 = REC2020.to_space(&SRGB);
|
||||
render(kind, seed, w, h).map(|c| to709.apply_f32(c).map(|v| v.max(0.0)))
|
||||
}
|
||||
|
||||
fn shade(kind: Kind, p: &P, x: f32, v: f32) -> C {
|
||||
match kind {
|
||||
Kind::AlpineLake => alpine(p, x, v, false),
|
||||
Kind::BlueHour => alpine(p, x, v, true),
|
||||
Kind::Dunes => dunes(p, x, v),
|
||||
Kind::OceanSunset => ocean(p, x, v),
|
||||
Kind::Aurora => aurora(p, x, v),
|
||||
Kind::MistyForest => forest(p, x, v),
|
||||
Kind::Lavender => lavender(p, x, v),
|
||||
Kind::Macro => macro_flower(p, x, v),
|
||||
Kind::Canyon => canyon(p, x, v),
|
||||
Kind::Beach => beach(p, x, v),
|
||||
}
|
||||
}
|
||||
|
||||
fn alpine(p: &P, x: f32, v: f32, blue: bool) -> C {
|
||||
let hz = 0.62;
|
||||
let sun = (p.aspect * (0.2 + 0.6 * p.r(1)), if blue { 0.75 } else { 0.28 + 0.1 * p.r(2) });
|
||||
let (sun_col, sk) = if blue {
|
||||
(
|
||||
hex(0xffb4a0),
|
||||
Sky {
|
||||
zenith: hex(0x0b1a3c),
|
||||
horizon: hex(0x6a6fa8),
|
||||
horizon_v: hz,
|
||||
sun,
|
||||
sun_col: hex(0xff9a8a),
|
||||
sun_disc: 0.0,
|
||||
glow: 0.25,
|
||||
clouds: 0.0,
|
||||
cloud_col: hex(0x303a60),
|
||||
stars: 0.5,
|
||||
},
|
||||
)
|
||||
} else {
|
||||
let sc = hex(0xffc27a);
|
||||
(
|
||||
sc,
|
||||
Sky {
|
||||
zenith: hex(0x2f5c9e),
|
||||
horizon: hex(0xf2c08e),
|
||||
horizon_v: hz,
|
||||
sun,
|
||||
sun_col: sc,
|
||||
sun_disc: 22.0,
|
||||
glow: 1.1,
|
||||
clouds: 0.35 + 0.2 * p.r(3),
|
||||
cloud_col: hex(0x9aa4be),
|
||||
stars: 0.0,
|
||||
},
|
||||
)
|
||||
};
|
||||
let haze = if blue { hex(0x5d668f) } else { hex(0xc8b8b0) };
|
||||
let layers = [
|
||||
Layer { base: 0.02, amp: 0.05, freq: 3.0, col: hex(0x24331f), haze: 0.1, snow: 0.0, ridged: false, seed: p.seed + 4 },
|
||||
Layer {
|
||||
base: 0.08,
|
||||
amp: 0.16,
|
||||
freq: 1.6,
|
||||
col: if blue { hex(0x3a4466) } else { hex(0x5d5a58) },
|
||||
haze: 0.18,
|
||||
snow: 0.7,
|
||||
ridged: true,
|
||||
seed: p.seed + 3,
|
||||
},
|
||||
Layer {
|
||||
base: 0.16,
|
||||
amp: 0.28,
|
||||
freq: 1.0,
|
||||
col: if blue { hex(0x4d5a88) } else { hex(0x7b7a86) },
|
||||
haze: 0.42,
|
||||
snow: 1.0,
|
||||
ridged: true,
|
||||
seed: p.seed + 2,
|
||||
},
|
||||
];
|
||||
let wl = hz + 0.035;
|
||||
let above = |x: f32, v: f32| -> C {
|
||||
if let Some(t) = pine(p, x, v, wl, 0.16, 0.55) {
|
||||
let rim = if blue { 0.0 } else { (1.0 - t).powi(4) * 0.0 + (t).powi(6) * 0.25 };
|
||||
return add(mul(hex(0x0c140d), 1.0), mul(sun_col, rim * 0.2));
|
||||
}
|
||||
for l in &layers {
|
||||
let top = hz - layer_h(l, x);
|
||||
if v >= top {
|
||||
return terrain(l, x, v, top, sun.0, sun_col, haze);
|
||||
}
|
||||
}
|
||||
sky(p, &sk, x, v)
|
||||
};
|
||||
if v > wl {
|
||||
let tint = if blue { hex(0x1a2440) } else { hex(0x20303a) };
|
||||
return water(p, x, v, wl, 0.75, &above, tint, sun, sun_col);
|
||||
}
|
||||
above(x, v)
|
||||
}
|
||||
|
||||
fn dunes(p: &P, x: f32, v: f32) -> C {
|
||||
let hz = 0.42;
|
||||
let sun_col = hex(0xffd29a);
|
||||
let sun = (p.aspect * (0.1 + 0.25 * p.r(1)), 0.12);
|
||||
let sk = Sky {
|
||||
zenith: hex(0x2a64b8),
|
||||
horizon: hex(0xcfd9e6),
|
||||
horizon_v: hz,
|
||||
sun,
|
||||
sun_col,
|
||||
sun_disc: 0.0,
|
||||
glow: 0.5,
|
||||
clouds: 0.0,
|
||||
cloud_col: hex(0xffffff),
|
||||
stars: 0.0,
|
||||
};
|
||||
// Dune layers from near (low) to far (high). Crest = sharp ridge via |sin|.
|
||||
for i in 0..5 {
|
||||
let fi = i as f32;
|
||||
let freq = 1.3 + fi * 0.6;
|
||||
let ph = p.r(10 + i) * 6.0;
|
||||
let hfun = |x: f32| {
|
||||
let w = (x * freq + ph + 0.4 * fbm(x * 0.8, fi, p.seed + i, 3)).sin();
|
||||
let crest = 1.0 - w.abs().powf(0.6);
|
||||
0.05 + fi * 0.085 + crest * (0.16 - fi * 0.02)
|
||||
};
|
||||
let top = 0.98 - hfun(x) - fi * 0.08;
|
||||
if v >= top {
|
||||
let e = 0.001;
|
||||
let slope = (hfun(x + e) - hfun(x - e)) / (2.0 * e);
|
||||
let lit = (0.5 - slope * 1.2).clamp(0.0, 1.0);
|
||||
let ripple = ((x * 260.0 + v * 90.0 + 8.0 * fbm(x * 3.0, v * 3.0, p.seed, 3)).sin() * 0.5 + 0.5) * 0.08;
|
||||
let sand = mix(hex(0xb4562a), hex(0xf2a55c), (1.0 - (v - top) * 3.0).clamp(0.2, 1.0));
|
||||
let shadow_fill = hex(0x5e3a3a);
|
||||
let shade = smooth(0.35, 0.65, lit);
|
||||
let c = mix(mix(mul(sand, 0.28), shadow_fill, 0.45), mul(sand, 1.05 + ripple), shade);
|
||||
let far = fi / 5.0;
|
||||
return mix(mul(c, 0.9), hex(0xe8c8b0), far * 0.45);
|
||||
}
|
||||
}
|
||||
sky(p, &sk, x, v)
|
||||
}
|
||||
|
||||
fn ocean(p: &P, x: f32, v: f32) -> C {
|
||||
let hz = 0.56;
|
||||
let sun_col = hex(0xffa050);
|
||||
let sun = (p.aspect * (0.35 + 0.3 * p.r(1)), hz - 0.06 - 0.05 * p.r(2));
|
||||
let sk = Sky {
|
||||
zenith: hex(0x3b3f78),
|
||||
horizon: hex(0xff9c55),
|
||||
horizon_v: hz,
|
||||
sun,
|
||||
sun_col,
|
||||
sun_disc: 30.0,
|
||||
glow: 1.6,
|
||||
clouds: 0.45,
|
||||
cloud_col: hex(0x5a4a6a),
|
||||
stars: 0.0,
|
||||
};
|
||||
// Headland silhouette on one side.
|
||||
let side = if p.r(5) > 0.5 { x } else { p.aspect - x };
|
||||
let head = 0.18 * (1.0 - smooth(0.0, 0.35 * p.aspect, side)) * (0.8 + 0.4 * fbm(x * 4.0, 0.0, p.seed, 4));
|
||||
let above = |x: f32, v: f32| -> C {
|
||||
let side = if p.r(5) > 0.5 { x } else { p.aspect - x };
|
||||
let head = 0.18 * (1.0 - smooth(0.0, 0.35 * p.aspect, side)) * (0.8 + 0.4 * fbm(x * 4.0, 0.0, p.seed, 4));
|
||||
if v > hz - head && v <= hz {
|
||||
return mix(hex(0x1c1420), hex(0x3a2230), (hz - v) * 2.0);
|
||||
}
|
||||
sky(p, &sk, x, v)
|
||||
};
|
||||
if v > hz {
|
||||
if v < hz + head * 0.3 {
|
||||
return hex(0x120c14);
|
||||
}
|
||||
return water(p, x, v, hz, 0.35, &above, hex(0x1a2040), sun, sun_col);
|
||||
}
|
||||
let _ = head;
|
||||
above(x, v)
|
||||
}
|
||||
|
||||
fn aurora(p: &P, x: f32, v: f32) -> C {
|
||||
let hz = 0.64;
|
||||
let sk = Sky {
|
||||
zenith: hex(0x03060f),
|
||||
horizon: hex(0x0d2230),
|
||||
horizon_v: hz,
|
||||
sun: (-5.0, 5.0),
|
||||
sun_col: [0.0; 3],
|
||||
sun_disc: 0.0,
|
||||
glow: 0.0,
|
||||
clouds: 0.0,
|
||||
cloud_col: [0.0; 3],
|
||||
stars: 1.2,
|
||||
};
|
||||
let aur = |x: f32, v: f32| -> C {
|
||||
let yc = 0.40 + 0.12 * fbm(x * 0.9, 1.0, p.seed ^ 0xa0, 4);
|
||||
let d = yc - v;
|
||||
let rays = 0.55 + 0.45 * noise(x * 38.0, v * 1.5, p.seed ^ 0xb1).abs() * 1.6;
|
||||
let i = if d > 0.0 { (-d / 0.16).exp() } else { (-(d / 0.018).powi(2)).exp() };
|
||||
let fold = 0.6 + 0.4 * fbm(x * 3.0, 0.3, p.seed ^ 0xc2, 3);
|
||||
let g = mul(hex(0x2cff8a), i * rays * fold * 2.2);
|
||||
let pur = mul(hex(0xa040ff), i * smooth(0.06, 0.3, d) * 1.4);
|
||||
add(g, pur)
|
||||
};
|
||||
let layers = [
|
||||
Layer { base: 0.03, amp: 0.05, freq: 2.2, col: hex(0x0a0e12), haze: 0.0, snow: 0.0, ridged: false, seed: p.seed + 3 },
|
||||
Layer { base: 0.07, amp: 0.2, freq: 1.2, col: hex(0x1a2430), haze: 0.1, snow: 0.9, ridged: true, seed: p.seed + 2 },
|
||||
];
|
||||
let wl = hz + 0.05;
|
||||
let above = |x: f32, v: f32| -> C {
|
||||
for l in &layers {
|
||||
let top = hz - layer_h(l, x);
|
||||
if v >= top {
|
||||
let t = terrain(l, x, v, top, x + 1.0, hex(0x2c8a60), hex(0x0a1418));
|
||||
return mul(t, 0.35);
|
||||
}
|
||||
}
|
||||
add(sky(p, &sk, x, v), aur(x, v))
|
||||
};
|
||||
if v > wl {
|
||||
return water(p, x, v, wl, 0.9, &above, hex(0x020408), (-5.0, 5.0), [0.0; 3]);
|
||||
}
|
||||
above(x, v)
|
||||
}
|
||||
|
||||
fn forest(p: &P, x: f32, v: f32) -> C {
|
||||
let sun = (p.aspect * (0.55 + 0.3 * p.r(1)), 0.1);
|
||||
let sun_col = hex(0xfff0c8);
|
||||
let fog = hex(0xcfd6c8);
|
||||
let mut c = mix(hex(0xf4f0e0), hex(0xb8c4c0), (1.0 - v).powf(1.5));
|
||||
for i in 0..6 {
|
||||
let fi = i as f32;
|
||||
let ground = 0.5 + fi * 0.1;
|
||||
let depth = 1.0 - fi / 5.0;
|
||||
let col = mix(mix(hex(0x0c1c10), hex(0x1c3020), depth), fog, depth.powf(0.8) * 0.88);
|
||||
if pine(&P { seed: p.seed + i * 17, aspect: p.aspect }, x * (1.0 - fi * 0.08) + fi * 3.1, v, ground, 0.22 + fi * 0.07, 0.85).is_some()
|
||||
|| v > ground
|
||||
{
|
||||
c = col;
|
||||
}
|
||||
}
|
||||
// volumetric light shafts in front of everything, strongest in the fog
|
||||
let (dx, dy) = (x - sun.0, v - sun.1);
|
||||
let ang = dy.atan2(dx);
|
||||
let rays = (fbm(ang * 9.0, 0.0, p.seed ^ 0x9a, 4) * 0.5 + 0.5).powf(3.0);
|
||||
let dist = (dx * dx + dy * dy).sqrt();
|
||||
add(c, mul(sun_col, rays * (-dist * 1.3).exp() * 0.7 + (-dist * 7.0).exp() * 1.6))
|
||||
}
|
||||
|
||||
fn lavender(p: &P, x: f32, v: f32) -> C {
|
||||
let hz = 0.45;
|
||||
let sun_col = hex(0xffb070);
|
||||
let sun = (p.aspect * (0.6 + 0.2 * p.r(1)), hz - 0.08);
|
||||
let sk = Sky {
|
||||
zenith: hex(0x4a6ea8),
|
||||
horizon: hex(0xffc896),
|
||||
horizon_v: hz,
|
||||
sun,
|
||||
sun_col,
|
||||
sun_disc: 18.0,
|
||||
glow: 1.2,
|
||||
clouds: 0.3,
|
||||
cloud_col: hex(0xa89ab8),
|
||||
stars: 0.0,
|
||||
};
|
||||
// lone tree
|
||||
let tx = p.aspect * (0.22 + 0.1 * p.r(2));
|
||||
let (dx, dy) = ((x - tx) / 0.09, (v - (hz - 0.11)) / 0.075);
|
||||
let blob = dx * dx + dy * dy + 0.35 * fbm(x * 30.0, v * 30.0, p.seed, 3);
|
||||
if blob < 1.0 {
|
||||
return mix(hex(0x1a2412), hex(0x3a4a1c), (1.0 - blob) * 0.3);
|
||||
}
|
||||
if (x - tx).abs() < 0.006 && v > hz - 0.06 && v < hz + 0.004 {
|
||||
return hex(0x1a1410);
|
||||
}
|
||||
if v > hz {
|
||||
let dz = v - hz;
|
||||
let z = 0.06 / (dz + 0.002);
|
||||
let xw = (x - p.aspect * 0.5) / (dz + 0.004);
|
||||
let row = (xw * 9.0).sin() * smooth(0.0, 0.08, dz) + (1.0 - smooth(0.0, 0.08, dz)) * 0.3;
|
||||
let tex = fbm(xw * 3.0, z * 3.0, p.seed ^ 0x1a, 3);
|
||||
let purple = mix(hex(0x5a3a9a), hex(0x9a70d8), (0.5 + 0.5 * tex).clamp(0.0, 1.0));
|
||||
let gap = hex(0x3a3a1e);
|
||||
let t = smooth(-0.4, 0.2, row + tex * 0.4 * (1.0 - smooth(0.0, 0.2, dz)));
|
||||
let mut c = mix(gap, purple, t);
|
||||
c = mul(c, 0.55 + 0.6 * smooth(0.0, 0.5, dz));
|
||||
c = add(c, mul(sun_col, 0.18 * (-dz * 8.0).exp()));
|
||||
let haze = (-dz * 25.0).exp();
|
||||
return mix(c, hex(0xe8b8a0), haze * 0.6);
|
||||
}
|
||||
sky(p, &sk, x, v)
|
||||
}
|
||||
|
||||
fn macro_flower(p: &P, x: f32, v: f32) -> C {
|
||||
let ymax = 1.0;
|
||||
let mut c = mix(hex(0x0c2a10), hex(0x3e6a1a), (1.0 - v / ymax).powf(1.2));
|
||||
// bokeh discs
|
||||
for i in 0..36u32 {
|
||||
let cx = p.r(100 + i) * p.aspect;
|
||||
let cy = p.r(200 + i);
|
||||
let r = 0.03 + 0.09 * p.r(300 + i);
|
||||
let d = ((x - cx).powi(2) + (v - cy).powi(2)).sqrt();
|
||||
if d < r {
|
||||
let pal = [hex(0xd8e870), hex(0x80c040), hex(0xfff0b0), hex(0xf0a0c0), hex(0x60a8a0)];
|
||||
let col = pal[(hash(i as i32, 1, p.seed) % 5) as usize];
|
||||
let rim = smooth(r * 0.8, r, d) * 0.4;
|
||||
let a = smooth(r, r * 0.93, d) * (0.25 + 0.35 * p.r(400 + i));
|
||||
c = add(c, mul(col, a * (1.0 + rim)));
|
||||
}
|
||||
}
|
||||
let center = (p.aspect * (0.45 + 0.1 * p.r(1)), 0.46);
|
||||
let rr = 0.28;
|
||||
// stem
|
||||
if (x - center.0 - (v - center.1) * 0.15).abs() < 0.008 && v > center.1 {
|
||||
return mix(hex(0x2a5a1a), hex(0x4a8a2a), 0.5);
|
||||
}
|
||||
let (dx, dy) = (x - center.0, v - center.1);
|
||||
let r = (dx * dx + dy * dy).sqrt() / rr;
|
||||
let th = dy.atan2(dx) + p.r(2) * 3.0;
|
||||
let n = 8.0;
|
||||
let petal = ((th * n / 2.0).cos()).abs();
|
||||
let edge = 0.35 + 0.65 * petal.powf(0.45) + 0.03 * noise(th * 20.0, 0.0, p.seed);
|
||||
if r < edge {
|
||||
let (pc, tip) = if p.seed.is_multiple_of(2) { (hex(0xe0408a), hex(0xffc0e0)) } else { (hex(0x8a3ad0), hex(0xe0c0ff)) };
|
||||
let vein = ((th * n * 7.0).sin() * 0.5 + 0.5) * 0.12;
|
||||
let t = (r / edge).clamp(0.0, 1.0);
|
||||
let mut col = mix(mul(pc, 0.55), tip, t.powf(1.5));
|
||||
col = mul(col, 0.85 + vein + 0.25 * (1.0 - petal));
|
||||
// centre
|
||||
if r < 0.2 {
|
||||
let dots = rand01((x * 900.0) as i32, (v * 900.0) as i32, p.seed) * 0.4;
|
||||
col = mul(mix(hex(0xffb000), hex(0xffe060), 1.0 - r * 5.0), 0.8 + dots);
|
||||
}
|
||||
let light = 1.2 - 0.4 * (dx + dy + 0.2).clamp(-1.0, 1.0);
|
||||
return mul(col, light);
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
fn canyon(p: &P, x: f32, v: f32) -> C {
|
||||
let hz = 0.3;
|
||||
let sun_col = hex(0xffe0b0);
|
||||
let sk = Sky {
|
||||
zenith: hex(0x1e5aa8),
|
||||
horizon: hex(0x9ac4e8),
|
||||
horizon_v: hz + 0.2,
|
||||
sun: (p.aspect * 0.8, -0.1),
|
||||
sun_col,
|
||||
sun_disc: 0.0,
|
||||
glow: 0.3,
|
||||
clouds: 0.2,
|
||||
cloud_col: hex(0xe8eef4),
|
||||
stars: 0.0,
|
||||
};
|
||||
// Two walls meeting in a V-shaped gap.
|
||||
let mid = p.aspect * (0.5 + 0.1 * (p.r(1) - 0.5));
|
||||
let gap = (x - mid).abs() / p.aspect;
|
||||
let wall_top = hz + 0.25 - gap * 0.9 + 0.08 * fbm(x * 3.0, 0.0, p.seed, 5);
|
||||
if v > wall_top {
|
||||
let strata = ((v * 70.0 + 3.0 * fbm(x * 2.0, v * 2.0, p.seed ^ 2, 4)).sin() * 0.5 + 0.5).powf(1.5);
|
||||
let base = mix(hex(0xa8401c), hex(0xe89050), strata);
|
||||
let cream = hex(0xe8c8a0);
|
||||
let mut c = mix(base, cream, smooth(0.8, 1.0, strata) * 0.5);
|
||||
let lit_side = x < mid;
|
||||
let depth = (v - wall_top).max(0.0);
|
||||
let light = if lit_side { 1.0 - depth * 0.6 } else { 0.35 - depth * 0.2 };
|
||||
c = mul(c, light.max(0.08) * (0.9 + 0.2 * fbm(x * 40.0, v * 40.0, p.seed ^ 9, 3)));
|
||||
if !lit_side {
|
||||
c = add(c, mul(hex(0x6a4a60), 0.12));
|
||||
}
|
||||
return c;
|
||||
}
|
||||
sky(p, &sk, x, v)
|
||||
}
|
||||
|
||||
fn beach(p: &P, x: f32, v: f32) -> C {
|
||||
let hz = 0.42;
|
||||
let sun_col = hex(0xffffff);
|
||||
let sk = Sky {
|
||||
zenith: hex(0x1f6fd0),
|
||||
horizon: hex(0xb8e0f8),
|
||||
horizon_v: hz,
|
||||
sun: (p.aspect * 0.8, -0.2),
|
||||
sun_col,
|
||||
sun_disc: 0.0,
|
||||
glow: 0.25,
|
||||
clouds: 0.4,
|
||||
cloud_col: hex(0xffffff),
|
||||
stars: 0.0,
|
||||
};
|
||||
// palm silhouette (right side)
|
||||
let px0 = p.aspect * 0.86;
|
||||
let trunk_x = |v: f32| px0 - (1.0 - v) * 0.25 * (1.0 - v);
|
||||
if v > 0.18 && (x - trunk_x(v)).abs() < 0.012 * (0.6 + v * 0.6) {
|
||||
return hex(0x2a2018);
|
||||
}
|
||||
let crown = (trunk_x(0.18), 0.18);
|
||||
for f in 0..9 {
|
||||
let a0 = -2.9 + f as f32 * 0.7 + 0.2 * p.r(50 + f);
|
||||
let len = 0.22 + 0.06 * p.r(60 + f);
|
||||
// parametric droop
|
||||
let steps = 24;
|
||||
for s in 0..steps {
|
||||
let t = s as f32 / steps as f32;
|
||||
let fx = crown.0 + a0.cos() * len * t;
|
||||
let fy = crown.1 + a0.sin() * len * t + t * t * 0.12;
|
||||
let d = ((x - fx).powi(2) + (v - fy).powi(2)).sqrt();
|
||||
let leaf = 0.022 * (1.0 - t) * (0.6 + 0.4 * ((t * 60.0).sin()).abs());
|
||||
if d < leaf + 0.002 {
|
||||
return mix(hex(0x0e2410), hex(0x1a3a18), t);
|
||||
}
|
||||
}
|
||||
}
|
||||
if v <= hz {
|
||||
return sky(p, &sk, x, v);
|
||||
}
|
||||
let shore = 0.68 + 0.04 * (x * 3.0 + p.r(1) * 5.0).sin() + 0.02 * fbm(x * 6.0, 0.0, p.seed, 3);
|
||||
if v < shore {
|
||||
let t = (v - hz) / (shore - hz);
|
||||
let deep = hex(0x0d4a8a);
|
||||
let turq = hex(0x30d0c8);
|
||||
let mut c = mix(deep, turq, t.powf(1.4));
|
||||
let caust = (fbm(x * 30.0, v * 40.0, p.seed ^ 3, 3) * 0.5 + 0.5).powf(3.0) * t;
|
||||
c = add(c, mul([0.6, 0.9, 0.9], caust * 0.35));
|
||||
let foam = smooth(shore - 0.012, shore, v) * (0.6 + 0.4 * noise(x * 60.0, 0.0, p.seed));
|
||||
c = mix(c, hex(0xf4f8f8), foam);
|
||||
let refl = mul(sky(p, &sk, x, hz - (v - hz) * 0.8), 0.25 * (1.0 - t));
|
||||
return add(c, refl);
|
||||
}
|
||||
let wet = smooth(shore + 0.04, shore, v);
|
||||
let sand = mul(hex(0xf0dcb8), 0.9 + 0.12 * fbm(x * 80.0, v * 80.0, p.seed ^ 4, 3));
|
||||
mix(sand, mul(hex(0xc8b090), 0.9), wet)
|
||||
}
|
||||
Reference in New Issue
Block a user