diff --git a/Pinta.Core/Algorithms/PerlinNoise.cs b/Pinta.Core/Algorithms/PerlinNoise.cs index f26d84e8d2..00296e76c3 100644 --- a/Pinta.Core/Algorithms/PerlinNoise.cs +++ b/Pinta.Core/Algorithms/PerlinNoise.cs @@ -63,7 +63,7 @@ static PerlinNoise () rot_22 = Math.Cos (rotationRadians.Radians); } - private static double Fade (double t) + public static double Fade (double t) => t * t * t * (t * (t * 6 - 15) + 10); /// diff --git a/Pinta.Effects/Algorithms/PerlinNoise3D.cs b/Pinta.Effects/Algorithms/PerlinNoise3D.cs new file mode 100644 index 0000000000..81bf610565 --- /dev/null +++ b/Pinta.Effects/Algorithms/PerlinNoise3D.cs @@ -0,0 +1,142 @@ +// Copyright (c) 2025 Jerry Huxtable +// +// MIT License: http://www.opensource.org/licenses/mit-license.php +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// +// Ported to Pinta by Martin del Rio + +using System; +using Pinta.Core; + +namespace Pinta.Effects; + +public sealed class PerlinNoise3D +{ + private const int GRADIENT_SIZE = 256; + + private readonly int[] permutation = new int[GRADIENT_SIZE * 2]; + private readonly double[] gradient_x = new double[GRADIENT_SIZE]; + private readonly double[] gradient_y = new double[GRADIENT_SIZE]; + private readonly double[] gradient_z = new double[GRADIENT_SIZE]; + + // The constructor now takes a seed to generate a unique, repeatable noise pattern. + public PerlinNoise3D (int seed) + { + var randomGen = new Random (seed); + int[] p = new int[GRADIENT_SIZE]; + + for (int i = 0; i < GRADIENT_SIZE; i++) + p[i] = i; + + for (int i = 0; i < GRADIENT_SIZE; i++) { + int j = randomGen.Next (GRADIENT_SIZE); + (p[i], p[j]) = (p[j], p[i]); + } + + for (int i = 0; i < GRADIENT_SIZE; i++) { + permutation[i] = permutation[i + GRADIENT_SIZE] = p[i]; + double invLen; + do { + gradient_x[i] = randomGen.NextDouble () * 2.0 - 1.0; + gradient_y[i] = randomGen.NextDouble () * 2.0 - 1.0; + gradient_z[i] = randomGen.NextDouble () * 2.0 - 1.0; + invLen = gradient_x[i] * gradient_x[i] + gradient_y[i] * gradient_y[i] + gradient_z[i] * gradient_z[i]; + } while (invLen == 0); + invLen = 1.0 / Math.Sqrt (invLen); + gradient_x[i] *= invLen; + gradient_y[i] *= invLen; + gradient_z[i] *= invLen; + } + } + private double Grad (int hash, double x, double y, double z) + { + int h = hash & (GRADIENT_SIZE - 1); + return gradient_x[h] * x + gradient_y[h] * y + gradient_z[h] * z; + } + + public double Noise3 (double x, double y, double z) + { + int X = (int) Math.Floor (x) & (GRADIENT_SIZE - 1); + int Y = (int) Math.Floor (y) & (GRADIENT_SIZE - 1); + int Z = (int) Math.Floor (z) & (GRADIENT_SIZE - 1); + + x -= Math.Floor (x); + y -= Math.Floor (y); + z -= Math.Floor (z); + + double u = PerlinNoise.Fade (x); + double v = PerlinNoise.Fade (y); + double w = PerlinNoise.Fade (z); + + int A = permutation[X] + Y; + int AA = permutation[A] + Z; + int AB = permutation[A + 1] + Z; + int B = permutation[X + 1] + Y; + int BA = permutation[B] + Z; + int BB = permutation[B + 1] + Z; + + return Mathematics.Lerp ( + Mathematics.Lerp ( + Mathematics.Lerp ( + Grad (permutation[AA], x, y, z), + Grad (permutation[BA], x - 1, y, z), + u), + Mathematics.Lerp ( + Grad ( + permutation[AB], + x, + y - 1, + z), + Grad ( + permutation[BB], + x - 1, + y - 1, + z), + u), + v), + Mathematics.Lerp ( + Mathematics.Lerp ( + Grad ( + permutation[AA + 1], + x, + y, + z - 1), + Grad ( + permutation[BA + 1], + x - 1, + y, + z - 1), + u), + Mathematics.Lerp ( + Grad ( + permutation[AB + 1], + x, + y - 1, + z - 1), + Grad ( + permutation[BB + 1], + x - 1, + y - 1, + z - 1), + u), + v), + w); + } +} diff --git a/Pinta.Effects/CoreEffectsExtension.cs b/Pinta.Effects/CoreEffectsExtension.cs index 339602f30f..478962c917 100644 --- a/Pinta.Effects/CoreEffectsExtension.cs +++ b/Pinta.Effects/CoreEffectsExtension.cs @@ -59,6 +59,7 @@ public void Initialize () PintaCore.Effects.RegisterEffect (new AddNoiseEffect (services)); PintaCore.Effects.RegisterEffect (new AlignObjectEffect (services)); PintaCore.Effects.RegisterEffect (new BulgeEffect (services)); + PintaCore.Effects.RegisterEffect (new CausticsEffect (services)); PintaCore.Effects.RegisterEffect (new CellsEffect (services)); PintaCore.Effects.RegisterEffect (new CloudsEffect (services)); PintaCore.Effects.RegisterEffect (new DentsEffect (services)); @@ -112,6 +113,7 @@ public void Uninitialize () PintaCore.Effects.UnregisterInstanceOfEffect (); PintaCore.Effects.UnregisterInstanceOfEffect (); PintaCore.Effects.UnregisterInstanceOfEffect (); + PintaCore.Effects.UnregisterInstanceOfEffect (); PintaCore.Effects.UnregisterInstanceOfEffect (); PintaCore.Effects.UnregisterInstanceOfEffect (); PintaCore.Effects.UnregisterInstanceOfEffect (); diff --git a/Pinta.Effects/Effects/CausticsEffect.cs b/Pinta.Effects/Effects/CausticsEffect.cs new file mode 100644 index 0000000000..835fd3d9e7 --- /dev/null +++ b/Pinta.Effects/Effects/CausticsEffect.cs @@ -0,0 +1,241 @@ +// Copyright (c) 2025 Jerry Huxtable +// +// MIT License: http://www.opensource.org/licenses/mit-license.php +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// +// Ported to Pinta by Martin del Rio + +using System; +using System.Threading.Tasks; +using Cairo; +using Pinta.Core; +using Pinta.Gui.Widgets; + +namespace Pinta.Effects; + +public sealed class CausticsEffect : BaseEffect +{ + private readonly IChromeService chrome; + private readonly IPaletteService palette; + private readonly IWorkspaceService workspace; + + public sealed override bool IsTileable => false; + + public override string Name => Translations.GetString ("Caustics"); + + public override bool IsConfigurable => true; + + public override string EffectMenuCategory => Translations.GetString ("Render"); + + public CausticsData Data => (CausticsData) EffectData!; // NRT - Set in constructor + + private static readonly double s_rad = Math.Sin (0.1); + private static readonly double c_rad = Math.Cos (0.1); + + public CausticsEffect (IServiceProvider services) + { + chrome = services.GetService (); + palette = services.GetService (); + workspace = services.GetService (); + EffectData = new CausticsData (); + } + + public override Task LaunchConfiguration () + => chrome.LaunchSimpleEffectDialog (this, workspace); + + // Algorithm Code Ported From JH Labs + + private sealed record CausticsSettings ( + Size canvasSize, + int v, + int samples, + double dispersion, + double scale, + double invScale, + double d, + double focus, + double turbulence, + double time, + ColorBgra background, + RandomSeed seed); + private CausticsSettings CreateSettings (ImageSurface dest) + { + int valor = Data.Brightness / Math.Max (1, Data.Samples); + if (valor == 0) + valor = (Data.Brightness > 0 && Data.Samples > 0) ? 1 : 0; + + return new ( + canvasSize: dest.GetSize (), + v: valor, + samples: Data.Samples, + dispersion: Data.Dispersion, + scale: Data.Scale, + invScale: 1.0 / Data.Scale, + d: 0.95, + focus: 0.1 + Data.Amount, + seed: Data.Seed, + turbulence: Data.Turbulence, + background: palette.PrimaryColor.ToColorBgra ().ToPremultipliedAlpha (), + time: Data.TimeOffset); + } + + protected override void Render (ImageSurface src, ImageSurface dest, RectangleI roi) + { + CausticsSettings settings = CreateSettings (dest); + Random jitterRand = new (settings.seed.Value); + PerlinNoise3D noiseGenerator = new (settings.seed.Value); + + Span dest_data = dest.GetPixelData (); + + foreach (var pixel in roi.GeneratePixelOffsets (settings.canvasSize)) + dest_data[pixel.memoryOffset] = settings.background; + + foreach (var pixel in roi.GeneratePixelOffsets (settings.canvasSize)) { + // Loop for multi-sampling + for (int s_sample = 0; s_sample < settings.samples; s_sample++) { + // Add random jitter for sampling + double sx = pixel.coordinates.X + jitterRand.NextDouble (); + double sy = pixel.coordinates.Y + jitterRand.NextDouble (); + + // Normalized coordinates for noise evaluation + double nx = sx * settings.invScale; + double ny = sy * settings.invScale; + + // Evaluate noise for displacement + double xDisplacement = Evaluate (settings, noiseGenerator, nx - settings.d, ny) - Evaluate (settings, noiseGenerator, nx + settings.d, ny); + double yDisplacement = Evaluate (settings, noiseGenerator, nx, ny + settings.d) - Evaluate (settings, noiseGenerator, nx, ny - settings.d); + + if (settings.dispersion > 0.0) { + for (int channel = 0; channel < 3; channel++) // R, G, B channels + { + double ca = (1.0 + channel * settings.dispersion); + double targetX = sx + settings.scale * settings.focus * xDisplacement * ca; + double targetY = sy + settings.scale * settings.focus * yDisplacement * ca; + + if (targetX >= 0 && targetX < settings.canvasSize.Width - 1 && + targetY >= 0 && targetY < settings.canvasSize.Height - 1) { + int dest_offset = (int) targetY * settings.canvasSize.Width + (int) targetX; + ColorBgra pixelColor = dest_data[dest_offset]; + + byte r = pixelColor.R; + byte g = pixelColor.G; + byte b = pixelColor.B; + + if (channel == 2) // Red + r = (byte) Math.Min (255, r + settings.v); + else if (channel == 1) // Green + g = (byte) Math.Min (255, g + settings.v); + else // Blue + b = (byte) Math.Min (255, b + settings.v); + + byte a = (byte) Math.Min (255, pixelColor.A + settings.v); + b = Math.Min (b, a); + g = Math.Min (g, a); + r = Math.Min (r, a); + + dest_data[dest_offset] = ColorBgra.FromBgra (b, g, r, a); + } + } + } else // No dispersion + { + double targetX = sx + settings.scale * settings.focus * xDisplacement; + double targetY = sy + settings.scale * settings.focus * yDisplacement; + + if (targetX >= 0 && targetX < settings.canvasSize.Width - 1 && + targetY >= 0 && targetY < settings.canvasSize.Height - 1) { + int dest_offset = (int) targetY * settings.canvasSize.Width + (int) targetX; + + ColorBgra pixelColor = dest_data[dest_offset]; + + byte a = (byte) Math.Min (255, pixelColor.A + settings.v); + byte r = (byte) Math.Min (a, pixelColor.R + settings.v); + byte g = (byte) Math.Min (a, pixelColor.G + settings.v); + byte b = (byte) Math.Min (a, pixelColor.B + settings.v); + + dest_data[dest_offset] = ColorBgra.FromBgra (b, g, r, a); + + } + } + } + } + } + private static double Evaluate (CausticsSettings settings, PerlinNoise3D noiseGenerator, double x, double y) + { + double xt = s_rad * x + c_rad * settings.time; + double tt_eval = c_rad * x - c_rad * settings.time; + + double f = (settings.turbulence == 0.0) + ? noiseGenerator.Noise3 (xt, y, tt_eval) + : Turbulence2 (xt, y, tt_eval, settings.turbulence, noiseGenerator); + + return f; + } + + private static double Turbulence2 (double x, double y, double time, double octaves, PerlinNoise3D noiseGenerator) + { + double value = 0.0; + double lacunarity = 2.0; + double f = 1.0; + + x += 371; + y += 529; + + for (int i = 0; i < (int) octaves; i++) { + value += noiseGenerator.Noise3 (x, y, time) / f; + x *= lacunarity; + y *= lacunarity; + f *= 2.0; + } + + double remainder = octaves - (int) octaves; + if (remainder != 0) + value += remainder * noiseGenerator.Noise3 (x, y, time) / f; + + return value; + } + + public sealed class CausticsData : EffectData // All CausticsData fields + { + [Caption ("Scale"), MinimumValue (1), MaximumValue (300)] + public double Scale { get; set; } = 32; + + [Caption ("Brightness"), MinimumValue (0), MaximumValue (100)] + public int Brightness { get; set; } = 10; + + [Caption ("Amount"), MinimumValue (0), MaximumValue (5)] + public double Amount { get; set; } = 1.0; + + [Caption ("Turbulence"), MinimumValue (0), MaximumValue (10)] + public double Turbulence { get; set; } = 1.0; + + [Caption ("Dispersion"), MinimumValue (0), MaximumValue (1)] + public double Dispersion { get; set; } = 0.0; + + [Caption ("Time Offset"), MinimumValue (0), MaximumValue (100)] + public double TimeOffset { get; set; } = 0.0; + + [Caption ("Samples"), MinimumValue (1), MaximumValue (10)] + public int Samples { get; set; } = 2; + + [Caption ("Seed")] + public RandomSeed Seed { get; set; } = new (0); + } +} + diff --git a/tests/Pinta.Effects.Tests/Assets/caustics1.png b/tests/Pinta.Effects.Tests/Assets/caustics1.png new file mode 100644 index 0000000000..5c29866ef8 Binary files /dev/null and b/tests/Pinta.Effects.Tests/Assets/caustics1.png differ diff --git a/tests/Pinta.Effects.Tests/Assets/caustics2.png b/tests/Pinta.Effects.Tests/Assets/caustics2.png new file mode 100644 index 0000000000..f7723f35f4 Binary files /dev/null and b/tests/Pinta.Effects.Tests/Assets/caustics2.png differ diff --git a/tests/Pinta.Effects.Tests/Assets/caustics3.png b/tests/Pinta.Effects.Tests/Assets/caustics3.png new file mode 100644 index 0000000000..f5b8a7587d Binary files /dev/null and b/tests/Pinta.Effects.Tests/Assets/caustics3.png differ diff --git a/tests/Pinta.Effects.Tests/Assets/caustics4.png b/tests/Pinta.Effects.Tests/Assets/caustics4.png new file mode 100644 index 0000000000..1b3404d203 Binary files /dev/null and b/tests/Pinta.Effects.Tests/Assets/caustics4.png differ diff --git a/tests/Pinta.Effects.Tests/EffectsTest.cs b/tests/Pinta.Effects.Tests/EffectsTest.cs index ced12a8ab2..d7736cc105 100644 --- a/tests/Pinta.Effects.Tests/EffectsTest.cs +++ b/tests/Pinta.Effects.Tests/EffectsTest.cs @@ -57,6 +57,37 @@ public void BulgeSmallerRadius () Utilities.TestEffect (effect, "bulge3.png"); } + [Test] + public void Caustics1 () + { + CausticsEffect effect = new CausticsEffect (Utilities.CreateMockServices ()); + Utilities.TestEffect (effect, "caustics1.png"); + } + + [Test] + public void Caustics2 () + { + CausticsEffect effect = new CausticsEffect (Utilities.CreateMockServices ()); + effect.Data.Dispersion = 1.0; + Utilities.TestEffect (effect, "caustics2.png"); + } + + [Test] + public void Caustics3 () + { + CausticsEffect effect = new CausticsEffect (Utilities.CreateMockServices ()); + effect.Data.TimeOffset = 100.0; + Utilities.TestEffect (effect, "caustics3.png"); + } + + [Test] + public void Caustics4 () + { + CausticsEffect effect = new CausticsEffect (Utilities.CreateMockServices ()); + effect.Data.Samples = 10; + Utilities.TestEffect (effect, "caustics4.png"); + } + [Test] public void Cells1 () {