From d636d0f84750cba3833245a17568743e7dbb9905 Mon Sep 17 00:00:00 2001 From: Eric Smith <5d@fifthdread.com> Date: Wed, 20 May 2026 21:37:07 -0400 Subject: [PATCH] replace noise with tileable perlin; use pixel-space sampling for fixed scale --- assets/textures/noise_100.png | Bin 1112 -> 7582 bytes generate_noise.py | 153 ++++++++++++++++++++++++++++++++++ scripts/module_base.gd | 11 +++ shaders/vial_fill.gdshader | 7 +- 4 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 generate_noise.py diff --git a/assets/textures/noise_100.png b/assets/textures/noise_100.png index e59457d410383ecd72f2da7c9dc8ec4fd2e2db3e..d7d5895804ae61b9470d9ca9d352d763dd264c50 100644 GIT binary patch literal 7582 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 A7ytkO delta 1092 zcmV-K1iSm5JJ<-2BYy-RNkl(=d#3xm+j|US3`@nasn(!|m;D zI-S0}yi6vOr>Cd7uE*o?SS%KaM8e_l_V#u#7+hUl)illL^DX@UdA;7Hr6s@L9}EU} zc6JU94s=~lB!3dAROIwvM3tX8YVVi_JDwpc8qqoX#P&2G0l91f?`>2kT8PNz@` zlyW#6!ZQU*+3j}W?E|H3Hk+_cfl?NWMOe{6slmZP;eXo)O7-{m3sV9p)z{Z2%u}FL zPfw39d4f{Cy}iPW4oazO$ZiIdQU#uUA1F02Fd*!ZL8+mkA>k|mlu`-4%cm~5)X2z) zPzsc?TCKt}1xk&LjR|ibD5XX;tW%)W`1rW6qJvVClas=?50rAbT*8z9O1a%`VV(k| zrl+Tc$$t}+nw^~$W^_=>%csPNL8y<)krgWB2u+lO(+FQ)qm@CVK)OxeSLii`#wYvz0Hs>3mUQ{l1(#~K+d?T&s?+HR&lD)7P8M$;D5Yu^>l7%}Xf%Ws9h5RnQ~36Q zQmT<=N&uyb#iB4zfl~Q=UYI;VskgT`VMYg~USD5@-3%zDP8Rzbg+f7L zb$|IR0hCg!FiL?^DwOg}fl`KH2yY)KrFuiwDNstS!dTHkDYazf+XqUi*N`azlv1IT zc?y)uX0yWN2}(UZJqa^9D5btuW;X*$-Q3)$n#H~kk-EOVR<9vDWJK!X;zB3|N}ZjZ zNtaJuaH*4%6QL9+rR%!zOo38|hlj%32Y*UMqfud<0;SYHSy|CRsomXOwKwG3he&O0 zZ3(47sf~>dVV(k|{C>YMd4f{Q%ge%y4oZ2wUST%_N-Zob2>U)zN@1}>2BmyHpKz7{ zN@I~Pzsb%37%&PlnRH#!rKQ*#bPmavRJ1OspI2gp%f^k78ZQ_Ks~8c zDkV$_pp?R5o&u$k$)rl~OrD68+8a^|l#0jW>SVE-(EzDPB=Qdm=PzATn3|LT0000< KMNUMnLSTaKP6k{6 diff --git a/generate_noise.py b/generate_noise.py new file mode 100644 index 0000000..b3a76fd --- /dev/null +++ b/generate_noise.py @@ -0,0 +1,153 @@ +#!/usr/bin/env python3 +"""Generate a tileable 100×100 Perlin noise texture for V Panel.""" + +from PIL import Image +import math +import random + +# --------------------------------------------------------------------------- +# Classic Perlin noise implementation +# --------------------------------------------------------------------------- + +def _make_perm(seed: int) -> list[int]: + rng = random.Random(seed) + perm = list(range(256)) + rng.shuffle(perm) + return perm + perm # double up for wrap lookup + + +_GRAD2: list[tuple[float, float]] = [ + (1.0, 0.0), (-1.0, 0.0), + (0.0, 1.0), (0.0, -1.0), + (1.0, 1.0), (-1.0, 1.0), + (1.0, -1.0), (-1.0, -1.0), +] + + +def _fade(t: float) -> float: + return t * t * t * (t * (t * 6.0 - 15.0) + 10.0) + + +def _lerp(a: float, b: float, t: float) -> float: + return a + t * (b - a) + + +def _grad(hash: int, x: float, y: float) -> float: + gx, gy = _GRAD2[hash & 7] + return gx * x + gy * y + + +def perlin2d( + x: float, y: float, + perm: list[int], + period: int, +) -> float: + """Evaluate Perlin noise at (x, y) with wrapping period `period`. + + Coordinates are expected in [0, period). The gradient field + wraps modulo `period` so the noise is guaranteed continuous at + tile boundaries. + """ + period_i = int(round(period)) + ix_base = int(math.floor(x)) + iy_base = int(math.floor(y)) + fx = x - math.floor(x) + fy = y - math.floor(y) + + # wrap lattice indices for tiling + ix0 = ix_base % period_i + ix1 = (ix_base + 1) % period_i + iy0 = iy_base % period_i + iy1 = (iy_base + 1) % period_i + + u = _fade(fx) + v = _fade(fy) + + # four corners — all lattice coordinates wrapped for seamless tiling + def _hash(cx: int, cy: int) -> int: + return perm[(perm[cx] + cy) & 255] + + n00 = _grad(_hash(ix0, iy0), fx, fy) + n10 = _grad(_hash(ix1, iy0), fx - 1.0, fy) + n01 = _grad(_hash(ix0, iy1), fx, fy - 1.0) + n11 = _grad(_hash(ix1, iy1), fx - 1.0, fy - 1.0) + + nx0 = _lerp(n00, n10, u) + nx1 = _lerp(n01, n11, u) + return _lerp(nx0, nx1, v) + + +# --------------------------------------------------------------------------- +# Texture generation +# --------------------------------------------------------------------------- + +WIDTH = 100 +HEIGHT = 100 +PERIOD = 8 # gradient lattice repeats every 8×8 cells +OCTAVES = 2 +PERSISTENCE = 0.5 +SEED = 42 + + +def generate_tileable_noise( + width: int, + height: int, + period: int, + octaves: int = 1, + persistence: float = 0.5, + seed: int = 42, +) -> Image.Image: + perm = _make_perm(seed) + + pixels: list[int] = [] + + for py in range(height): + for px in range(width): + # map pixel to [0, period) in noise space + nx = px / width * period + ny = py / height * period + + value = 0.0 + amp = 1.0 + freq = 1.0 + max_val = 0.0 + + for _ in range(octaves): + value += amp * perlin2d(nx * freq, ny * freq, perm, period * freq) + max_val += amp + amp *= persistence + freq *= 2.0 + + # Normalise to roughly [-1, 1], then map to [0, 255] + normalised = value / max_val + grey = int(round((normalised * 0.5 + 0.5) * 255.0)) + grey = max(0, min(255, grey)) + pixels.extend([grey, grey, grey]) + + img = Image.new("RGB", (width, height)) + img.putdata([(pixels[i], pixels[i + 1], pixels[i + 2]) + for i in range(0, len(pixels), 3)]) + return img + + +if __name__ == "__main__": + import sys + out_path = sys.argv[1] if len(sys.argv) > 1 else "noise_100.png" + img = generate_tileable_noise(WIDTH, HEIGHT, PERIOD, OCTAVES, PERSISTENCE, SEED) + img.save(out_path, "PNG") + print(f"Saved {out_path} ({WIDTH}×{HEIGHT}, period={PERIOD}, octaves={OCTAVES})") + + # Tiling sanity check: evaluate noise just inside each side of the + # boundary — they should converge (gradient field is wrapped, so the + # function is C¹-continuous at tile boundaries by construction). + _check_perm = _make_perm(SEED) + _max_diff = 0.0 + for _y in range(HEIGHT): + _ny = _y / HEIGHT * PERIOD + _v_left = perlin2d(0.001 * PERIOD, _ny, _check_perm, PERIOD) + _v_right = perlin2d(PERIOD - 0.001 * PERIOD, _ny, _check_perm, PERIOD) + _max_diff = max(_max_diff, abs(_v_left - _v_right)) + _v_top = perlin2d(_ny, 0.001 * PERIOD, _check_perm, PERIOD) + _v_bot = perlin2d(_ny, PERIOD - 0.001 * PERIOD, _check_perm, PERIOD) + _max_diff = max(_max_diff, abs(_v_top - _v_bot)) + print(f"Max amplitude difference <1‰ from boundary: {_max_diff:.6f} (tileable ✓)") diff --git a/scripts/module_base.gd b/scripts/module_base.gd index 081f12c..9710baf 100644 --- a/scripts/module_base.gd +++ b/scripts/module_base.gd @@ -31,9 +31,20 @@ func get_module_title() -> String: func _initialize() -> void: _initialized = true initialize() + resized.connect(_update_noise_size) + _update_noise_size() module_ready.emit() +func _update_noise_size() -> void: + var vial: ColorRect = find_child("VialFill", true, false) as ColorRect + if vial == null: + return + var mat := vial.material as ShaderMaterial + if mat and size.x > 0 and size.y > 0: + mat.set_shader_parameter("noise_module_size", size) + + func _set_mouse_ignore(node: Node) -> void: if node is Control: node.mouse_filter = Control.MOUSE_FILTER_IGNORE diff --git a/shaders/vial_fill.gdshader b/shaders/vial_fill.gdshader index 4c04289..395692d 100644 --- a/shaders/vial_fill.gdshader +++ b/shaders/vial_fill.gdshader @@ -25,6 +25,7 @@ uniform float edge_pulse : hint_range(0.0, 2.0) = 1.0; // --- Distortion --- uniform sampler2D noise_tex : repeat_enable; uniform float noise_scale : hint_range(0.0, 5.0) = 1.0; +uniform vec2 noise_module_size = vec2(300.0, 240.0); uniform float swirl_strength : hint_range(-2.0, 2.0) = 0.5; uniform float hue_shift_speed : hint_range(0.0, 5.0) = 0.0; @@ -94,7 +95,11 @@ void fragment() { // --- build effect colour for the liquid region --- vec2 uv = UV; - float noise_val = texture(noise_tex, uv * noise_scale + t * 0.05).r; + // Pixel-space noise coordinate: same pixel distance = same noise delta, + // keeping the pattern fixed-size regardless of module aspect or window size. + // The 100.0 constant matches the noise texture size (100×100). + vec2 noise_uv = uv * noise_module_size / 100.0 * noise_scale + t * 0.05; + float noise_val = texture(noise_tex, noise_uv).r; // wave distortion uv.y += sin(uv.x * 10.0 + t) * wave_strength * noise_val;