diff --git a/assets/textures/noise_100.png b/assets/textures/noise_100.png index e59457d..d7d5895 100644 Binary files a/assets/textures/noise_100.png and b/assets/textures/noise_100.png differ 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;