From 3a2639bd5c0970f600fb17137cafe755514946ce Mon Sep 17 00:00:00 2001 From: agent Date: Tue, 27 Oct 2026 23:31:41 +0300 Subject: [PATCH 1/2] Math: add tileable noise and display gamma conversion TileableNoise builds a seeded permutation table and evaluates periodic 2D gradient noise with quintic interpolation. Fractal noise sums weighted octaves, increasing frequency and period together so the result tiles at every octave. gammaToLinear and linearToGamma provide clamped gamma 2.2 conversion for linear-light colour blending. Tests cover periodicity, bounds, seed variation, octave tiling, reference values and round trips. --- Math/private/Color.cpp | 16 ++++++++- Math/private/Noise.cpp | 81 ++++++++++++++++++++++++++++++++++++++++++ Math/public/Color.hpp | 5 +++ Math/public/Noise.hpp | 22 ++++++++++++ Math/tests/Tests.cpp | 61 +++++++++++++++++++++++++++++-- 5 files changed, 182 insertions(+), 3 deletions(-) create mode 100644 Math/private/Noise.cpp create mode 100644 Math/public/Noise.hpp diff --git a/Math/private/Color.cpp b/Math/private/Color.cpp index de167997..f3488e80 100644 --- a/Math/private/Color.cpp +++ b/Math/private/Color.cpp @@ -1,6 +1,8 @@ #include "Color.hpp" +#include + using namespace tp; RGB::RGB() { r = g = b = 1.f; } @@ -89,7 +91,7 @@ HSV::operator RGB() const { return out; } - hh = h / (PI2) *360; + hh = h / (PI2) * 360; if (hh >= 360.0) { hh = 0.0; @@ -139,6 +141,18 @@ HSV::operator RGB() const { return out; } +halnf tp::gammaToLinear(halnf encoded) { + if (encoded <= 0.0f) return 0.0f; + if (encoded >= 1.0f) return 1.0f; + return std::pow(encoded, kDisplayGamma); +} + +halnf tp::linearToGamma(halnf linear) { + if (linear <= 0.0f) return 0.0f; + if (linear >= 1.0f) return 1.0f; + return std::pow(linear, 1.0f / kDisplayGamma); +} + /* struct rgbab { diff --git a/Math/private/Noise.cpp b/Math/private/Noise.cpp new file mode 100644 index 00000000..13059b79 --- /dev/null +++ b/Math/private/Noise.cpp @@ -0,0 +1,81 @@ +#include "Noise.hpp" + +#include +#include + +namespace tp { + + namespace { + constexpr halnf kGradients[8][2] = { + { 1, 0 }, + { -1, 0 }, + { 0, 1 }, + { 0, -1 }, + { 0.7071f, 0.7071f }, + { -0.7071f, 0.7071f }, + { 0.7071f, -0.7071f }, + { -0.7071f, -0.7071f }, + }; + + halnf fade(halnf t) { return t * t * t * (t * (t * 6 - 15) + 10); } + + int4 wrap(int4 value, int4 period) { + const int4 remainder = value % period; + return remainder < 0 ? remainder + period : remainder; + } + + halnf dotGradient(int4 hash, halnf x, halnf y) { + const halnf* gradient = kGradients[hash & 7]; + return gradient[0] * x + gradient[1] * y; + } + } + + TileableNoise::TileableNoise(uint32_t seed) { + std::array order{}; + std::iota(order.begin(), order.end(), 0); + uint32_t state = seed * 747796405u + 2891336453u; + for (int4 index = 255; index > 0; index--) { + state = state * 1664525u + 1013904223u; + const int4 other = (int4) ((state >> 8) % (uint32_t) (index + 1)); + std::swap(order[index], order[other]); + } + for (int4 index = 0; index < 512; index++) + mPermutation[index] = order[index & 255]; + } + + int4 TileableNoise::hash(int4 x, int4 y) const { return mPermutation[mPermutation[x & 255] + (y & 255)]; } + + halnf TileableNoise::gradient(halnf x, halnf y, int4 periodX, int4 periodY) const { + const halnf floorX = std::floor(x); + const halnf floorY = std::floor(y); + const int4 x0 = wrap((int4) floorX, periodX); + const int4 y0 = wrap((int4) floorY, periodY); + const int4 x1 = wrap(x0 + 1, periodX); + const int4 y1 = wrap(y0 + 1, periodY); + const halnf fx = x - floorX; + const halnf fy = y - floorY; + + const halnf n00 = dotGradient(hash(x0, y0), fx, fy); + const halnf n10 = dotGradient(hash(x1, y0), fx - 1, fy); + const halnf n01 = dotGradient(hash(x0, y1), fx, fy - 1); + const halnf n11 = dotGradient(hash(x1, y1), fx - 1, fy - 1); + + const halnf u = fade(fx); + const halnf v = fade(fy); + const halnf top = n00 + (n10 - n00) * u; + const halnf bottom = n01 + (n11 - n01) * u; + return (top + (bottom - top) * v) * 1.4142f; + } + + halnf TileableNoise::fractal(halnf x, halnf y, int4 period, const halnf* weights, int4 octaves) const { + halnf sum = 0; + halnf total = 0; + int4 scale = 1; + for (int4 octave = 0; octave < octaves; octave++) { + sum += gradient(x * (halnf) scale, y * (halnf) scale, period * scale, period * scale) * weights[octave]; + total += weights[octave]; + scale *= 2; + } + return total > 0 ? sum / total : 0; + } +} diff --git a/Math/public/Color.hpp b/Math/public/Color.hpp index 7cebe3db..55574b65 100644 --- a/Math/public/Color.hpp +++ b/Math/public/Color.hpp @@ -106,4 +106,9 @@ namespace tp { HSV rgbs; flt4 a = 1.f; }; + + constexpr halnf kDisplayGamma = 2.2f; + + [[nodiscard]] halnf gammaToLinear(halnf encoded); + [[nodiscard]] halnf linearToGamma(halnf linear); } diff --git a/Math/public/Noise.hpp b/Math/public/Noise.hpp new file mode 100644 index 00000000..22336a9d --- /dev/null +++ b/Math/public/Noise.hpp @@ -0,0 +1,22 @@ +#pragma once + +#include "Environment.hpp" + +#include +#include + +namespace tp { + + class TileableNoise { + public: + explicit TileableNoise(uint32_t seed = 0); + + [[nodiscard]] halnf gradient(halnf x, halnf y, int4 periodX, int4 periodY) const; + [[nodiscard]] halnf fractal(halnf x, halnf y, int4 period, const halnf* weights, int4 octaves) const; + + private: + [[nodiscard]] int4 hash(int4 x, int4 y) const; + + std::array mPermutation{}; + }; +} diff --git a/Math/tests/Tests.cpp b/Math/tests/Tests.cpp index a5514495..5625baec 100644 --- a/Math/tests/Tests.cpp +++ b/Math/tests/Tests.cpp @@ -41,6 +41,63 @@ SUITE(Math) { } } -int main() { - return UnitTest::RunAllTests(); +int main() { return UnitTest::RunAllTests(); } + +#include "Color.hpp" +#include "Noise.hpp" + +#include + +SUITE(TileableNoise) { + TEST(NoiseTilesAcrossItsPeriod) { + const TileableNoise noise(7); + for (int index = 0; index < 20; index++) { + const halnf x = 0.37f * (halnf) index; + const halnf y = 1.13f + 0.21f * (halnf) index; + CHECK_CLOSE(noise.gradient(x, y, 8, 4), noise.gradient(x + 8, y, 8, 4), 1e-5f); + CHECK_CLOSE(noise.gradient(x, y, 8, 4), noise.gradient(x, y + 4, 8, 4), 1e-5f); + } + } + + TEST(NoiseIsZeroOnLatticeAndBounded) { + const TileableNoise noise(3); + CHECK_CLOSE(0.0f, noise.gradient(2, 5, 16, 16), 1e-6f); + halnf low = 0; + halnf high = 0; + for (int y = 0; y < 64; y++) { + for (int x = 0; x < 64; x++) { + const halnf value = noise.gradient((halnf) x * 0.13f, (halnf) y * 0.17f, 16, 16); + low = std::min(low, value); + high = std::max(high, value); + } + } + CHECK(low >= -1.0f && high <= 1.0f); + CHECK(high - low > 0.8f); + } + + TEST(SeedsChangeThePattern) { + const TileableNoise a(1); + const TileableNoise b(2); + halnf difference = 0; + for (int index = 0; index < 32; index++) + difference += std::fabs(a.gradient(0.3f * index, 0.7f, 32, 32) - b.gradient(0.3f * index, 0.7f, 32, 32)); + CHECK(difference > 1); + } + + TEST(FractalStaysTileableAtEveryOctave) { + const TileableNoise noise(5); + const halnf weights[] = { 1, 0.5f, 0.25f, 0.125f }; + CHECK_CLOSE(noise.fractal(0.4f, 0.6f, 4, weights, 4), noise.fractal(4.4f, 4.6f, 4, weights, 4), 1e-5f); + } +} + +SUITE(DisplayGamma) { + TEST(GammaRoundTripsAndMatchesReferenceTable) { + CHECK_CLOSE(169.0f / 16384.0f, gammaToLinear(0.125f), 2e-4f); + CHECK_CLOSE(6367.0f / 16384.0f, linearToGamma(0.125f), 2e-4f); + for (halnf value = 0; value <= 1.0f; value += 0.05f) + CHECK_CLOSE(value, linearToGamma(gammaToLinear(value)), 1e-5f); + CHECK_EQUAL(0.0f, gammaToLinear(-1)); + CHECK_EQUAL(1.0f, linearToGamma(2)); + } } -- 2.49.1 From 4171ee1d028da2093532accd05cb31c06ca94554 Mon Sep 17 00:00:00 2001 From: agent Date: Tue, 27 Oct 2026 23:31:41 +0300 Subject: [PATCH 2/2] Math: make spring animation explicit and channel based SpringValue advances by a caller-supplied delta in substeps of at most 1 ms instead of reading the global clock. It supports parameter sets, snapping, launching with initial velocity and a settled test based on distance and velocity. SpringChannels applies the same implementation to scalars, vectors, rectangles and colours, replacing the duplicated SpringVec and SpringRect paths. --- Math/public/SpringAnimations.hpp | 225 +++++++++++++++---------------- 1 file changed, 111 insertions(+), 114 deletions(-) diff --git a/Math/public/SpringAnimations.hpp b/Math/public/SpringAnimations.hpp index 0c8b4f21..20a58e46 100644 --- a/Math/public/SpringAnimations.hpp +++ b/Math/public/SpringAnimations.hpp @@ -2,154 +2,151 @@ #include "Color.hpp" #include "Rect.hpp" -#include "Timing.hpp" + +#include namespace tp { + + struct SpringParameters { + halnf stiffness = 0.0008f; + halnf damping = 0.0566f; + + static SpringParameters criticallyDamped(halnf responseMs) { + const halnf angularFrequency = 2.0f * 3.14159265f / responseMs; + return { angularFrequency * angularFrequency, 2.0f * angularFrequency }; + } + }; + class SpringValue { public: SpringValue() = default; - void updateCurrentPosition() { - const auto deltaTime = halnf(gCurrentTime - mPrevTime); - const auto deltaPos = mTargetPosition - mCurrentPosition; + explicit SpringValue(halnf value) : + mValue(value), + mTarget(value) {} - auto deltaVelocity = deltaPos * mSpringStiffness; - deltaVelocity -= mVelocity * (mVelocityDamping); + void setParameters(const SpringParameters& parameters) { mParameters = parameters; } - mVelocity += deltaVelocity; - mCurrentPosition += mVelocity * deltaTime; - mPrevTime = gCurrentTime; - } + void setTarget(halnf target) { mTarget = target; } - void setTargetPosition(halnf pos) { - mTargetPosition = pos; - if (mVelocity == 0) mPrevTime = gCurrentTime; - } - - [[nodiscard]] halnf getCurrentPos() const { return mCurrentPosition; } - [[nodiscard]] halnf getTargetPos() const { return mTargetPosition; } - [[nodiscard]] halnf getVelocity() const { return mVelocity; } - [[nodiscard]] halnf getDeltaPos() const { return mTargetPosition - mCurrentPosition; } - - void endAnimation() { - mCurrentPosition = mTargetPosition; + void snapTo(halnf value) { + mValue = value; + mTarget = value; mVelocity = 0; } + void snapToTarget() { snapTo(mTarget); } + + void launch(halnf value, halnf velocity, halnf target) { + mValue = value; + mVelocity = velocity; + mTarget = target; + } + + void step(halnf deltaMs) { + while (deltaMs > 0) { + const halnf substep = deltaMs < kMaxSubstepMs ? deltaMs : kMaxSubstepMs; + const halnf acceleration = mParameters.stiffness * (mTarget - mValue) - mParameters.damping * mVelocity; + + mVelocity += acceleration * substep; + mValue += mVelocity * substep; + deltaMs -= substep; + } + } + + [[nodiscard]] bool isSettled(halnf tolerance) const { + const bool closeToTarget = std::fabs(mTarget - mValue) <= tolerance; + const bool nearlyStill = std::fabs(mVelocity) * kVelocityHorizonMs <= tolerance; + return closeToTarget && nearlyStill; + } + + [[nodiscard]] halnf value() const { return mValue; } + [[nodiscard]] halnf target() const { return mTarget; } + [[nodiscard]] halnf velocity() const { return mVelocity; } + private: - time_ms mPrevTime = 0; - - halnf mTargetPosition = 0; - halnf mCurrentPosition = 0; + static constexpr halnf kMaxSubstepMs = 1.0f; + static constexpr halnf kVelocityHorizonMs = 16.0f; + SpringParameters mParameters; + halnf mValue = 0; + halnf mTarget = 0; halnf mVelocity = 0; - - halnf mSpringStiffness = 0.0041; - halnf mVelocityDamping = 0.25f; }; - class SpringVec { + template + class SpringChannels { public: - SpringVec() = default; - - void updateCurrentPosition() { - mPosX.updateCurrentPosition(); - mPosY.updateCurrentPosition(); + void setParameters(const SpringParameters& parameters) { + for (auto& channel : mChannels) + channel.setParameters(parameters); } - void setTargetPosition(Vec2F pos) { - mPosX.setTargetPosition(pos.x); - mPosY.setTargetPosition(pos.y); + void step(halnf deltaMs) { + for (auto& channel : mChannels) + channel.step(deltaMs); } - [[nodiscard]] Vec2F getCurrentPos() const { - return { mPosX.getCurrentPos(), mPosY.getCurrentPos() }; + void snapToTarget() { + for (auto& channel : mChannels) + channel.snapToTarget(); } - [[nodiscard]] Vec2F getTargetPos() const { - return { mPosX.getTargetPos(), mPosY.getTargetPos() }; + [[nodiscard]] bool isSettled(halnf tolerance) const { + for (const auto& channel : mChannels) { + if (!channel.isSettled(tolerance)) return false; + } + return true; } - [[nodiscard]] Vec2F getVelocity() const { - return { mPosX.getVelocity(), mPosY.getVelocity() }; + protected: + void setChannelTargets(const halnf (&targets)[ChannelCount]) { + for (int index = 0; index < ChannelCount; index++) + mChannels[index].setTarget(targets[index]); } - [[nodiscard]] bool checkAnimationShouldEnd() const { - halnf velocityEpsilon = 0.0001f; - halnf positionEpsilon = 1; - - const auto vel = abs(mPosY.getVelocity()) > velocityEpsilon || abs(mPosX.getVelocity()) > velocityEpsilon; - const auto pos = abs(mPosY.getDeltaPos()) > positionEpsilon || abs(mPosX.getDeltaPos()) > positionEpsilon; - - return !(pos || vel); + void snapChannels(const halnf (&values)[ChannelCount]) { + for (int index = 0; index < ChannelCount; index++) + mChannels[index].snapTo(values[index]); } - void endAnimation() { - mPosX.endAnimation(); - mPosY.endAnimation(); - } + [[nodiscard]] halnf channelValue(int index) const { return mChannels[index].value(); } + [[nodiscard]] halnf channelTarget(int index) const { return mChannels[index].target(); } private: - SpringValue mPosY; - SpringValue mPosX; + SpringValue mChannels[ChannelCount]; }; - class SpringRect { + class SpringVec2 : public SpringChannels<2> { public: - SpringRect() = default; + void setTarget(const Vec2F& target) { setChannelTargets({ target.x, target.y }); } + void snapTo(const Vec2F& value) { snapChannels({ value.x, value.y }); } - [[nodiscard]] RectF getTargetRect() const { - const auto start = mStartPos.getTargetPos(); - const auto end = mEndPos.getTargetPos(); - return { start, end - start }; - } - - [[nodiscard]] RectF getCurrentRect() const { - const auto start = mStartPos.getCurrentPos(); - const auto end = mEndPos.getCurrentPos(); - return { start, end - start }; - } - - [[nodiscard]] RGBA getCurrentColor() const { - const auto start = mStartPos.getCurrentPos(); - const auto end = mEndPos.getCurrentPos(); - return { start.x, start.y, end.x, end.y }; - } - - void setTargetRect(const RectF& rect) { - mStartPos.setTargetPosition(rect.p1()); - mEndPos.setTargetPosition(rect.p3()); - } - - void setTargetColor(const RGBA& color) { - mStartPos.setTargetPosition({ color.r, color.g }); - mEndPos.setTargetPosition({ color.b, color.a }); - } - - SpringVec& getStart() { - return mStartPos; - } - - SpringVec& getEnd() { - return mEndPos; - } - - void updateCurrentRect() { - mStartPos.updateCurrentPosition(); - mEndPos.updateCurrentPosition(); - } - - [[nodiscard]] bool shouldEndTransition() const { - return mStartPos.checkAnimationShouldEnd() && mEndPos.checkAnimationShouldEnd(); - } - - void endAnimation() { - mStartPos.endAnimation(); - mEndPos.endAnimation(); - } - - private: - SpringVec mStartPos; - SpringVec mEndPos; + [[nodiscard]] Vec2F value() const { return { channelValue(0), channelValue(1) }; } + [[nodiscard]] Vec2F target() const { return { channelTarget(0), channelTarget(1) }; } }; -} \ No newline at end of file + + class SpringRect : public SpringChannels<4> { + public: + void setTarget(const RectF& target) { setChannelTargets({ target.x, target.y, target.z, target.w }); } + void snapTo(const RectF& value) { snapChannels({ value.x, value.y, value.z, value.w }); } + + [[nodiscard]] RectF value() const { return { channelValue(0), channelValue(1), channelValue(2), channelValue(3) }; } + + [[nodiscard]] RectF target() const { + return { channelTarget(0), channelTarget(1), channelTarget(2), channelTarget(3) }; + } + }; + + class SpringColor : public SpringChannels<4> { + public: + void setTarget(const RGBA& target) { setChannelTargets({ target.r, target.g, target.b, target.a }); } + void snapTo(const RGBA& value) { snapChannels({ value.r, value.g, value.b, value.a }); } + + [[nodiscard]] RGBA value() const { return { channelValue(0), channelValue(1), channelValue(2), channelValue(3) }; } + + [[nodiscard]] RGBA target() const { + return { channelTarget(0), channelTarget(1), channelTarget(2), channelTarget(3) }; + } + }; +} -- 2.49.1